update Beets

This commit is contained in:
AdeHub
2024-08-24 16:44:41 +12:00
parent a63098a919
commit 046d4d82b4
116 changed files with 17353 additions and 9964 deletions
+8 -7
View File
@@ -13,28 +13,29 @@
# included in all copies or substantial portions of the Software.
import confuse
from sys import stderr
__version__ = '1.6.0'
__author__ = 'Adrian Sampson <adrian@radbox.org>'
import confuse
__version__ = "2.0.0"
__author__ = "Adrian Sampson <adrian@radbox.org>"
class IncludeLazyConfig(confuse.LazyConfig):
"""A version of Confuse's LazyConfig that also merges in data from
YAML files specified in an `include` setting.
"""
def read(self, user=True, defaults=True):
super().read(user, defaults)
try:
for view in self['include']:
for view in self["include"]:
self.set_file(view.as_filename())
except confuse.NotFoundError:
pass
except confuse.ConfigReadError as err:
stderr.write("configuration `import` failed: {}"
.format(err.reason))
stderr.write("configuration `import` failed: {}".format(err.reason))
config = IncludeLazyConfig('beets', __name__)
config = IncludeLazyConfig("beets", __name__)
+1
View File
@@ -18,6 +18,7 @@
import sys
from .ui import main
if __name__ == "__main__":
+108 -112
View File
@@ -17,21 +17,19 @@ music and items' embedded album art.
"""
import subprocess
import platform
from tempfile import NamedTemporaryFile
import os
from tempfile import NamedTemporaryFile
from beets.util import displayable_path, syspath, bytestring_path
from beets.util.artresizer import ArtResizer
import mediafile
from beets.util import bytestring_path, displayable_path, syspath
from beets.util.artresizer import ArtResizer
def mediafile_image(image_path, maxwidth=None):
"""Return a `mediafile.Image` object for the path.
"""
"""Return a `mediafile.Image` object for the path."""
with open(syspath(image_path), 'rb') as f:
with open(syspath(image_path), "rb") as f:
data = f.read()
return mediafile.Image(data, type=mediafile.ImageType.front)
@@ -41,170 +39,168 @@ def get_art(log, item):
try:
mf = mediafile.MediaFile(syspath(item.path))
except mediafile.UnreadableFileError as exc:
log.warning('Could not extract art from {0}: {1}',
displayable_path(item.path), exc)
log.warning(
"Could not extract art from {0}: {1}",
displayable_path(item.path),
exc,
)
return
return mf.art
def embed_item(log, item, imagepath, maxwidth=None, itempath=None,
compare_threshold=0, ifempty=False, as_album=False, id3v23=None,
quality=0):
"""Embed an image into the item's media file.
"""
# Conditions and filters.
def embed_item(
log,
item,
imagepath,
maxwidth=None,
itempath=None,
compare_threshold=0,
ifempty=False,
as_album=False,
id3v23=None,
quality=0,
):
"""Embed an image into the item's media file."""
# Conditions.
if compare_threshold:
if not check_art_similarity(log, item, imagepath, compare_threshold):
log.info('Image not similar; skipping.')
is_similar = check_art_similarity(
log, item, imagepath, compare_threshold
)
if is_similar is None:
log.warning("Error while checking art similarity; skipping.")
return
elif not is_similar:
log.info("Image not similar; skipping.")
return
if ifempty and get_art(log, item):
log.info('media file already contained art')
log.info("media file already contained art")
return
# Filters.
if maxwidth and not as_album:
imagepath = resize_image(log, imagepath, maxwidth, quality)
# Get the `Image` object from the file.
try:
log.debug('embedding {0}', displayable_path(imagepath))
log.debug("embedding {0}", displayable_path(imagepath))
image = mediafile_image(imagepath, maxwidth)
except OSError as exc:
log.warning('could not read image file: {0}', exc)
log.warning("could not read image file: {0}", exc)
return
# Make sure the image kind is safe (some formats only support PNG
# and JPEG).
if image.mime_type not in ('image/jpeg', 'image/png'):
log.info('not embedding image of unsupported type: {}',
image.mime_type)
if image.mime_type not in ("image/jpeg", "image/png"):
log.info("not embedding image of unsupported type: {}", image.mime_type)
return
item.try_write(path=itempath, tags={'images': [image]}, id3v23=id3v23)
item.try_write(path=itempath, tags={"images": [image]}, id3v23=id3v23)
def embed_album(log, album, maxwidth=None, quiet=False, compare_threshold=0,
ifempty=False, quality=0):
"""Embed album art into all of the album's items.
"""
def embed_album(
log,
album,
maxwidth=None,
quiet=False,
compare_threshold=0,
ifempty=False,
quality=0,
):
"""Embed album art into all of the album's items."""
imagepath = album.artpath
if not imagepath:
log.info('No album art present for {0}', album)
log.info("No album art present for {0}", album)
return
if not os.path.isfile(syspath(imagepath)):
log.info('Album art not found at {0} for {1}',
displayable_path(imagepath), album)
log.info(
"Album art not found at {0} for {1}",
displayable_path(imagepath),
album,
)
return
if maxwidth:
imagepath = resize_image(log, imagepath, maxwidth, quality)
log.info('Embedding album art into {0}', album)
log.info("Embedding album art into {0}", album)
for item in album.items():
embed_item(log, item, imagepath, maxwidth, None, compare_threshold,
ifempty, as_album=True, quality=quality)
embed_item(
log,
item,
imagepath,
maxwidth,
None,
compare_threshold,
ifempty,
as_album=True,
quality=quality,
)
def resize_image(log, imagepath, maxwidth, quality):
"""Returns path to an image resized to maxwidth and encoded with the
specified quality level.
"""
log.debug('Resizing album art to {0} pixels wide and encoding at quality \
level {1}', maxwidth, quality)
imagepath = ArtResizer.shared.resize(maxwidth, syspath(imagepath),
quality=quality)
log.debug(
"Resizing album art to {0} pixels wide and encoding at quality \
level {1}",
maxwidth,
quality,
)
imagepath = ArtResizer.shared.resize(
maxwidth, syspath(imagepath), quality=quality
)
return imagepath
def check_art_similarity(log, item, imagepath, compare_threshold):
def check_art_similarity(
log,
item,
imagepath,
compare_threshold,
artresizer=None,
):
"""A boolean indicating if an image is similar to embedded item art.
If no embedded art exists, always return `True`. If the comparison fails
for some reason, the return value is `None`.
This must only be called if `ArtResizer.shared.can_compare` is `True`.
"""
with NamedTemporaryFile(delete=True) as f:
art = extract(log, f.name, item)
if art:
is_windows = platform.system() == "Windows"
if not art:
return True
# Converting images to grayscale tends to minimize the weight
# of colors in the diff score. So we first convert both images
# to grayscale and then pipe them into the `compare` command.
# On Windows, ImageMagick doesn't support the magic \\?\ prefix
# on paths, so we pass `prefix=False` to `syspath`.
convert_cmd = ['convert', syspath(imagepath, prefix=False),
syspath(art, prefix=False),
'-colorspace', 'gray', 'MIFF:-']
compare_cmd = ['compare', '-metric', 'PHASH', '-', 'null:']
log.debug('comparing images with pipeline {} | {}',
convert_cmd, compare_cmd)
convert_proc = subprocess.Popen(
convert_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
compare_proc = subprocess.Popen(
compare_cmd,
stdin=convert_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
if artresizer is None:
artresizer = ArtResizer.shared
# Check the convert output. We're not interested in the
# standard output; that gets piped to the next stage.
convert_proc.stdout.close()
convert_stderr = convert_proc.stderr.read()
convert_proc.stderr.close()
convert_proc.wait()
if convert_proc.returncode:
log.debug(
'ImageMagick convert failed with status {}: {!r}',
convert_proc.returncode,
convert_stderr,
)
return
# Check the compare output.
stdout, stderr = compare_proc.communicate()
if compare_proc.returncode:
if compare_proc.returncode != 1:
log.debug('ImageMagick compare failed: {0}, {1}',
displayable_path(imagepath),
displayable_path(art))
return
out_str = stderr
else:
out_str = stdout
try:
phash_diff = float(out_str)
except ValueError:
log.debug('IM output is not a number: {0!r}', out_str)
return
log.debug('ImageMagick compare score: {0}', phash_diff)
return phash_diff <= compare_threshold
return True
return artresizer.compare(art, imagepath, compare_threshold)
def extract(log, outpath, item):
art = get_art(log, item)
outpath = bytestring_path(outpath)
if not art:
log.info('No album art present in {0}, skipping.', item)
log.info("No album art present in {0}, skipping.", item)
return
# Add an extension to the filename.
ext = mediafile.image_extension(art)
if not ext:
log.warning('Unknown image type in {0}.',
displayable_path(item.path))
log.warning("Unknown image type in {0}.", displayable_path(item.path))
return
outpath += bytestring_path('.' + ext)
outpath += bytestring_path("." + ext)
log.info('Extracting album art from: {0} to: {1}',
item, displayable_path(outpath))
with open(syspath(outpath), 'wb') as f:
log.info(
"Extracting album art from: {0} to: {1}",
item,
displayable_path(outpath),
)
with open(syspath(outpath), "wb") as f:
f.write(art)
return outpath
@@ -218,7 +214,7 @@ def extract_first(log, outpath, items):
def clear(log, lib, query):
items = lib.items(query)
log.info('Clearing album art from {0} items', len(items))
log.info("Clearing album art from {0} items", len(items))
for item in items:
log.debug('Clearing art for {0}', item)
item.try_write(tags={'images': None})
log.debug("Clearing art for {0}", item)
item.try_write(tags={"images": None})
+99 -62
View File
@@ -14,78 +14,91 @@
"""Facilities for automatically determining files' correct metadata.
"""
from typing import Mapping
from beets import logging
from beets import config
from beets import config, logging
from beets.library import Item
# Parts of external interface.
from .hooks import ( # noqa
AlbumInfo,
TrackInfo,
AlbumMatch,
TrackMatch,
Distance,
TrackInfo,
TrackMatch,
)
from .match import tag_item, tag_album, Proposal # noqa
from .match import Recommendation # noqa
from .match import Proposal, current_metadata, tag_album, tag_item # noqa
# Global logger.
log = logging.getLogger('beets')
log = logging.getLogger("beets")
# Metadata fields that are already hardcoded, or where the tag name changes.
SPECIAL_FIELDS = {
'album': (
'va',
'releasegroup_id',
'artist_id',
'album_id',
'mediums',
'tracks',
'year',
'month',
'day',
'artist',
'artist_credit',
'artist_sort',
'data_url'
"album": (
"va",
"releasegroup_id",
"artist_id",
"artists_ids",
"album_id",
"mediums",
"tracks",
"year",
"month",
"day",
"artist",
"artists",
"artist_credit",
"artists_credit",
"artist_sort",
"artists_sort",
"data_url",
),
"track": (
"track_alt",
"artist_id",
"artists_ids",
"release_track_id",
"medium",
"index",
"medium_index",
"title",
"artist_credit",
"artists_credit",
"artist_sort",
"artists_sort",
"artist",
"artists",
"track_id",
"medium_total",
"data_url",
"length",
),
'track': (
'track_alt',
'artist_id',
'release_track_id',
'medium',
'index',
'medium_index',
'title',
'artist_credit',
'artist_sort',
'artist',
'track_id',
'medium_total',
'data_url',
'length'
)
}
# Additional utilities for the main interface.
def apply_item_metadata(item, track_info):
"""Set an item's metadata from its matched TrackInfo object.
"""
def apply_item_metadata(item: Item, track_info: TrackInfo):
"""Set an item's metadata from its matched TrackInfo object."""
item.artist = track_info.artist
item.artists = track_info.artists
item.artist_sort = track_info.artist_sort
item.artists_sort = track_info.artists_sort
item.artist_credit = track_info.artist_credit
item.artists_credit = track_info.artists_credit
item.title = track_info.title
item.mb_trackid = track_info.track_id
item.mb_releasetrackid = track_info.release_track_id
if track_info.artist_id:
item.mb_artistid = track_info.artist_id
if track_info.artists_ids:
item.mb_artistids = track_info.artists_ids
for field, value in track_info.items():
# We only overwrite fields that are not already hardcoded.
if field in SPECIAL_FIELDS['track']:
if field in SPECIAL_FIELDS["track"]:
continue
if value is None:
continue
@@ -95,45 +108,62 @@ def apply_item_metadata(item, track_info):
# and track number). Perhaps these should be emptied?
def apply_metadata(album_info, mapping):
def apply_metadata(album_info: AlbumInfo, mapping: Mapping[Item, TrackInfo]):
"""Set the items' metadata to match an AlbumInfo object using a
mapping from Items to TrackInfo objects.
"""
for item, track_info in mapping.items():
# Artist or artist credit.
if config['artist_credit']:
item.artist = (track_info.artist_credit or
track_info.artist or
album_info.artist_credit or
album_info.artist)
item.albumartist = (album_info.artist_credit or
album_info.artist)
if config["artist_credit"]:
item.artist = (
track_info.artist_credit
or track_info.artist
or album_info.artist_credit
or album_info.artist
)
item.artists = (
track_info.artists_credit
or track_info.artists
or album_info.artists_credit
or album_info.artists
)
item.albumartist = album_info.artist_credit or album_info.artist
item.albumartists = album_info.artists_credit or album_info.artists
else:
item.artist = (track_info.artist or album_info.artist)
item.artist = track_info.artist or album_info.artist
item.artists = track_info.artists or album_info.artists
item.albumartist = album_info.artist
item.albumartists = album_info.artists
# Album.
item.album = album_info.album
# Artist sort and credit names.
item.artist_sort = track_info.artist_sort or album_info.artist_sort
item.artist_credit = (track_info.artist_credit or
album_info.artist_credit)
item.artists_sort = track_info.artists_sort or album_info.artists_sort
item.artist_credit = (
track_info.artist_credit or album_info.artist_credit
)
item.artists_credit = (
track_info.artists_credit or album_info.artists_credit
)
item.albumartist_sort = album_info.artist_sort
item.albumartists_sort = album_info.artists_sort
item.albumartist_credit = album_info.artist_credit
item.albumartists_credit = album_info.artists_credit
# Release date.
for prefix in '', 'original_':
if config['original_date'] and not prefix:
for prefix in "", "original_":
if config["original_date"] and not prefix:
# Ignore specific release date.
continue
for suffix in 'year', 'month', 'day':
for suffix in "year", "month", "day":
key = prefix + suffix
value = getattr(album_info, key) or 0
# If we don't even have a year, apply nothing.
if suffix == 'year' and not value:
if suffix == "year" and not value:
break
# Otherwise, set the fetched value (or 0 for the month
@@ -142,13 +172,13 @@ def apply_metadata(album_info, mapping):
# If we're using original release date for both fields,
# also set item.year = info.original_year, etc.
if config['original_date']:
if config["original_date"]:
item[suffix] = value
# Title.
item.title = track_info.title
if config['per_disc_numbering']:
if config["per_disc_numbering"]:
# We want to let the track number be zero, but if the medium index
# is not provided we need to fall back to the overall index.
if track_info.medium_index is not None:
@@ -172,7 +202,14 @@ def apply_metadata(album_info, mapping):
item.mb_artistid = track_info.artist_id
else:
item.mb_artistid = album_info.artist_id
if track_info.artists_ids:
item.mb_artistids = track_info.artists_ids
else:
item.mb_artistids = album_info.artists_ids
item.mb_albumartistid = album_info.artist_id
item.mb_albumartistids = album_info.artists_ids
item.mb_releasegroupid = album_info.releasegroup_id
# Compilation flag.
@@ -184,17 +221,17 @@ def apply_metadata(album_info, mapping):
# Don't overwrite fields with empty values unless the
# field is explicitly allowed to be overwritten
for field, value in album_info.items():
if field in SPECIAL_FIELDS['album']:
if field in SPECIAL_FIELDS["album"]:
continue
clobber = field in config['overwrite_null']['album'].as_str_seq()
clobber = field in config["overwrite_null"]["album"].as_str_seq()
if value is None and not clobber:
continue
item[field] = value
for field, value in track_info.items():
if field in SPECIAL_FIELDS['track']:
if field in SPECIAL_FIELDS["track"]:
continue
clobber = field in config['overwrite_null']['track'].as_str_seq()
clobber = field in config["overwrite_null"]["track"].as_str_seq()
value = getattr(track_info, field)
if value is None and not clobber:
continue
+242 -184
View File
@@ -14,40 +14,51 @@
"""Glue between metadata sources and the matching logic."""
from __future__ import annotations
import re
from collections import namedtuple
from functools import total_ordering
import re
from typing import (
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Optional,
Tuple,
TypeVar,
Union,
cast,
)
from beets import logging
from beets import plugins
from beets import config
from beets.util import as_string
from beets.autotag import mb
from jellyfish import levenshtein_distance
from unidecode import unidecode
log = logging.getLogger('beets')
from beets import config, logging, plugins
from beets.autotag import mb
from beets.library import Item
from beets.util import as_string, cached_classproperty
# The name of the type for patterns in re changed in Python 3.7.
try:
Pattern = re._pattern_type
except AttributeError:
Pattern = re.Pattern
log = logging.getLogger("beets")
V = TypeVar("V")
# Classes used to represent candidate options.
class AttrDict(dict):
class AttrDict(Dict[str, V]):
"""A dictionary that supports attribute ("dot") access, so `d.field`
is equivalent to `d['field']`.
"""
def __getattr__(self, attr):
def __getattr__(self, attr: str) -> V:
if attr in self:
return self.get(attr)
return self[attr]
else:
raise AttributeError
def __setattr__(self, key, value):
def __setattr__(self, key: str, value: V):
self.__setitem__(key, value)
def __hash__(self):
@@ -68,32 +79,73 @@ class AlbumInfo(AttrDict):
The others are optional and may be None.
"""
def __init__(self, tracks, album=None, album_id=None, artist=None,
artist_id=None, asin=None, albumtype=None, va=False,
year=None, month=None, day=None, label=None, mediums=None,
artist_sort=None, releasegroup_id=None, catalognum=None,
script=None, language=None, country=None, style=None,
genre=None, albumstatus=None, media=None, albumdisambig=None,
releasegroupdisambig=None, artist_credit=None,
original_year=None, original_month=None,
original_day=None, data_source=None, data_url=None,
discogs_albumid=None, discogs_labelid=None,
discogs_artistid=None, **kwargs):
# TYPING: are all of these correct? I've assumed optional strings
def __init__(
self,
tracks: List[TrackInfo],
album: Optional[str] = None,
album_id: Optional[str] = None,
artist: Optional[str] = None,
artist_id: Optional[str] = None,
artists: Optional[List[str]] = None,
artists_ids: Optional[List[str]] = None,
asin: Optional[str] = None,
albumtype: Optional[str] = None,
albumtypes: Optional[List[str]] = None,
va: bool = False,
year: Optional[int] = None,
month: Optional[int] = None,
day: Optional[int] = None,
label: Optional[str] = None,
barcode: Optional[str] = None,
mediums: Optional[int] = None,
artist_sort: Optional[str] = None,
artists_sort: Optional[List[str]] = None,
releasegroup_id: Optional[str] = None,
release_group_title: Optional[str] = None,
catalognum: Optional[str] = None,
script: Optional[str] = None,
language: Optional[str] = None,
country: Optional[str] = None,
style: Optional[str] = None,
genre: Optional[str] = None,
albumstatus: Optional[str] = None,
media: Optional[str] = None,
albumdisambig: Optional[str] = None,
releasegroupdisambig: Optional[str] = None,
artist_credit: Optional[str] = None,
artists_credit: Optional[List[str]] = None,
original_year: Optional[int] = None,
original_month: Optional[int] = None,
original_day: Optional[int] = None,
data_source: Optional[str] = None,
data_url: Optional[str] = None,
discogs_albumid: Optional[str] = None,
discogs_labelid: Optional[str] = None,
discogs_artistid: Optional[str] = None,
**kwargs,
):
self.album = album
self.album_id = album_id
self.artist = artist
self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
self.tracks = tracks
self.asin = asin
self.albumtype = albumtype
self.albumtypes = albumtypes or []
self.va = va
self.year = year
self.month = month
self.day = day
self.label = label
self.barcode = barcode
self.mediums = mediums
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.releasegroup_id = releasegroup_id
self.release_group_title = release_group_title
self.catalognum = catalognum
self.script = script
self.language = language
@@ -105,6 +157,7 @@ class AlbumInfo(AttrDict):
self.albumdisambig = albumdisambig
self.releasegroupdisambig = releasegroupdisambig
self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.original_year = original_year
self.original_month = original_month
self.original_day = original_day
@@ -115,27 +168,7 @@ class AlbumInfo(AttrDict):
self.discogs_artistid = discogs_artistid
self.update(kwargs)
# Work around a bug in python-musicbrainz-ngs that causes some
# strings to be bytes rather than Unicode.
# https://github.com/alastair/python-musicbrainz-ngs/issues/85
def decode(self, codec='utf-8'):
"""Ensure that all string attributes on this object, and the
constituent `TrackInfo` objects, are decoded to Unicode.
"""
for fld in ['album', 'artist', 'albumtype', 'label', 'artist_sort',
'catalognum', 'script', 'language', 'country', 'style',
'genre', 'albumstatus', 'albumdisambig',
'releasegroupdisambig', 'artist_credit',
'media', 'discogs_albumid', 'discogs_labelid',
'discogs_artistid']:
value = getattr(self, fld)
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
for track in self.tracks:
track.decode(codec)
def copy(self):
def copy(self) -> AlbumInfo:
dupe = AlbumInfo([])
dupe.update(self)
dupe.tracks = [track.copy() for track in self.tracks]
@@ -154,20 +187,50 @@ class TrackInfo(AttrDict):
are all 1-based.
"""
def __init__(self, title=None, track_id=None, release_track_id=None,
artist=None, artist_id=None, length=None, index=None,
medium=None, medium_index=None, medium_total=None,
artist_sort=None, disctitle=None, artist_credit=None,
data_source=None, data_url=None, media=None, lyricist=None,
composer=None, composer_sort=None, arranger=None,
track_alt=None, work=None, mb_workid=None,
work_disambig=None, bpm=None, initial_key=None, genre=None,
**kwargs):
# TYPING: are all of these correct? I've assumed optional strings
def __init__(
self,
title: Optional[str] = None,
track_id: Optional[str] = None,
release_track_id: Optional[str] = None,
artist: Optional[str] = None,
artist_id: Optional[str] = None,
artists: Optional[List[str]] = None,
artists_ids: Optional[List[str]] = None,
length: Optional[float] = None,
index: Optional[int] = None,
medium: Optional[int] = None,
medium_index: Optional[int] = None,
medium_total: Optional[int] = None,
artist_sort: Optional[str] = None,
artists_sort: Optional[List[str]] = None,
disctitle: Optional[str] = None,
artist_credit: Optional[str] = None,
artists_credit: Optional[List[str]] = None,
data_source: Optional[str] = None,
data_url: Optional[str] = None,
media: Optional[str] = None,
lyricist: Optional[str] = None,
composer: Optional[str] = None,
composer_sort: Optional[str] = None,
arranger: Optional[str] = None,
track_alt: Optional[str] = None,
work: Optional[str] = None,
mb_workid: Optional[str] = None,
work_disambig: Optional[str] = None,
bpm: Optional[str] = None,
initial_key: Optional[str] = None,
genre: Optional[str] = None,
album: Optional[str] = None,
**kwargs,
):
self.title = title
self.track_id = track_id
self.release_track_id = release_track_id
self.artist = artist
self.artist_id = artist_id
self.artists = artists or []
self.artists_ids = artists_ids or []
self.length = length
self.index = index
self.media = media
@@ -175,8 +238,10 @@ class TrackInfo(AttrDict):
self.medium_index = medium_index
self.medium_total = medium_total
self.artist_sort = artist_sort
self.artists_sort = artists_sort or []
self.disctitle = disctitle
self.artist_credit = artist_credit
self.artists_credit = artists_credit or []
self.data_source = data_source
self.data_url = data_url
self.lyricist = lyricist
@@ -190,20 +255,10 @@ class TrackInfo(AttrDict):
self.bpm = bpm
self.initial_key = initial_key
self.genre = genre
self.album = album
self.update(kwargs)
# As above, work around a bug in python-musicbrainz-ngs.
def decode(self, codec='utf-8'):
"""Ensure that all string attributes on this object are decoded
to Unicode.
"""
for fld in ['title', 'artist', 'medium', 'artist_sort', 'disctitle',
'artist_credit', 'media']:
value = getattr(self, fld)
if isinstance(value, bytes):
setattr(self, fld, value.decode(codec, 'ignore'))
def copy(self):
def copy(self) -> TrackInfo:
dupe = TrackInfo()
dupe.update(self)
return dupe
@@ -213,23 +268,23 @@ class TrackInfo(AttrDict):
# Parameters for string distance function.
# Words that can be moved to the end of a string using a comma.
SD_END_WORDS = ['the', 'a', 'an']
SD_END_WORDS = ["the", "a", "an"]
# Reduced weights for certain portions of the string.
SD_PATTERNS = [
(r'^the ', 0.1),
(r'[\[\(]?(ep|single)[\]\)]?', 0.0),
(r'[\[\(]?(featuring|feat|ft)[\. :].+', 0.1),
(r'\(.*?\)', 0.3),
(r'\[.*?\]', 0.3),
(r'(, )?(pt\.|part) .+', 0.2),
(r"^the ", 0.1),
(r"[\[\(]?(ep|single)[\]\)]?", 0.0),
(r"[\[\(]?(featuring|feat|ft)[\. :].+", 0.1),
(r"\(.*?\)", 0.3),
(r"\[.*?\]", 0.3),
(r"(, )?(pt\.|part) .+", 0.2),
]
# Replacements to use before testing distance.
SD_REPLACE = [
(r'&', 'and'),
(r"&", "and"),
]
def _string_dist_basic(str1, str2):
def _string_dist_basic(str1: str, str2: str) -> float:
"""Basic edit distance between two strings, ignoring
non-alphanumeric characters and case. Comparisons are based on a
transliteration/lowering to ASCII characters. Normalized by string
@@ -239,14 +294,14 @@ def _string_dist_basic(str1, str2):
assert isinstance(str2, str)
str1 = as_string(unidecode(str1))
str2 = as_string(unidecode(str2))
str1 = re.sub(r'[^a-z0-9]', '', str1.lower())
str2 = re.sub(r'[^a-z0-9]', '', str2.lower())
str1 = re.sub(r"[^a-z0-9]", "", str1.lower())
str2 = re.sub(r"[^a-z0-9]", "", str2.lower())
if not str1 and not str2:
return 0.0
return levenshtein_distance(str1, str2) / float(max(len(str1), len(str2)))
def string_dist(str1, str2):
def string_dist(str1: Optional[str], str2: Optional[str]) -> float:
"""Gives an "intuitive" edit distance between two strings. This is
an edit distance, normalized by the string length, with a number of
tweaks that reflect intuition about text.
@@ -263,10 +318,10 @@ def string_dist(str1, str2):
# example, "the something" should be considered equal to
# "something, the".
for word in SD_END_WORDS:
if str1.endswith(', %s' % word):
str1 = '{} {}'.format(word, str1[:-len(word) - 2])
if str2.endswith(', %s' % word):
str2 = '{} {}'.format(word, str2[:-len(word) - 2])
if str1.endswith(", %s" % word):
str1 = "{} {}".format(word, str1[: -len(word) - 2])
if str2.endswith(", %s" % word):
str2 = "{} {}".format(word, str2[: -len(word) - 2])
# Perform a couple of basic normalizing substitutions.
for pat, repl in SD_REPLACE:
@@ -281,8 +336,8 @@ def string_dist(str1, str2):
penalty = 0.0
for pat, weight in SD_PATTERNS:
# Get strings that drop the pattern.
case_str1 = re.sub(pat, '', str1)
case_str2 = re.sub(pat, '', str2)
case_str1 = re.sub(pat, "", str1)
case_str2 = re.sub(pat, "", str2)
if case_str1 != str1 or case_str2 != str2:
# If the pattern was present (i.e., it is deleted in the
@@ -304,23 +359,6 @@ def string_dist(str1, str2):
return base_dist + penalty
class LazyClassProperty:
"""A decorator implementing a read-only property that is *lazy* in
the sense that the getter is only invoked once. Subsequent accesses
through *any* instance use the cached result.
"""
def __init__(self, getter):
self.getter = getter
self.computed = False
def __get__(self, obj, owner):
if not self.computed:
self.value = self.getter(owner)
self.computed = True
return self.value
@total_ordering
class Distance:
"""Keeps track of multiple distance penalties. Provides a single
@@ -330,12 +368,12 @@ class Distance:
def __init__(self):
self._penalties = {}
self.tracks: Dict[TrackInfo, Distance] = {}
@LazyClassProperty
def _weights(cls): # noqa: N805
"""A dictionary from keys to floating-point weights.
"""
weights_view = config['match']['distance_weights']
@cached_classproperty
def _weights(cls) -> Dict[str, float]: # noqa: N805
"""A dictionary from keys to floating-point weights."""
weights_view = config["match"]["distance_weights"]
weights = {}
for key in weights_view.keys():
weights[key] = weights_view[key].as_number()
@@ -344,7 +382,7 @@ class Distance:
# Access the components and their aggregates.
@property
def distance(self):
def distance(self) -> float:
"""Return a weighted and normalized distance across all
penalties.
"""
@@ -354,24 +392,22 @@ class Distance:
return 0.0
@property
def max_distance(self):
"""Return the maximum distance penalty (normalization factor).
"""
def max_distance(self) -> float:
"""Return the maximum distance penalty (normalization factor)."""
dist_max = 0.0
for key, penalty in self._penalties.items():
dist_max += len(penalty) * self._weights[key]
return dist_max
@property
def raw_distance(self):
"""Return the raw (denormalized) distance.
"""
def raw_distance(self) -> float:
"""Return the raw (denormalized) distance."""
dist_raw = 0.0
for key, penalty in self._penalties.items():
dist_raw += sum(penalty) * self._weights[key]
return dist_raw
def items(self):
def items(self) -> List[Tuple[str, float]]:
"""Return a list of (key, dist) pairs, with `dist` being the
weighted distance, sorted from highest to lowest. Does not
include penalties with a zero value.
@@ -385,87 +421,88 @@ class Distance:
# ascending order (for keys, when the penalty is equal) and
# still get the items with the biggest distance first.
return sorted(
list_,
key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
list_, key=lambda key_and_dist: (-key_and_dist[1], key_and_dist[0])
)
def __hash__(self):
def __hash__(self) -> int:
return id(self)
def __eq__(self, other):
def __eq__(self, other) -> bool:
return self.distance == other
# Behave like a float.
def __lt__(self, other):
def __lt__(self, other) -> bool:
return self.distance < other
def __float__(self):
def __float__(self) -> float:
return self.distance
def __sub__(self, other):
def __sub__(self, other) -> float:
return self.distance - other
def __rsub__(self, other):
def __rsub__(self, other) -> float:
return other - self.distance
def __str__(self):
def __str__(self) -> str:
return f"{self.distance:.2f}"
# Behave like a dict.
def __getitem__(self, key):
"""Returns the weighted distance for a named penalty.
"""
def __getitem__(self, key) -> float:
"""Returns the weighted distance for a named penalty."""
dist = sum(self._penalties[key]) * self._weights[key]
dist_max = self.max_distance
if dist_max:
return dist / dist_max
return 0.0
def __iter__(self):
def __iter__(self) -> Iterator[Tuple[str, float]]:
return iter(self.items())
def __len__(self):
def __len__(self) -> int:
return len(self.items())
def keys(self):
def keys(self) -> List[str]:
return [key for key, _ in self.items()]
def update(self, dist):
"""Adds all the distance penalties from `dist`.
"""
def update(self, dist: "Distance"):
"""Adds all the distance penalties from `dist`."""
if not isinstance(dist, Distance):
raise ValueError(
'`dist` must be a Distance object, not {}'.format(type(dist))
"`dist` must be a Distance object, not {}".format(type(dist))
)
for key, penalties in dist._penalties.items():
self._penalties.setdefault(key, []).extend(penalties)
# Adding components.
def _eq(self, value1, value2):
def _eq(self, value1: Union[re.Pattern[str], Any], value2: Any) -> bool:
"""Returns True if `value1` is equal to `value2`. `value1` may
be a compiled regular expression, in which case it will be
matched against `value2`.
"""
if isinstance(value1, Pattern):
if isinstance(value1, re.Pattern):
value2 = cast(str, value2)
return bool(value1.match(value2))
return value1 == value2
def add(self, key, dist):
def add(self, key: str, dist: float):
"""Adds a distance penalty. `key` must correspond with a
configured weight setting. `dist` must be a float between 0.0
and 1.0, and will be added to any existing distance penalties
for the same key.
"""
if not 0.0 <= dist <= 1.0:
raise ValueError(
f'`dist` must be between 0.0 and 1.0, not {dist}'
)
raise ValueError(f"`dist` must be between 0.0 and 1.0, not {dist}")
self._penalties.setdefault(key, []).append(dist)
def add_equality(self, key, value, options):
def add_equality(
self,
key: str,
value: Any,
options: Union[List[Any], Tuple[Any, ...], Any],
):
"""Adds a distance penalty of 1.0 if `value` doesn't match any
of the values in `options`. If an option is a compiled regular
expression, it will be considered equal if it matches against
@@ -481,7 +518,7 @@ class Distance:
dist = 1.0
self.add(key, dist)
def add_expr(self, key, expr):
def add_expr(self, key: str, expr: bool):
"""Adds a distance penalty of 1.0 if `expr` evaluates to True,
or 0.0.
"""
@@ -490,7 +527,7 @@ class Distance:
else:
self.add(key, 0.0)
def add_number(self, key, number1, number2):
def add_number(self, key: str, number1: int, number2: int):
"""Adds a distance penalty of 1.0 for each number of difference
between `number1` and `number2`, or 0.0 when there is no
difference. Use this when there is no upper limit on the
@@ -503,7 +540,12 @@ class Distance:
else:
self.add(key, 0.0)
def add_priority(self, key, value, options):
def add_priority(
self,
key: str,
value: Any,
options: Union[List[Any], Tuple[Any, ...], Any],
):
"""Adds a distance penalty that corresponds to the position at
which `value` appears in `options`. A distance penalty of 0.0
for the first option, or 1.0 if there is no matching option. If
@@ -521,7 +563,12 @@ class Distance:
dist = 1.0
self.add(key, dist)
def add_ratio(self, key, number1, number2):
def add_ratio(
self,
key: str,
number1: Union[int, float],
number2: Union[int, float],
):
"""Adds a distance penalty for `number1` as a ratio of `number2`.
`number1` is bound at 0 and `number2`.
"""
@@ -532,7 +579,7 @@ class Distance:
dist = 0.0
self.add(key, dist)
def add_string(self, key, str1, str2):
def add_string(self, key: str, str1: Optional[str], str2: Optional[str]):
"""Adds a distance penalty based on the edit distance between
`str1` and `str2`.
"""
@@ -542,64 +589,82 @@ class Distance:
# Structures that compose all the information for a candidate match.
AlbumMatch = namedtuple('AlbumMatch', ['distance', 'info', 'mapping',
'extra_items', 'extra_tracks'])
AlbumMatch = namedtuple(
"AlbumMatch", ["distance", "info", "mapping", "extra_items", "extra_tracks"]
)
TrackMatch = namedtuple('TrackMatch', ['distance', 'info'])
TrackMatch = namedtuple("TrackMatch", ["distance", "info"])
# Aggregation of sources.
def album_for_mbid(release_id):
def album_for_mbid(release_id: str) -> Optional[AlbumInfo]:
"""Get an AlbumInfo object for a MusicBrainz release ID. Return None
if the ID is not found.
"""
try:
album = mb.album_for_id(release_id)
if album:
plugins.send('albuminfo_received', info=album)
plugins.send("albuminfo_received", info=album)
return album
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return None
def track_for_mbid(recording_id):
def track_for_mbid(recording_id: str) -> Optional[TrackInfo]:
"""Get a TrackInfo object for a MusicBrainz recording ID. Return None
if the ID is not found.
"""
try:
track = mb.track_for_id(recording_id)
if track:
plugins.send('trackinfo_received', info=track)
plugins.send("trackinfo_received", info=track)
return track
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return None
def albums_for_id(album_id):
def albums_for_id(album_id: str) -> Iterable[AlbumInfo]:
"""Get a list of albums for an ID."""
a = album_for_mbid(album_id)
if a:
yield a
for a in plugins.album_for_id(album_id):
if a:
plugins.send('albuminfo_received', info=a)
plugins.send("albuminfo_received", info=a)
yield a
def tracks_for_id(track_id):
def tracks_for_id(track_id: str) -> Iterable[TrackInfo]:
"""Get a list of tracks for an ID."""
t = track_for_mbid(track_id)
if t:
yield t
for t in plugins.track_for_id(track_id):
if t:
plugins.send('trackinfo_received', info=t)
plugins.send("trackinfo_received", info=t)
yield t
@plugins.notify_info_yielded('albuminfo_received')
def album_candidates(items, artist, album, va_likely, extra_tags):
def invoke_mb(call_func: Callable, *args):
try:
return call_func(*args)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
return ()
@plugins.notify_info_yielded("albuminfo_received")
def album_candidates(
items: List[Item],
artist: str,
album: str,
va_likely: bool,
extra_tags: Dict,
) -> Iterable[Tuple]:
"""Search for album matches. ``items`` is a list of Item objects
that make up the album. ``artist`` and ``album`` are the respective
names (strings), which may be derived from the item list or may be
@@ -609,40 +674,33 @@ def album_candidates(items, artist, album, va_likely, extra_tags):
constrain the search.
"""
# Base candidates if we have album and artist to match.
if artist and album:
try:
yield from mb.match_album(artist, album, len(items),
extra_tags)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
if config["musicbrainz"]["enabled"]:
# Base candidates if we have album and artist to match.
if artist and album:
yield from invoke_mb(
mb.match_album, artist, album, len(items), extra_tags
)
# Also add VA matches from MusicBrainz where appropriate.
if va_likely and album:
try:
yield from mb.match_album(None, album, len(items),
extra_tags)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
# Also add VA matches from MusicBrainz where appropriate.
if va_likely and album:
yield from invoke_mb(
mb.match_album, None, album, len(items), extra_tags
)
# Candidates from plugins.
yield from plugins.candidates(items, artist, album, va_likely,
extra_tags)
yield from plugins.candidates(items, artist, album, va_likely, extra_tags)
@plugins.notify_info_yielded('trackinfo_received')
def item_candidates(item, artist, title):
@plugins.notify_info_yielded("trackinfo_received")
def item_candidates(item: Item, artist: str, title: str) -> Iterable[Tuple]:
"""Search for item matches. ``item`` is the Item to be matched.
``artist`` and ``title`` are strings and either reflect the item or
are specified by the user.
"""
# MusicBrainz candidates.
if artist and title:
try:
yield from mb.match_track(artist, title)
except mb.MusicBrainzAPIError as exc:
exc.log(log)
if config["musicbrainz"]["enabled"] and artist and title:
yield from invoke_mb(mb.match_track, artist, title)
# Plugin candidates.
yield from plugins.item_candidates(item, artist, title)
+245 -149
View File
@@ -19,32 +19,53 @@ releases and tracks.
import datetime
import re
from munkres import Munkres
from collections import namedtuple
from typing import (
Any,
Dict,
Iterable,
List,
Optional,
Sequence,
Tuple,
TypeVar,
Union,
cast,
)
from beets import logging
from beets import plugins
from beets import config
from munkres import Munkres
from beets import config, logging, plugins
from beets.autotag import (
AlbumInfo,
AlbumMatch,
Distance,
TrackInfo,
TrackMatch,
hooks,
)
from beets.library import Item
from beets.util import plurality
from beets.autotag import hooks
from beets.util.enumeration import OrderedEnum
# Artist signals that indicate "various artists". These are used at the
# album level to determine whether a given release is likely a VA
# release and also on the track level to to remove the penalty for
# differing artists.
VA_ARTISTS = ('', 'various artists', 'various', 'va', 'unknown')
VA_ARTISTS = ("", "various artists", "various", "va", "unknown")
# Global logger.
log = logging.getLogger('beets')
log = logging.getLogger("beets")
# Recommendation enumeration.
class Recommendation(OrderedEnum):
"""Indicates a qualitative suggestion to the user about what should
be done with a given match.
"""
none = 0
low = 1
medium = 2
@@ -55,12 +76,15 @@ class Recommendation(OrderedEnum):
# consists of a list of possible candidates (i.e., AlbumInfo or TrackInfo
# objects) and a recommendation value.
Proposal = namedtuple('Proposal', ('candidates', 'recommendation'))
Proposal = namedtuple("Proposal", ("candidates", "recommendation"))
# Primary matching functionality.
def current_metadata(items):
def current_metadata(
items: Iterable[Item],
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""Extract the likely current metadata for an album given a list of its
items. Return two dictionaries:
- The most common value for each field.
@@ -70,22 +94,36 @@ def current_metadata(items):
likelies = {}
consensus = {}
fields = ['artist', 'album', 'albumartist', 'year', 'disctotal',
'mb_albumid', 'label', 'catalognum', 'country', 'media',
'albumdisambig']
fields = [
"artist",
"album",
"albumartist",
"year",
"disctotal",
"mb_albumid",
"label",
"barcode",
"catalognum",
"country",
"media",
"albumdisambig",
]
for field in fields:
values = [item[field] for item in items if item]
likelies[field], freq = plurality(values)
consensus[field] = (freq == len(values))
consensus[field] = freq == len(values)
# If there's an album artist consensus, use this for the artist.
if consensus['albumartist'] and likelies['albumartist']:
likelies['artist'] = likelies['albumartist']
if consensus["albumartist"] and likelies["albumartist"]:
likelies["artist"] = likelies["albumartist"]
return likelies, consensus
def assign_items(items, tracks):
def assign_items(
items: Sequence[Item],
tracks: Sequence[TrackInfo],
) -> Tuple[Dict[Item, TrackInfo], List[Item], List[TrackInfo]]:
"""Given a list of Items and a list of TrackInfo objects, find the
best mapping between them. Returns a mapping from Items to TrackInfo
objects, a set of extra Items, and a set of extra TrackInfo
@@ -93,17 +131,17 @@ def assign_items(items, tracks):
of objects of the two types.
"""
# Construct the cost matrix.
costs = []
costs: List[List[Distance]] = []
for item in items:
row = []
for i, track in enumerate(tracks):
for track in tracks:
row.append(track_distance(item, track))
costs.append(row)
# Find a minimum-cost bipartite matching.
log.debug('Computing track assignment...')
log.debug("Computing track assignment...")
matching = Munkres().compute(costs)
log.debug('...done.')
log.debug("...done.")
# Produce the output matching.
mapping = {items[i]: tracks[j] for (i, j) in matching}
@@ -114,14 +152,18 @@ def assign_items(items, tracks):
return mapping, extra_items, extra_tracks
def track_index_changed(item, track_info):
def track_index_changed(item: Item, track_info: TrackInfo) -> bool:
"""Returns True if the item and track info index is different. Tolerates
per disc and per release numbering.
"""
return item.track not in (track_info.medium_index, track_info.index)
def track_distance(item, track_info, incl_artist=False):
def track_distance(
item: Item,
track_info: TrackInfo,
incl_artist: bool = False,
) -> Distance:
"""Determines the significance of a track metadata change. Returns a
Distance object. `incl_artist` indicates that a distance component should
be included for the track artist (i.e., for various-artist releases).
@@ -130,26 +172,37 @@ def track_distance(item, track_info, incl_artist=False):
# Length.
if track_info.length:
diff = abs(item.length - track_info.length) - \
config['match']['track_length_grace'].as_number()
dist.add_ratio('track_length', diff,
config['match']['track_length_max'].as_number())
item_length = cast(float, item.length)
track_length_grace = cast(
Union[float, int],
config["match"]["track_length_grace"].as_number(),
)
track_length_max = cast(
Union[float, int],
config["match"]["track_length_max"].as_number(),
)
diff = abs(item_length - track_info.length) - track_length_grace
dist.add_ratio("track_length", diff, track_length_max)
# Title.
dist.add_string('track_title', item.title, track_info.title)
dist.add_string("track_title", item.title, track_info.title)
# Artist. Only check if there is actually an artist in the track data.
if incl_artist and track_info.artist and \
item.artist.lower() not in VA_ARTISTS:
dist.add_string('track_artist', item.artist, track_info.artist)
if (
incl_artist
and track_info.artist
and item.artist.lower() not in VA_ARTISTS
):
dist.add_string("track_artist", item.artist, track_info.artist)
# Track index.
if track_info.index and item.track:
dist.add_expr('track_index', track_index_changed(item, track_info))
dist.add_expr("track_index", track_index_changed(item, track_info))
# Track ID.
if item.mb_trackid:
dist.add_expr('track_id', item.mb_trackid != track_info.track_id)
dist.add_expr("track_id", item.mb_trackid != track_info.track_id)
# Plugins.
dist.update(plugins.track_distance(item, track_info))
@@ -157,7 +210,11 @@ def track_distance(item, track_info, incl_artist=False):
return dist
def distance(items, album_info, mapping):
def distance(
items: Sequence[Item],
album_info: AlbumInfo,
mapping: Dict[Item, TrackInfo],
) -> Distance:
"""Determines how "significant" an album metadata change would be.
Returns a Distance object. `album_info` is an AlbumInfo object
reflecting the album to be compared. `items` is a sequence of all
@@ -172,90 +229,96 @@ def distance(items, album_info, mapping):
# Artist, if not various.
if not album_info.va:
dist.add_string('artist', likelies['artist'], album_info.artist)
dist.add_string("artist", likelies["artist"], album_info.artist)
# Album.
dist.add_string('album', likelies['album'], album_info.album)
dist.add_string("album", likelies["album"], album_info.album)
# Current or preferred media.
if album_info.media:
# Preferred media options.
patterns = config['match']['preferred']['media'].as_str_seq()
options = [re.compile(r'(\d+x)?(%s)' % pat, re.I) for pat in patterns]
patterns = config["match"]["preferred"]["media"].as_str_seq()
patterns = cast(Sequence[str], patterns)
options = [re.compile(r"(\d+x)?(%s)" % pat, re.I) for pat in patterns]
if options:
dist.add_priority('media', album_info.media, options)
dist.add_priority("media", album_info.media, options)
# Current media.
elif likelies['media']:
dist.add_equality('media', album_info.media, likelies['media'])
elif likelies["media"]:
dist.add_equality("media", album_info.media, likelies["media"])
# Mediums.
if likelies['disctotal'] and album_info.mediums:
dist.add_number('mediums', likelies['disctotal'], album_info.mediums)
if likelies["disctotal"] and album_info.mediums:
dist.add_number("mediums", likelies["disctotal"], album_info.mediums)
# Prefer earliest release.
if album_info.year and config['match']['preferred']['original_year']:
if album_info.year and config["match"]["preferred"]["original_year"]:
# Assume 1889 (earliest first gramophone discs) if we don't know the
# original year.
original = album_info.original_year or 1889
diff = abs(album_info.year - original)
diff_max = abs(datetime.date.today().year - original)
dist.add_ratio('year', diff, diff_max)
dist.add_ratio("year", diff, diff_max)
# Year.
elif likelies['year'] and album_info.year:
if likelies['year'] in (album_info.year, album_info.original_year):
elif likelies["year"] and album_info.year:
if likelies["year"] in (album_info.year, album_info.original_year):
# No penalty for matching release or original year.
dist.add('year', 0.0)
dist.add("year", 0.0)
elif album_info.original_year:
# Prefer matchest closest to the release year.
diff = abs(likelies['year'] - album_info.year)
diff_max = abs(datetime.date.today().year -
album_info.original_year)
dist.add_ratio('year', diff, diff_max)
diff = abs(likelies["year"] - album_info.year)
diff_max = abs(
datetime.date.today().year - album_info.original_year
)
dist.add_ratio("year", diff, diff_max)
else:
# Full penalty when there is no original year.
dist.add('year', 1.0)
dist.add("year", 1.0)
# Preferred countries.
patterns = config['match']['preferred']['countries'].as_str_seq()
patterns = config["match"]["preferred"]["countries"].as_str_seq()
patterns = cast(Sequence[str], patterns)
options = [re.compile(pat, re.I) for pat in patterns]
if album_info.country and options:
dist.add_priority('country', album_info.country, options)
dist.add_priority("country", album_info.country, options)
# Country.
elif likelies['country'] and album_info.country:
dist.add_string('country', likelies['country'], album_info.country)
elif likelies["country"] and album_info.country:
dist.add_string("country", likelies["country"], album_info.country)
# Label.
if likelies['label'] and album_info.label:
dist.add_string('label', likelies['label'], album_info.label)
if likelies["label"] and album_info.label:
dist.add_string("label", likelies["label"], album_info.label)
# Catalog number.
if likelies['catalognum'] and album_info.catalognum:
dist.add_string('catalognum', likelies['catalognum'],
album_info.catalognum)
if likelies["catalognum"] and album_info.catalognum:
dist.add_string(
"catalognum", likelies["catalognum"], album_info.catalognum
)
# Disambiguation.
if likelies['albumdisambig'] and album_info.albumdisambig:
dist.add_string('albumdisambig', likelies['albumdisambig'],
album_info.albumdisambig)
if likelies["albumdisambig"] and album_info.albumdisambig:
dist.add_string(
"albumdisambig", likelies["albumdisambig"], album_info.albumdisambig
)
# Album ID.
if likelies['mb_albumid']:
dist.add_equality('album_id', likelies['mb_albumid'],
album_info.album_id)
if likelies["mb_albumid"]:
dist.add_equality(
"album_id", likelies["mb_albumid"], album_info.album_id
)
# Tracks.
dist.tracks = {}
for item, track in mapping.items():
dist.tracks[track] = track_distance(item, track, album_info.va)
dist.add('tracks', dist.tracks[track].distance)
dist.add("tracks", dist.tracks[track].distance)
# Missing tracks.
for i in range(len(album_info.tracks) - len(mapping)):
dist.add('missing_tracks', 1.0)
for _ in range(len(album_info.tracks) - len(mapping)):
dist.add("missing_tracks", 1.0)
# Unmatched tracks.
for i in range(len(items) - len(mapping)):
dist.add('unmatched_tracks', 1.0)
for _ in range(len(items) - len(mapping)):
dist.add("unmatched_tracks", 1.0)
# Plugins.
dist.update(plugins.album_distance(items, album_info, mapping))
@@ -263,7 +326,7 @@ def distance(items, album_info, mapping):
return dist
def match_by_id(items):
def match_by_id(items: Iterable[Item]):
"""If the items are tagged with a MusicBrainz album ID, returns an
AlbumInfo object for the corresponding album. Otherwise, returns
None.
@@ -274,20 +337,22 @@ def match_by_id(items):
try:
first = next(albumids)
except StopIteration:
log.debug('No album ID found.')
log.debug("No album ID found.")
return None
# Is there a consensus on the MB album ID?
for other in albumids:
if other != first:
log.debug('No album ID consensus.')
log.debug("No album ID consensus.")
return None
# If all album IDs are equal, look up the album.
log.debug('Searching for discovered album ID: {0}', first)
log.debug("Searching for discovered album ID: {0}", first)
return hooks.album_for_mbid(first)
def _recommendation(results):
def _recommendation(
results: Sequence[Union[AlbumMatch, TrackMatch]],
) -> Recommendation:
"""Given a sorted list of AlbumMatch or TrackMatch objects, return a
recommendation based on the results' distances.
@@ -301,17 +366,19 @@ def _recommendation(results):
# Basic distance thresholding.
min_dist = results[0].distance
if min_dist < config['match']['strong_rec_thresh'].as_number():
if min_dist < config["match"]["strong_rec_thresh"].as_number():
# Strong recommendation level.
rec = Recommendation.strong
elif min_dist <= config['match']['medium_rec_thresh'].as_number():
elif min_dist <= config["match"]["medium_rec_thresh"].as_number():
# Medium recommendation level.
rec = Recommendation.medium
elif len(results) == 1:
# Only a single candidate.
rec = Recommendation.low
elif results[1].distance - min_dist >= \
config['match']['rec_gap_thresh'].as_number():
elif (
results[1].distance - min_dist
>= config["match"]["rec_gap_thresh"].as_number()
):
# Gap between first two candidates is large.
rec = Recommendation.low
else:
@@ -324,48 +391,60 @@ def _recommendation(results):
if isinstance(results[0], hooks.AlbumMatch):
for track_dist in min_dist.tracks.values():
keys.update(list(track_dist.keys()))
max_rec_view = config['match']['max_rec']
max_rec_view = config["match"]["max_rec"]
for key in keys:
if key in list(max_rec_view.keys()):
max_rec = max_rec_view[key].as_choice({
'strong': Recommendation.strong,
'medium': Recommendation.medium,
'low': Recommendation.low,
'none': Recommendation.none,
})
max_rec = max_rec_view[key].as_choice(
{
"strong": Recommendation.strong,
"medium": Recommendation.medium,
"low": Recommendation.low,
"none": Recommendation.none,
}
)
rec = min(rec, max_rec)
return rec
def _sort_candidates(candidates):
AnyMatch = TypeVar("AnyMatch", TrackMatch, AlbumMatch)
def _sort_candidates(candidates: Iterable[AnyMatch]) -> Sequence[AnyMatch]:
"""Sort candidates by distance."""
return sorted(candidates, key=lambda match: match.distance)
def _add_candidate(items, results, info):
def _add_candidate(
items: Sequence[Item],
results: Dict[Any, AlbumMatch],
info: AlbumInfo,
):
"""Given a candidate AlbumInfo object, attempt to add the candidate
to the output dictionary of AlbumMatch objects. This involves
checking the track count, ordering the items, checking for
duplicates, and calculating the distance.
"""
log.debug('Candidate: {0} - {1} ({2})',
info.artist, info.album, info.album_id)
log.debug(
"Candidate: {0} - {1} ({2})", info.artist, info.album, info.album_id
)
# Discard albums with zero tracks.
if not info.tracks:
log.debug('No tracks.')
log.debug("No tracks.")
return
# Don't duplicate.
if info.album_id in results:
log.debug('Duplicate.')
# Prevent duplicates.
if info.album_id and info.album_id in results:
log.debug("Duplicate.")
return
# Discard matches without required tags.
for req_tag in config['match']['required'].as_str_seq():
for req_tag in cast(
Sequence[str], config["match"]["required"].as_str_seq()
):
if getattr(info, req_tag) is None:
log.debug('Ignored. Missing required tag: {0}', req_tag)
log.debug("Ignored. Missing required tag: {0}", req_tag)
return
# Find mapping between the items and the track info.
@@ -376,18 +455,24 @@ def _add_candidate(items, results, info):
# Skip matches with ignored penalties.
penalties = [key for key, _ in dist]
for penalty in config['match']['ignored'].as_str_seq():
ignored = cast(Sequence[str], config["match"]["ignored"].as_str_seq())
for penalty in ignored:
if penalty in penalties:
log.debug('Ignored. Penalty: {0}', penalty)
log.debug("Ignored. Penalty: {0}", penalty)
return
log.debug('Success. Distance: {0}', dist)
results[info.album_id] = hooks.AlbumMatch(dist, info, mapping,
extra_items, extra_tracks)
log.debug("Success. Distance: {0}", dist)
results[info.album_id] = hooks.AlbumMatch(
dist, info, mapping, extra_items, extra_tracks
)
def tag_album(items, search_artist=None, search_album=None,
search_ids=[]):
def tag_album(
items,
search_artist: Optional[str] = None,
search_album: Optional[str] = None,
search_ids: List[str] = [],
) -> Tuple[str, str, Proposal]:
"""Return a tuple of the current artist name, the current album
name, and a `Proposal` containing `AlbumMatch` candidates.
@@ -407,20 +492,19 @@ def tag_album(items, search_artist=None, search_album=None,
"""
# Get current metadata.
likelies, consensus = current_metadata(items)
cur_artist = likelies['artist']
cur_album = likelies['album']
log.debug('Tagging {0} - {1}', cur_artist, cur_album)
cur_artist = cast(str, likelies["artist"])
cur_album = cast(str, likelies["album"])
log.debug("Tagging {0} - {1}", cur_artist, cur_album)
# The output result (distance, AlbumInfo) tuples (keyed by MB album
# ID).
candidates = {}
# The output result, keys are the MB album ID.
candidates: Dict[Any, AlbumMatch] = {}
# Search by explicit ID.
if search_ids:
for search_id in search_ids:
log.debug('Searching for album ID: {0}', search_id)
for id_candidate in hooks.albums_for_id(search_id):
_add_candidate(items, candidates, id_candidate)
log.debug("Searching for album ID: {0}", search_id)
for album_info_for_id in hooks.albums_for_id(search_id):
_add_candidate(items, candidates, album_info_for_id)
# Use existing metadata or text search.
else:
@@ -429,51 +513,58 @@ def tag_album(items, search_artist=None, search_album=None,
if id_info:
_add_candidate(items, candidates, id_info)
rec = _recommendation(list(candidates.values()))
log.debug('Album ID match recommendation is {0}', rec)
if candidates and not config['import']['timid']:
log.debug("Album ID match recommendation is {0}", rec)
if candidates and not config["import"]["timid"]:
# If we have a very good MBID match, return immediately.
# Otherwise, this match will compete against metadata-based
# matches.
if rec == Recommendation.strong:
log.debug('ID match.')
return cur_artist, cur_album, \
Proposal(list(candidates.values()), rec)
log.debug("ID match.")
return (
cur_artist,
cur_album,
Proposal(list(candidates.values()), rec),
)
# Search terms.
if not (search_artist and search_album):
# No explicit search terms -- use current metadata.
search_artist, search_album = cur_artist, cur_album
log.debug('Search terms: {0} - {1}', search_artist, search_album)
log.debug("Search terms: {0} - {1}", search_artist, search_album)
extra_tags = None
if config['musicbrainz']['extra_tags']:
tag_list = config['musicbrainz']['extra_tags'].get()
if config["musicbrainz"]["extra_tags"]:
tag_list = config["musicbrainz"]["extra_tags"].get()
extra_tags = {k: v for (k, v) in likelies.items() if k in tag_list}
log.debug('Additional search terms: {0}', extra_tags)
log.debug("Additional search terms: {0}", extra_tags)
# Is this album likely to be a "various artist" release?
va_likely = ((not consensus['artist']) or
(search_artist.lower() in VA_ARTISTS) or
any(item.comp for item in items))
log.debug('Album might be VA: {0}', va_likely)
va_likely = (
(not consensus["artist"])
or (search_artist.lower() in VA_ARTISTS)
or any(item.comp for item in items)
)
log.debug("Album might be VA: {0}", va_likely)
# Get the results from the data sources.
for matched_candidate in hooks.album_candidates(items,
search_artist,
search_album,
va_likely,
extra_tags):
for matched_candidate in hooks.album_candidates(
items, search_artist, search_album, va_likely, extra_tags
):
_add_candidate(items, candidates, matched_candidate)
log.debug('Evaluating {0} candidates.', len(candidates))
log.debug("Evaluating {0} candidates.", len(candidates))
# Sort and get the recommendation.
candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates)
return cur_artist, cur_album, Proposal(candidates, rec)
candidates_sorted = _sort_candidates(candidates.values())
rec = _recommendation(candidates_sorted)
return cur_artist, cur_album, Proposal(candidates_sorted, rec)
def tag_item(item, search_artist=None, search_title=None,
search_ids=[]):
def tag_item(
item,
search_artist: Optional[str] = None,
search_title: Optional[str] = None,
search_ids: Optional[List[str]] = None,
) -> Proposal:
"""Find metadata for a single track. Return a `Proposal` consisting
of `TrackMatch` objects.
@@ -485,26 +576,31 @@ def tag_item(item, search_artist=None, search_title=None,
# Holds candidates found so far: keys are MBIDs; values are
# (distance, TrackInfo) pairs.
candidates = {}
rec: Optional[Recommendation] = None
# First, try matching by MusicBrainz ID.
trackids = search_ids or [t for t in [item.mb_trackid] if t]
if trackids:
for trackid in trackids:
log.debug('Searching for track ID: {0}', trackid)
log.debug("Searching for track ID: {0}", trackid)
for track_info in hooks.tracks_for_id(trackid):
dist = track_distance(item, track_info, incl_artist=True)
candidates[track_info.track_id] = \
hooks.TrackMatch(dist, track_info)
candidates[track_info.track_id] = hooks.TrackMatch(
dist, track_info
)
# If this is a good match, then don't keep searching.
rec = _recommendation(_sort_candidates(candidates.values()))
if rec == Recommendation.strong and \
not config['import']['timid']:
log.debug('Track ID match.')
if (
rec == Recommendation.strong
and not config["import"]["timid"]
):
log.debug("Track ID match.")
return Proposal(_sort_candidates(candidates.values()), rec)
# If we're searching by ID, don't proceed.
if search_ids:
if candidates:
assert rec is not None
return Proposal(_sort_candidates(candidates.values()), rec)
else:
return Proposal([], Recommendation.none)
@@ -512,7 +608,7 @@ def tag_item(item, search_artist=None, search_title=None,
# Search terms.
if not (search_artist and search_title):
search_artist, search_title = item.artist, item.title
log.debug('Item search terms: {0} - {1}', search_artist, search_title)
log.debug("Item search terms: {0} - {1}", search_artist, search_title)
# Get and evaluate candidate metadata.
for track_info in hooks.item_candidates(item, search_artist, search_title):
@@ -520,7 +616,7 @@ def tag_item(item, search_artist=None, search_title=None,
candidates[track_info.track_id] = hooks.TrackMatch(dist, track_info)
# Sort by distance and return with recommendation.
log.debug('Found {0} candidates.', len(candidates))
candidates = _sort_candidates(candidates.values())
rec = _recommendation(candidates)
return Proposal(candidates, rec)
log.debug("Found {0} candidates.", len(candidates))
candidates_sorted = _sort_candidates(candidates.values())
rec = _recommendation(candidates_sorted)
return Proposal(candidates_sorted, rec)
+556 -251
View File
File diff suppressed because it is too large Load Diff
+114 -41
View File
@@ -1,10 +1,34 @@
# --------------- Main ---------------
library: library.db
directory: ~/Music
statefile: state.pickle
# --------------- Plugins ---------------
plugins: []
pluginpath: []
# --------------- Import ---------------
clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information", "lost+found"]
ignore_hidden: yes
import:
# common options
write: yes
copy: yes
move: no
timid: no
quiet: no
log:
# other options
default_action: apply
languages: []
quiet_fallback: skip
none_rec_action: ask
# rare options
link: no
hardlink: no
reflink: no
@@ -13,76 +37,117 @@ import:
incremental: no
incremental_skip_later: no
from_scratch: no
quiet_fallback: skip
none_rec_action: ask
timid: no
log:
autotag: yes
quiet: no
singletons: no
default_action: apply
languages: []
detail: no
flat: no
group_albums: no
pretend: false
search_ids: []
duplicate_keys:
album: albumartist album
item: artist title
duplicate_action: ask
duplicate_verbose_prompt: no
bell: no
set_fields: {}
ignored_alias_types: []
singleton_album_disambig: yes
clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information", "lost+found"]
ignore_hidden: yes
# --------------- Paths ---------------
replace:
'[\\/]': _
'^\.': _
'[\x00-\x1f]': _
'[<>:"\?\*\|]': _
'\.$': _
'\s+$': ''
'^\s+': ''
'^-': _
path_sep_replace: _
drive_sep_replace: _
asciify_paths: false
art_filename: cover
max_filename_length: 0
replace:
# Replace bad characters with _
# prohibited in many filesystem paths
'[<>:\?\*\|]': _
# double quotation mark "
'\"': _
# path separators: \ or /
'[\\/]': _
# starting and closing periods
'^\.': _
'\.$': _
# control characters
'[\x00-\x1f]': _
# dash at the start of a filename (causes command line ambiguity)
'^-': _
# Replace bad characters with nothing
# starting and closing whitespace
'\s+$': ''
'^\s+': ''
aunique:
keys: albumartist album
disambiguators: albumtype year label catalognum albumdisambig releasegroupdisambig
bracket: '[]'
overwrite_null:
album: []
track: []
sunique:
keys: artist title
disambiguators: year trackdisambig
bracket: '[]'
# --------------- Tagging ---------------
plugins: []
pluginpath: []
threaded: yes
timeout: 5.0
per_disc_numbering: no
verbose: 0
terminal_encoding:
original_date: no
artist_credit: no
id3v23: no
va_name: "Various Artists"
paths:
default: $albumartist/$album%aunique{}/$track $title
singleton: Non-Album/$artist/$title
comp: Compilations/$album%aunique{}/$track $title
# --------------- Performance ---------------
threaded: yes
timeout: 5.0
# --------------- UI ---------------
verbose: 0
terminal_encoding:
ui:
terminal_width: 80
length_diff_thresh: 10.0
color: yes
colors:
text_success: green
text_warning: yellow
text_error: red
text_highlight: red
text_highlight_minor: lightgray
action_default: turquoise
action: blue
text_success: ['bold', 'green']
text_warning: ['bold', 'yellow']
text_error: ['bold', 'red']
text_highlight: ['bold', 'red']
text_highlight_minor: ['white']
action_default: ['bold', 'cyan']
action: ['bold', 'cyan']
# New Colors
text: ['normal']
text_faint: ['faint']
import_path: ['bold', 'blue']
import_path_items: ['bold', 'blue']
added: ['green']
removed: ['red']
changed: ['yellow']
added_highlight: ['bold', 'green']
removed_highlight: ['bold', 'red']
changed_highlight: ['bold', 'yellow']
text_diff_added: ['bold', 'red']
text_diff_removed: ['bold', 'red']
text_diff_changed: ['bold', 'red']
action_description: ['white']
import:
indentation:
match_header: 2
match_details: 2
match_tracklist: 5
layout: column
# --------------- Search ---------------
format_item: $artist - $album - $title
format_album: $albumartist - $album
@@ -93,14 +158,13 @@ sort_album: albumartist+ album+
sort_item: artist+ album+ disc+ track+
sort_case_insensitive: yes
paths:
default: $albumartist/$album%aunique{}/$track $title
singleton: Non-Album/$artist/$title
comp: Compilations/$album%aunique{}/$track $title
statefile: state.pickle
# --------------- Autotagger ---------------
overwrite_null:
album: []
track: []
musicbrainz:
enabled: yes
host: musicbrainz.org
https: no
ratelimit: 1
@@ -108,6 +172,13 @@ musicbrainz:
searchlimit: 5
extra_tags: []
genres: no
external_ids:
discogs: no
bandcamp: no
spotify: no
deezer: no
beatport: no
tidal: no
match:
strong_rec_thresh: 0.04
@@ -147,3 +218,5 @@ match:
ignore_video_tracks: yes
track_length_grace: 10
track_length_max: 30
album_disambig_fields: data_source media year country label catalognum albumdisambig
singleton_disambig_fields: data_source index track_alt album
+14 -6
View File
@@ -16,12 +16,20 @@
Library.
"""
from .db import Model, Database
from .query import Query, FieldQuery, MatchQuery, AndQuery, OrQuery
from .db import Database, Model, Results
from .query import (
AndQuery,
FieldQuery,
InvalidQueryError,
MatchQuery,
OrQuery,
Query,
)
from .queryparse import (
parse_sorted_query,
query_from_strings,
sort_from_strings,
)
from .types import Type
from .queryparse import query_from_strings
from .queryparse import sort_from_strings
from .queryparse import parse_sorted_query
from .query import InvalidQueryError
# flake8: noqa
+432 -238
View File
File diff suppressed because it is too large Load Diff
+431 -288
View File
File diff suppressed because it is too large Load Diff
+93 -63
View File
@@ -12,30 +12,33 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Parsing of strings into DBCore queries.
"""
"""Parsing of strings into DBCore queries."""
import re
import itertools
from . import query
import re
from typing import Collection, Dict, List, Optional, Sequence, Tuple, Type
from . import Model, query
from .query import Sort
PARSE_QUERY_PART_REGEX = re.compile(
# Non-capturing optional segment for the keyword.
r'(-|\^)?' # Negation prefixes.
r'(?:'
r'(\S+?)' # The field key.
r'(?<!\\):' # Unescaped :
r')?'
r'(.*)', # The term itself.
re.I # Case-insensitive.
r"(-|\^)?" # Negation prefixes.
r"(?:"
r"(\S+?)" # The field key.
r"(?<!\\):" # Unescaped :
r")?"
r"(.*)", # The term itself.
re.I, # Case-insensitive.
)
def parse_query_part(part, query_classes={}, prefixes={},
default_class=query.SubstringQuery):
def parse_query_part(
part: str,
query_classes: Dict[str, Type[query.FieldQuery]] = {},
prefixes: Dict = {},
default_class: Type[query.SubstringQuery] = query.SubstringQuery,
) -> Tuple[Optional[str], str, Type[query.FieldQuery], bool]:
"""Parse a single *query part*, which is a chunk of a complete query
string representing a single criterion.
@@ -86,13 +89,13 @@ def parse_query_part(part, query_classes={}, prefixes={},
assert match # Regex should always match
negate = bool(match.group(1))
key = match.group(2)
term = match.group(3).replace('\\:', ':')
term = match.group(3).replace("\\:", ":")
# Check whether there's a prefix in the query and use the
# corresponding query type.
for pre, query_class in prefixes.items():
if term.startswith(pre):
return key, term[len(pre):], query_class, negate
return key, term[len(pre) :], query_class, negate
# No matching prefix, so use either the query class determined by
# the field or the default as a fallback.
@@ -100,7 +103,11 @@ def parse_query_part(part, query_classes={}, prefixes={},
return key, term, query_class, negate
def construct_query_part(model_cls, prefixes, query_part):
def construct_query_part(
model_cls: Type[Model],
prefixes: Dict,
query_part: str,
) -> query.Query:
"""Parse a *query part* string and return a :class:`Query` object.
:param model_cls: The :class:`Model` class that this is a query for.
@@ -116,40 +123,44 @@ def construct_query_part(model_cls, prefixes, query_part):
if not query_part:
return query.TrueQuery()
out_query: query.Query
# 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: Dict[str, Type[query.FieldQuery]] = {}
for k, t in itertools.chain(
model_cls._fields.items(), model_cls._types.items()
):
query_classes[k] = t.query
query_classes.update(model_cls._queries) # Non-field queries.
# Parse the string.
key, pattern, query_class, negate = \
parse_query_part(query_part, query_classes, prefixes)
key, pattern, query_class, negate = parse_query_part(
query_part, query_classes, prefixes
)
# If there's no key (field name) specified, this is a "match
# anything" query.
if key is None:
if issubclass(query_class, query.FieldQuery):
# The query type matches a specific field, but none was
# specified. So we use a version of the query that matches
# any field.
out_query = query.AnyFieldQuery(pattern, model_cls._search_fields,
query_class)
else:
# Non-field query type.
out_query = query_class(pattern)
# The query type matches a specific field, but none was
# specified. So we use a version of the query that matches
# any field.
out_query = query.AnyFieldQuery(
pattern, model_cls._search_fields, query_class
)
# 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)
field = table = key.lower()
if field in model_cls.shared_db_fields:
# This field exists in both tables, so SQLite will encounter
# an OperationalError if we try to query it in a join.
# Using an explicit table name resolves this.
table = f"{model_cls._table}.{field}"
field_in_db = field in model_cls.all_db_fields
out_query = query_class(table, pattern, field_in_db)
# Apply negation.
if negate:
@@ -158,7 +169,13 @@ def construct_query_part(model_cls, prefixes, query_part):
return out_query
def query_from_strings(query_cls, model_cls, prefixes, query_parts):
# TYPING ERROR
def query_from_strings(
query_cls: Type[query.CollectionQuery],
model_cls: Type[Model],
prefixes: Dict,
query_parts: Collection[str],
) -> query.Query:
"""Creates a collection query of type `query_cls` from a list of
strings in the format used by parse_query_part. `model_cls`
determines how queries are constructed from strings.
@@ -171,7 +188,11 @@ def query_from_strings(query_cls, model_cls, prefixes, query_parts):
return query_cls(subqueries)
def construct_sort_part(model_cls, part, case_insensitive=True):
def construct_sort_part(
model_cls: Type[Model],
part: str,
case_insensitive: bool = True,
) -> Sort:
"""Create a `Sort` from a single string criterion.
`model_cls` is the `Model` being queried. `part` is a single string
@@ -183,12 +204,13 @@ def construct_sort_part(model_cls, part, case_insensitive=True):
field = part[:-1]
assert field, "field is missing"
direction = part[-1]
assert direction in ('+', '-'), "part must end with + or -"
is_ascending = direction == '+'
assert direction in ("+", "-"), "part must end with + or -"
is_ascending = direction == "+"
if field in model_cls._sorts:
sort = model_cls._sorts[field](model_cls, is_ascending,
case_insensitive)
sort = model_cls._sorts[field](
model_cls, is_ascending, case_insensitive
)
elif field in model_cls._fields:
sort = query.FixedFieldSort(field, is_ascending, case_insensitive)
else:
@@ -197,23 +219,31 @@ def construct_sort_part(model_cls, part, case_insensitive=True):
return sort
def sort_from_strings(model_cls, sort_parts, case_insensitive=True):
"""Create a `Sort` from a list of sort criteria (strings).
"""
def sort_from_strings(
model_cls: Type[Model],
sort_parts: Sequence[str],
case_insensitive: bool = True,
) -> Sort:
"""Create a `Sort` from a list of sort criteria (strings)."""
if not sort_parts:
sort = query.NullSort()
return query.NullSort()
elif len(sort_parts) == 1:
sort = construct_sort_part(model_cls, sort_parts[0], case_insensitive)
return construct_sort_part(model_cls, sort_parts[0], case_insensitive)
else:
sort = query.MultipleSort()
for part in sort_parts:
sort.add_sort(construct_sort_part(model_cls, part,
case_insensitive))
return sort
sort.add_sort(
construct_sort_part(model_cls, part, case_insensitive)
)
return sort
def parse_sorted_query(model_cls, parts, prefixes={},
case_insensitive=True):
def parse_sorted_query(
model_cls: Type[Model],
parts: List[str],
prefixes: Dict = {},
case_insensitive: bool = True,
) -> Tuple[query.Query, Sort]:
"""Given a list of strings, create the `Query` and `Sort` that they
represent.
"""
@@ -224,24 +254,24 @@ def parse_sorted_query(model_cls, parts, prefixes={},
# Split up query in to comma-separated subqueries, each representing
# an AndQuery, which need to be joined together in one OrQuery
subquery_parts = []
for part in parts + [',']:
if part.endswith(','):
for part in parts + [","]:
if part.endswith(","):
# Ensure we can catch "foo, bar" as well as "foo , bar"
last_subquery_part = part[:-1]
if last_subquery_part:
subquery_parts.append(last_subquery_part)
# Parse the subquery in to a single AndQuery
# TODO: Avoid needlessly wrapping AndQueries containing 1 subquery?
query_parts.append(query_from_strings(
query.AndQuery, model_cls, prefixes, subquery_parts
))
query_parts.append(
query_from_strings(
query.AndQuery, model_cls, prefixes, subquery_parts
)
)
del subquery_parts[:]
else:
# Sort parts (1) end in + or -, (2) don't have a field, and
# (3) consist of more than just the + or -.
if part.endswith(('+', '-')) \
and ':' not in part \
and len(part) > 1:
if part.endswith(("+", "-")) and ":" not in part and len(part) > 1:
sort_parts.append(part)
else:
subquery_parts.append(part)
+162 -67
View File
@@ -14,28 +14,46 @@
"""Representation of type information for DBCore model fields.
"""
import typing
from abc import ABC
from typing import Any, Generic, List, TypeVar, Union, cast
from . import query
from beets.util import str2bool
from .query import BooleanQuery, FieldQuery, NumericQuery, SubstringQuery
# Abstract base.
class Type:
class ModelType(typing.Protocol):
"""Protocol that specifies the required constructor for model types,
i.e. a function that takes any argument and attempts to parse it to the
given type.
"""
def __init__(self, value: Any = None): ...
# Generic type variables, used for the value type T and null type N (if
# nullable, else T and N are set to the same type for the concrete subclasses
# of Type).
N = TypeVar("N")
T = TypeVar("T", bound=ModelType)
class Type(ABC, Generic[T, N]):
"""An object encapsulating the type of a model field. Includes
information about how to store, query, format, and parse a given
field.
"""
sql = 'TEXT'
sql: str = "TEXT"
"""The SQLite column type for the value.
"""
query = query.SubstringQuery
query: typing.Type[FieldQuery] = SubstringQuery
"""The `Query` subclass to be used when querying the field.
"""
model_type = str
model_type: typing.Type[T]
"""The Python type that is used to represent the value in the model.
The model is guaranteed to return a value of this type if the field
@@ -44,12 +62,14 @@ class Type:
"""
@property
def null(self):
"""The value to be exposed when the underlying value is None.
"""
return self.model_type()
def null(self) -> N:
"""The value to be exposed when the underlying value is None."""
# Note that this default implementation only makes sense for T = N.
# It would be better to implement `null()` only in subclasses, or
# have a field null_type similar to `model_type` and use that here.
return cast(N, self.model_type())
def format(self, value):
def format(self, value: Union[N, T]) -> str:
"""Given a value of this type, produce a Unicode string
representing the value. This is used in template evaluation.
"""
@@ -57,13 +77,13 @@ class Type:
value = self.null
# `self.null` might be `None`
if value is None:
value = ''
if isinstance(value, bytes):
value = value.decode('utf-8', 'ignore')
return ""
elif isinstance(value, bytes):
return value.decode("utf-8", "ignore")
else:
return str(value)
return str(value)
def parse(self, string):
def parse(self, string: str) -> Union[T, N]:
"""Parse a (possibly human-written) string and return the
indicated value of this type.
"""
@@ -72,19 +92,23 @@ class Type:
except ValueError:
return self.null
def normalize(self, value):
def normalize(self, value: Any) -> Union[T, N]:
"""Given a value that will be assigned into a field of this
type, normalize the value to have the appropriate type. This
base implementation only reinterprets `None`.
"""
# TYPING ERROR
if value is None:
return self.null
else:
# TODO This should eventually be replaced by
# `self.model_type(value)`
return value
return cast(T, value)
def from_sql(self, sql_value):
def from_sql(
self,
sql_value: Union[None, int, float, str, bytes],
) -> Union[T, N]:
"""Receives the value stored in the SQL backend and return the
value to be stored in the model.
@@ -99,13 +123,13 @@ class Type:
and the method must handle these in addition.
"""
if isinstance(sql_value, memoryview):
sql_value = bytes(sql_value).decode('utf-8', 'ignore')
sql_value = bytes(sql_value).decode("utf-8", "ignore")
if isinstance(sql_value, str):
return self.parse(sql_value)
else:
return self.normalize(sql_value)
def to_sql(self, model_value):
def to_sql(self, model_value: Any) -> Union[None, int, float, str, bytes]:
"""Convert a value as stored in the model object to a value used
by the database adapter.
"""
@@ -114,18 +138,23 @@ class Type:
# Reusable types.
class Default(Type):
null = None
class Default(Type[str, None]):
model_type = str
@property
def null(self):
return None
class Integer(Type):
"""A basic integer type.
"""
sql = 'INTEGER'
query = query.NumericQuery
class BaseInteger(Type[int, N]):
"""A basic integer type."""
sql = "INTEGER"
query = NumericQuery
model_type = int
def normalize(self, value):
def normalize(self, value: Any) -> Union[int, N]:
try:
return self.model_type(round(float(value)))
except ValueError:
@@ -134,91 +163,153 @@ class Integer(Type):
return self.null
class PaddedInt(Integer):
class Integer(BaseInteger[int]):
@property
def null(self) -> int:
return 0
class NullInteger(BaseInteger[None]):
@property
def null(self) -> None:
return None
class BasePaddedInt(BaseInteger[N]):
"""An integer field that is formatted with a given number of digits,
padded with zeroes.
"""
def __init__(self, digits):
def __init__(self, digits: int):
self.digits = digits
def format(self, value):
return '{0:0{1}d}'.format(value or 0, self.digits)
def format(self, value: Union[int, N]) -> str:
return "{0:0{1}d}".format(value or 0, self.digits)
class NullPaddedInt(PaddedInt):
"""Same as `PaddedInt`, but does not normalize `None` to `0.0`.
"""
null = None
class PaddedInt(BasePaddedInt[int]):
pass
class NullPaddedInt(BasePaddedInt[None]):
"""Same as `PaddedInt`, but does not normalize `None` to `0`."""
@property
def null(self) -> None:
return None
class ScaledInt(Integer):
"""An integer whose formatting operation scales the number by a
constant and adds a suffix. Good for units with large magnitudes.
"""
def __init__(self, unit, suffix=''):
def __init__(self, unit: int, suffix: str = ""):
self.unit = unit
self.suffix = suffix
def format(self, value):
return '{}{}'.format((value or 0) // self.unit, self.suffix)
def format(self, value: int) -> str:
return "{}{}".format((value or 0) // self.unit, self.suffix)
class Id(Integer):
class Id(NullInteger):
"""An integer used as the row id or a foreign key in a SQLite table.
This type is nullable: None values are not translated to zero.
"""
null = None
def __init__(self, primary=True):
@property
def null(self) -> None:
return None
def __init__(self, primary: bool = True):
if primary:
self.sql = 'INTEGER PRIMARY KEY'
self.sql = "INTEGER PRIMARY KEY"
class Float(Type):
class BaseFloat(Type[float, N]):
"""A basic floating-point type. The `digits` parameter specifies how
many decimal places to use in the human-readable representation.
"""
sql = 'REAL'
query = query.NumericQuery
sql = "REAL"
query: typing.Type[FieldQuery[Any]] = NumericQuery
model_type = float
def __init__(self, digits=1):
def __init__(self, digits: int = 1):
self.digits = digits
def format(self, value):
return '{0:.{1}f}'.format(value or 0, self.digits)
def format(self, value: Union[float, N]) -> str:
return "{0:.{1}f}".format(value or 0, self.digits)
class NullFloat(Float):
"""Same as `Float`, but does not normalize `None` to `0.0`.
"""
null = None
class Float(BaseFloat[float]):
"""Floating-point type that normalizes `None` to `0.0`."""
@property
def null(self) -> float:
return 0.0
class String(Type):
"""A Unicode string type.
"""
sql = 'TEXT'
query = query.SubstringQuery
class NullFloat(BaseFloat[None]):
"""Same as `Float`, but does not normalize `None` to `0.0`."""
def normalize(self, value):
@property
def null(self) -> None:
return None
class BaseString(Type[T, N]):
"""A Unicode string type."""
sql = "TEXT"
query = SubstringQuery
def normalize(self, value: Any) -> Union[T, N]:
if value is None:
return self.null
else:
return self.model_type(value)
class Boolean(Type):
"""A boolean type.
class String(BaseString[str, Any]):
"""A Unicode string type."""
model_type = str
class DelimitedString(BaseString[List[str], List[str]]):
"""A list of Unicode strings, represented in-database by a single string
containing delimiter-separated values.
"""
sql = 'INTEGER'
query = query.BooleanQuery
model_type = list
def __init__(self, delimiter: str):
self.delimiter = delimiter
def format(self, value: List[str]):
return self.delimiter.join(value)
def parse(self, string: str):
if not string:
return []
return string.split(self.delimiter)
def to_sql(self, model_value: List[str]):
return self.delimiter.join(model_value)
class Boolean(Type):
"""A boolean type."""
sql = "INTEGER"
query = BooleanQuery
model_type = bool
def format(self, value):
def format(self, value: bool) -> str:
return str(bool(value))
def parse(self, string):
def parse(self, string: str) -> bool:
return str2bool(string)
@@ -231,3 +322,7 @@ FLOAT = Float()
NULL_FLOAT = NullFloat()
STRING = String()
BOOLEAN = Boolean()
SEMICOLON_SPACE_DSV = DelimitedString(delimiter="; ")
# Will set the proper null char in mediafile
MULTI_VALUE_DSV = DelimitedString(delimiter="\\")
+445 -320
View File
File diff suppressed because it is too large Load Diff
+826 -547
View File
File diff suppressed because it is too large Load Diff
+59 -47
View File
@@ -12,63 +12,50 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""A drop-in replacement for the standard-library `logging` module that
allows {}-style log formatting on Python 2 and 3.
"""A drop-in replacement for the standard-library `logging` module.
Provides everything the "logging" module does. The only difference is
that when getLogger(name) instantiates a logger that logger uses
{}-style formatting.
Provides everything the "logging" module does. In addition, beets' logger
(as obtained by `getLogger(name)`) supports thread-local levels, and messages
use {}-style formatting and can interpolate keywords arguments to the logging
calls (`debug`, `info`, etc).
"""
from copy import copy
from logging import * # noqa
import subprocess
import logging
import threading
from copy import copy
def logsafe(val):
"""Coerce a potentially "problematic" value so it can be formatted
in a Unicode log string.
"""Coerce `bytes` to `str` to avoid crashes solely due to logging.
This works around a number of pitfalls when logging objects in
Python 2:
- Logging path names, which must be byte strings, requires
conversion for output.
- Some objects, including some exceptions, will crash when you call
`unicode(v)` while `str(v)` works fine. CalledProcessError is an
example.
This is particularly relevant for bytestring paths. Much of our code
explicitly uses `displayable_path` for them, but better be safe and prevent
any crashes that are solely due to log formatting.
"""
# Already Unicode.
if isinstance(val, str):
return val
# Bytestring: needs decoding.
elif isinstance(val, bytes):
# Bytestring: Needs decoding to be safe for substitution in format strings.
if isinstance(val, bytes):
# Blindly convert with UTF-8. Eventually, it would be nice to
# (a) only do this for paths, if they can be given a distinct
# type, and (b) warn the developer if they do this for other
# bytestrings.
return val.decode('utf-8', 'replace')
# A "problem" object: needs a workaround.
elif isinstance(val, subprocess.CalledProcessError):
try:
return str(val)
except UnicodeDecodeError:
# An object with a broken __unicode__ formatter. Use __str__
# instead.
return str(val).decode('utf-8', 'replace')
return val.decode("utf-8", "replace")
# Other objects are used as-is so field access, etc., still works in
# the format string.
else:
return val
# the format string. Relies on a working __str__ implementation.
return val
class StrFormatLogger(Logger):
class StrFormatLogger(logging.Logger):
"""A version of `Logger` that uses `str.format`-style formatting
instead of %-style formatting.
instead of %-style formatting and supports keyword arguments.
We cannot easily get rid of this even in the Python 3 era: This custom
formatting supports substitution from `kwargs` into the message, which the
default `logging.Logger._log()` implementation does not.
Remark by @sampsyo: https://stackoverflow.com/a/24683360 might be a way to
achieve this with less code.
"""
class _LogMessage:
@@ -82,19 +69,39 @@ class StrFormatLogger(Logger):
kwargs = {k: logsafe(v) for (k, v) in self.kwargs.items()}
return self.msg.format(*args, **kwargs)
def _log(self, level, msg, args, exc_info=None, extra=None, **kwargs):
def _log(
self,
level,
msg,
args,
exc_info=None,
extra=None,
stack_info=False,
**kwargs,
):
"""Log msg.format(*args, **kwargs)"""
m = self._LogMessage(msg, args, kwargs)
return super()._log(level, m, (), exc_info, extra)
stacklevel = kwargs.pop("stacklevel", 1)
stacklevel = {"stacklevel": stacklevel}
return super()._log(
level,
m,
(),
exc_info=exc_info,
extra=extra,
stack_info=stack_info,
**stacklevel,
)
class ThreadLocalLevelLogger(Logger):
"""A version of `Logger` whose level is thread-local instead of shared.
"""
class ThreadLocalLevelLogger(logging.Logger):
"""A version of `Logger` whose level is thread-local instead of shared."""
def __init__(self, name, level=NOTSET):
def __init__(self, name, level=logging.NOTSET):
self._thread_level = threading.local()
self.default_level = NOTSET
self.default_level = logging.NOTSET
super().__init__(name, level)
@property
@@ -121,12 +128,17 @@ class BeetsLogger(ThreadLocalLevelLogger, StrFormatLogger):
pass
my_manager = copy(Logger.manager)
my_manager = copy(logging.Logger.manager)
my_manager.loggerClass = BeetsLogger
# Act like the stdlib logging module by re-exporting its namespace.
from logging import * # noqa
# Override the `getLogger` to use our machinery.
def getLogger(name=None): # noqa
if name:
return my_manager.getLogger(name)
else:
return Logger.root
return logging.Logger.root
+11 -3
View File
@@ -13,14 +13,22 @@
# included in all copies or substantial portions of the Software.
import warnings
import mediafile
import warnings
warnings.warn("beets.mediafile is deprecated; use mediafile instead")
warnings.warn(
"beets.mediafile is deprecated; use mediafile instead",
# Show the location of the `import mediafile` statement as the warning's
# source, rather than this file, such that the offending module can be
# identified easily.
stacklevel=2,
)
# Import everything from the mediafile module into this module.
for key, value in mediafile.__dict__.items():
if key not in ['__name__']:
if key not in ["__name__"]:
globals()[key] = value
# Cleanup namespace.
del key, value, warnings, mediafile
+124 -84
View File
@@ -15,26 +15,25 @@
"""Support for beets plugins."""
import traceback
import re
import inspect
import abc
import inspect
import re
import traceback
from collections import defaultdict
from functools import wraps
import mediafile
import beets
from beets import logging
import mediafile
PLUGIN_NAMESPACE = 'beetsplug'
PLUGIN_NAMESPACE = "beetsplug"
# Plugins using the Last.fm API can share the same API key.
LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
LASTFM_KEY = "2dc3914abf35f0d9c92d97d8f8e42b43"
# Global logger.
log = logging.getLogger('beets')
log = logging.getLogger("beets")
class PluginConflictException(Exception):
@@ -51,11 +50,10 @@ class PluginLogFilter(logging.Filter):
"""
def __init__(self, plugin):
self.prefix = f'{plugin.name}: '
self.prefix = f"{plugin.name}: "
def filter(self, record):
if hasattr(record.msg, 'msg') and isinstance(record.msg.msg,
str):
if hasattr(record.msg, "msg") and isinstance(record.msg.msg, str):
# A _LogMessage from our hacked-up Logging replacement.
record.msg.msg = self.prefix + record.msg.msg
elif isinstance(record.msg, str):
@@ -65,6 +63,7 @@ class PluginLogFilter(logging.Filter):
# Managing the plugins themselves.
class BeetsPlugin:
"""The base class for all beets plugins. Plugins provide
functionality by defining a subclass of BeetsPlugin and overriding
@@ -72,9 +71,8 @@ class BeetsPlugin:
"""
def __init__(self, name=None):
"""Perform one-time plugin setup.
"""
self.name = name or self.__module__.split('.')[-1]
"""Perform one-time plugin setup."""
self.name = name or self.__module__.split(".")[-1]
self.config = beets.config[self.name]
if not self.template_funcs:
self.template_funcs = {}
@@ -97,10 +95,11 @@ class BeetsPlugin:
return ()
def _set_stage_log_level(self, stages):
"""Adjust all the stages in `stages` to WARNING logging level.
"""
return [self._set_log_level_and_params(logging.WARNING, stage)
for stage in stages]
"""Adjust all the stages in `stages` to WARNING logging level."""
return [
self._set_log_level_and_params(logging.WARNING, stage)
for stage in stages
]
def get_early_import_stages(self):
"""Return a list of functions that should be called as importer
@@ -134,12 +133,11 @@ class BeetsPlugin:
def wrapper(*args, **kwargs):
assert self._log.level == logging.NOTSET
verbosity = beets.config['verbose'].get(int)
verbosity = beets.config["verbose"].get(int)
log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
self._log.setLevel(log_level)
if argspec.varkw is None:
kwargs = {k: v for k, v in kwargs.items()
if k in argspec.args}
kwargs = {k: v for k, v in kwargs.items() if k in argspec.args}
try:
return func(*args, **kwargs)
@@ -149,8 +147,7 @@ class BeetsPlugin:
return wrapper
def queries(self):
"""Should return a dict mapping prefixes to Query subclasses.
"""
"""Return a dict mapping prefixes to Query subclasses."""
return {}
def track_distance(self, item, info):
@@ -201,6 +198,7 @@ class BeetsPlugin:
"""
# Defer import to prevent circular dependency
from beets import library
mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name)
@@ -208,8 +206,7 @@ class BeetsPlugin:
listeners = None
def register_listener(self, event, func):
"""Add a function as a listener for the specified event.
"""
"""Add a function as a listener for the specified event."""
wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
cls = self.__class__
@@ -230,11 +227,13 @@ class BeetsPlugin:
function will be invoked as ``%name{}`` from path format
strings.
"""
def helper(func):
if cls.template_funcs is None:
cls.template_funcs = {}
cls.template_funcs[name] = func
return func
return helper
@classmethod
@@ -244,11 +243,13 @@ class BeetsPlugin:
strings. The function must accept a single parameter, the Item
being formatted.
"""
def helper(func):
if cls.template_fields is None:
cls.template_fields = {}
cls.template_fields[name] = func
return func
return helper
@@ -262,25 +263,29 @@ def load_plugins(names=()):
BeetsPlugin subclasses desired.
"""
for name in names:
modname = f'{PLUGIN_NAMESPACE}.{name}'
modname = f"{PLUGIN_NAMESPACE}.{name}"
try:
try:
namespace = __import__(modname, None, None)
except ImportError as exc:
# Again, this is hacky:
if exc.args[0].endswith(' ' + name):
log.warning('** plugin {0} not found', name)
if exc.args[0].endswith(" " + name):
log.warning("** plugin {0} not found", name)
else:
raise
else:
for obj in getattr(namespace, name).__dict__.values():
if isinstance(obj, type) and issubclass(obj, BeetsPlugin) \
and obj != BeetsPlugin and obj not in _classes:
if (
isinstance(obj, type)
and issubclass(obj, BeetsPlugin)
and obj != BeetsPlugin
and obj not in _classes
):
_classes.add(obj)
except Exception:
log.warning(
'** error loading plugin {}:\n{}',
"** error loading plugin {}:\n{}",
name,
traceback.format_exc(),
)
@@ -311,9 +316,9 @@ def find_plugins():
# Communication with plugins.
def commands():
"""Returns a list of Subcommand objects from all loaded plugins.
"""
"""Returns a list of Subcommand objects from all loaded plugins."""
out = []
for plugin in find_plugins():
out += plugin.commands()
@@ -332,16 +337,16 @@ def queries():
def types(model_cls):
# Gives us `item_types` and `album_types`
attr_name = f'{model_cls.__name__.lower()}_types'
attr_name = f"{model_cls.__name__.lower()}_types"
types = {}
for plugin in find_plugins():
plugin_types = getattr(plugin, attr_name, {})
for field in plugin_types:
if field in types and plugin_types[field] != types[field]:
raise PluginConflictException(
'Plugin {} defines flexible field {} '
'which has already been defined with '
'another type.'.format(plugin.name, field)
"Plugin {} defines flexible field {} "
"which has already been defined with "
"another type.".format(plugin.name, field)
)
types.update(plugin_types)
return types
@@ -349,7 +354,7 @@ def types(model_cls):
def named_queries(model_cls):
# Gather `item_queries` and `album_queries` from the plugins.
attr_name = f'{model_cls.__name__.lower()}_queries'
attr_name = f"{model_cls.__name__.lower()}_queries"
queries = {}
for plugin in find_plugins():
plugin_queries = getattr(plugin, attr_name, {})
@@ -362,6 +367,7 @@ def track_distance(item, info):
Returns a Distance object.
"""
from beets.autotag.hooks import Distance
dist = Distance()
for plugin in find_plugins():
dist.update(plugin.track_distance(item, info))
@@ -371,6 +377,7 @@ def track_distance(item, info):
def album_distance(items, album_info, mapping):
"""Returns the album distance calculated by plugins."""
from beets.autotag.hooks import Distance
dist = Distance()
for plugin in find_plugins():
dist.update(plugin.album_distance(items, album_info, mapping))
@@ -378,23 +385,21 @@ def album_distance(items, album_info, mapping):
def candidates(items, artist, album, va_likely, extra_tags=None):
"""Gets MusicBrainz candidates for an album from each plugin.
"""
"""Gets MusicBrainz candidates for an album from each plugin."""
for plugin in find_plugins():
yield from plugin.candidates(items, artist, album, va_likely,
extra_tags)
yield from plugin.candidates(
items, artist, album, va_likely, extra_tags
)
def item_candidates(item, artist, title):
"""Gets MusicBrainz candidates for an item from the plugins.
"""
"""Gets MusicBrainz candidates for an item from the plugins."""
for plugin in find_plugins():
yield from plugin.item_candidates(item, artist, title)
def album_for_id(album_id):
"""Get AlbumInfo objects for a given ID string.
"""
"""Get AlbumInfo objects for a given ID string."""
for plugin in find_plugins():
album = plugin.album_for_id(album_id)
if album:
@@ -402,8 +407,7 @@ def album_for_id(album_id):
def track_for_id(track_id):
"""Get TrackInfo objects for a given ID string.
"""
"""Get TrackInfo objects for a given ID string."""
for plugin in find_plugins():
track = plugin.track_for_id(track_id)
if track:
@@ -439,29 +443,44 @@ def import_stages():
# New-style (lazy) plugin-provided fields.
def _check_conflicts_and_merge(plugin, plugin_funcs, funcs):
"""Check the provided template functions for conflicts and merge into funcs.
Raises a `PluginConflictException` if a plugin defines template functions
for fields that another plugin has already defined template functions for.
"""
if plugin_funcs:
if not plugin_funcs.keys().isdisjoint(funcs.keys()):
conflicted_fields = ", ".join(plugin_funcs.keys() & funcs.keys())
raise PluginConflictException(
f"Plugin {plugin.name} defines template functions for "
f"{conflicted_fields} that conflict with another plugin."
)
funcs.update(plugin_funcs)
def item_field_getters():
"""Get a dictionary mapping field names to unary functions that
compute the field's value.
"""
funcs = {}
for plugin in find_plugins():
if plugin.template_fields:
funcs.update(plugin.template_fields)
_check_conflicts_and_merge(plugin, plugin.template_fields, funcs)
return funcs
def album_field_getters():
"""As above, for album fields.
"""
"""As above, for album fields."""
funcs = {}
for plugin in find_plugins():
if plugin.album_template_fields:
funcs.update(plugin.album_template_fields)
_check_conflicts_and_merge(plugin, plugin.album_template_fields, funcs)
return funcs
# Event dispatch.
def event_handlers():
"""Find all event handlers from plugins as a dictionary mapping
event names to sequences of callables.
@@ -482,7 +501,7 @@ def send(event, **arguments):
Return a list of non-None values returned from the handlers.
"""
log.debug('Sending event: {0}', event)
log.debug("Sending event: {0}", event)
results = []
for handler in event_handlers()[event]:
result = handler(**arguments)
@@ -497,11 +516,11 @@ def feat_tokens(for_artist=True):
The `for_artist` option determines whether the regex should be
suitable for matching artist fields (the default) or title fields.
"""
feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.']
feat_words = ["ft", "featuring", "feat", "feat.", "ft."]
if for_artist:
feat_words += ['with', 'vs', 'and', 'con', '&']
return r'(?<=\s)(?:{})(?=\s)'.format(
'|'.join(re.escape(x) for x in feat_words)
feat_words += ["with", "vs", "and", "con", "&"]
return r"(?<=\s)(?:{})(?=\s)".format(
"|".join(re.escape(x) for x in feat_words)
)
@@ -517,7 +536,7 @@ def sanitize_choices(choices, choices_all):
if s not in seen:
if s in list(choices_all):
res.append(s)
elif s == '*':
elif s == "*":
res.extend(others)
seen.add(s)
return res
@@ -550,11 +569,11 @@ def sanitize_pairs(pairs, pairs_all):
if x not in seen:
seen.add(x)
res.append(x)
elif k == '*':
elif k == "*":
new = [o for o in others if o not in seen]
seen.update(new)
res.extend(new)
elif v == '*':
elif v == "*":
new = [o for o in others if o not in seen and o[0] == k]
seen.update(new)
res.extend(new)
@@ -568,12 +587,15 @@ def notify_info_yielded(event):
Each yielded value is passed to plugins using the 'info' parameter of
'send'.
"""
def decorator(generator):
def decorated(*args, **kwargs):
for v in generator(*args, **kwargs):
send(event, info=v)
yield v
return decorated
return decorator
@@ -583,7 +605,7 @@ def get_distance(config, data_source, info):
"""
dist = beets.autotag.Distance()
if info.data_source == data_source:
dist.add('source', config['source_weight'].as_number())
dist.add("source", config["source_weight"].as_number())
return dist
@@ -620,7 +642,7 @@ def apply_item_changes(lib, item, move, pretend, write):
class MetadataSourcePlugin(metaclass=abc.ABCMeta):
def __init__(self):
super().__init__()
self.config.add({'source_weight': 0.5})
self.config.add({"source_weight": 0.5})
@abc.abstractproperty
def id_regex(self):
@@ -643,7 +665,7 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
raise NotImplementedError
@abc.abstractmethod
def _search_api(self, query_type, filters, keywords=''):
def _search_api(self, query_type, filters, keywords=""):
raise NotImplementedError
@abc.abstractmethod
@@ -655,7 +677,7 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
raise NotImplementedError
@staticmethod
def get_artist(artists, id_key='id', name_key='name'):
def get_artist(artists, id_key="id", name_key="name", join_key=None):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of artist object dicts.
@@ -663,6 +685,8 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
and 'the') to the front and strips trailing disambiguation numbers. It
returns a tuple containing the comma-separated string of all
normalized artists and the ``id`` of the main/first artist.
Alternatively a keyword can be used to combine artists together into a
single string by passing the join_key argument.
:param artists: Iterable of artist dicts or lists returned by API.
:type artists: list[dict] or list[list]
@@ -673,39 +697,55 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
to concatenate for the artist string (containing all artists).
Defaults to 'name'.
:type name_key: str or int
:param join_key: Key or index corresponding to a field containing a
keyword to use for combining artists into a single string, for
example "Feat.", "Vs.", "And" or similar. The default is None
which keeps the default behaviour (comma-separated).
:type join_key: str or int
:return: Normalized artist string.
:rtype: str
"""
artist_id = None
artist_names = []
for artist in artists:
artist_string = ""
artists = list(artists) # In case a generator was passed.
total = len(artists)
for idx, artist in enumerate(artists):
if not artist_id:
artist_id = artist[id_key]
name = artist[name_key]
# Strip disambiguation number.
name = re.sub(r' \(\d+\)$', '', name)
name = re.sub(r" \(\d+\)$", "", name)
# Move articles to the front.
name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
artist_names.append(name)
artist = ', '.join(artist_names).replace(' ,', ',') or None
return artist, artist_id
name = re.sub(r"^(.*?), (a|an|the)$", r"\2 \1", name, flags=re.I)
# Use a join keyword if requested and available.
if idx < (total - 1): # Skip joining on last.
if join_key and artist.get(join_key, None):
name += f" {artist[join_key]} "
else:
name += ", "
artist_string += name
def _get_id(self, url_type, id_):
return artist_string, artist_id
@staticmethod
def _get_id(url_type, id_, id_regex):
"""Parse an ID from its URL if necessary.
:param url_type: Type of URL. Either 'album' or 'track'.
:type url_type: str
:param id_: Album/track ID or URL.
:type id_: str
:param id_regex: A dictionary containing a regular expression
extracting an ID from an URL (if it's not an ID already) in
'pattern' and the number of the match group in 'match_group'.
:type id_regex: dict
:return: Album/track ID.
:rtype: str
"""
self._log.debug(
"Searching {} for {} '{}'", self.data_source, url_type, id_
)
match = re.search(self.id_regex['pattern'].format(url_type), str(id_))
log.debug("Extracting {} ID from '{}'", url_type, id_)
match = re.search(id_regex["pattern"].format(url_type), str(id_))
if match:
id_ = match.group(self.id_regex['match_group'])
id_ = match.group(id_regex["match_group"])
if id_:
return id_
return None
@@ -726,11 +766,11 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
:return: Candidate AlbumInfo objects.
:rtype: list[beets.autotag.hooks.AlbumInfo]
"""
query_filters = {'album': album}
query_filters = {"album": album}
if not va_likely:
query_filters['artist'] = artist
results = self._search_api(query_type='album', filters=query_filters)
albums = [self.album_for_id(album_id=r['id']) for r in results]
query_filters["artist"] = artist
results = self._search_api(query_type="album", filters=query_filters)
albums = [self.album_for_id(album_id=r["id"]) for r in results]
return [a for a in albums if a is not None]
def item_candidates(self, item, artist, title):
@@ -747,7 +787,7 @@ class MetadataSourcePlugin(metaclass=abc.ABCMeta):
:rtype: list[beets.autotag.hooks.TrackInfo]
"""
tracks = self._search_api(
query_type='track', keywords=title, filters={'artist': artist}
query_type="track", keywords=title, filters={"artist": artist}
)
return [self.track_for_id(track_data=track) for track in tracks]
+7 -8
View File
@@ -12,24 +12,22 @@
# 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.
"""
"""Get a random song or album from the library."""
import random
from operator import attrgetter
from itertools import groupby
from operator import attrgetter
def _length(obj, album):
"""Get the duration of an item or 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):
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.
@@ -86,8 +84,9 @@ def _take_time(iter, secs, album):
return out
def random_objs(objs, album, number=1, time=None, equal_chance=False,
random_gen=None):
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
@@ -1,5 +1,5 @@
# This file is part of beets.
# Copyright 2016-2019, Adrian Sampson.
# Copyright 2024, Lars Kruse
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -12,17 +12,8 @@
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
import confuse
import warnings
warnings.warn("beets.util.confit is deprecated; use confuse instead")
# Import everything from the confuse module into this module.
for key, value in confuse.__dict__.items():
if key not in ['__name__']:
globals()[key] = value
# Cleanup namespace.
del key, value, warnings, confuse
"""This module contains components of beets' test environment, which
may be of use for testing procedures of external libraries or programs.
For example the 'TestHelper' class may be useful for creating an
in-memory beets library filled with a few example items.
"""
+328
View File
@@ -0,0 +1,328 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Some common functionality for beets' test cases."""
import os
import sys
import tempfile
import unittest
from contextlib import contextmanager
import beets
import beets.library
# Make sure the development versions of the plugins are used
import beetsplug
from beets import importer, logging, util
from beets.ui import commands
from beets.util import syspath
beetsplug.__path__ = [
os.path.abspath(
os.path.join(
os.path.dirname(__file__),
os.path.pardir,
os.path.pardir,
"beetsplug",
)
)
]
# Test resources path.
RSRC = util.bytestring_path(
os.path.abspath(
os.path.join(
os.path.dirname(__file__),
os.path.pardir,
os.path.pardir,
"test",
"rsrc",
)
)
)
PLUGINPATH = os.path.join(RSRC.decode(), "beetsplug")
# Propagate to root logger so the test runner can capture it
log = logging.getLogger("beets")
log.propagate = True
log.setLevel(logging.DEBUG)
# Dummy item creation.
_item_ident = 0
# OS feature test.
HAVE_SYMLINK = sys.platform != "win32"
HAVE_HARDLINK = sys.platform != "win32"
try:
import reflink
HAVE_REFLINK = reflink.supported_at(tempfile.gettempdir())
except ImportError:
HAVE_REFLINK = False
def item(lib=None):
global _item_ident
_item_ident += 1
i = beets.library.Item(
title="the title",
artist="the artist",
albumartist="the album artist",
album="the album",
genre="the genre",
lyricist="the lyricist",
composer="the composer",
arranger="the arranger",
grouping="the grouping",
work="the work title",
mb_workid="the work musicbrainz id",
work_disambig="the work disambiguation",
year=1,
month=2,
day=3,
track=4,
tracktotal=5,
disc=6,
disctotal=7,
lyrics="the lyrics",
comments="the comments",
bpm=8,
comp=True,
path=f"somepath{_item_ident}",
length=60.0,
bitrate=128000,
format="FLAC",
mb_trackid="someID-1",
mb_albumid="someID-2",
mb_artistid="someID-3",
mb_albumartistid="someID-4",
mb_releasetrackid="someID-5",
album_id=None,
mtime=12345,
)
if lib:
lib.add(i)
return i
def album(lib=None):
global _item_ident
_item_ident += 1
i = beets.library.Album(
artpath=None,
albumartist="some album artist",
albumartist_sort="some sort album artist",
albumartist_credit="some album artist credit",
album="the album",
genre="the genre",
year=2014,
month=2,
day=5,
tracktotal=0,
disctotal=1,
comp=False,
mb_albumid="someID-1",
mb_albumartistid="someID-1",
)
if lib:
lib.add(i)
return i
# Dummy import session.
def import_session(lib=None, loghandler=None, paths=[], query=[], cli=False):
cls = commands.TerminalImportSession if cli else importer.ImportSession
return cls(lib, loghandler, paths, query)
class Assertions:
"""A mixin with additional unit test assertions."""
def assertExists(self, path): # noqa
assert os.path.exists(syspath(path)), f"file does not exist: {path!r}"
def assertNotExists(self, path): # noqa
assert not os.path.exists(syspath(path)), f"file exists: {path!r}"
def assertIsFile(self, path): # noqa
self.assertExists(path)
assert os.path.isfile(
syspath(path)
), "path exists, but is not a regular file: {!r}".format(path)
def assertIsDir(self, path): # noqa
self.assertExists(path)
assert os.path.isdir(
syspath(path)
), "path exists, but is not a directory: {!r}".format(path)
def assert_equal_path(self, a, b):
"""Check that two paths are equal."""
a_bytes, b_bytes = util.normpath(a), util.normpath(b)
assert a_bytes == b_bytes, f"{a_bytes=} != {b_bytes=}"
# Mock I/O.
class InputException(Exception):
def __init__(self, output=None):
self.output = output
def __str__(self):
msg = "Attempt to read with no input provided."
if self.output is not None:
msg += f" Output: {self.output!r}"
return msg
class DummyOut:
encoding = "utf-8"
def __init__(self):
self.buf = []
def write(self, s):
self.buf.append(s)
def get(self):
return "".join(self.buf)
def flush(self):
self.clear()
def clear(self):
self.buf = []
class DummyIn:
encoding = "utf-8"
def __init__(self, out=None):
self.buf = []
self.reads = 0
self.out = out
def add(self, s):
self.buf.append(s + "\n")
def close(self):
pass
def readline(self):
if not self.buf:
if self.out:
raise InputException(self.out.get())
else:
raise InputException()
self.reads += 1
return self.buf.pop(0)
class DummyIO:
"""Mocks input and output streams for testing UI code."""
def __init__(self):
self.stdout = DummyOut()
self.stdin = DummyIn(self.stdout)
def addinput(self, s):
self.stdin.add(s)
def getoutput(self):
res = self.stdout.get()
self.stdout.clear()
return res
def readcount(self):
return self.stdin.reads
def install(self):
sys.stdin = self.stdin
sys.stdout = self.stdout
def restore(self):
sys.stdin = sys.__stdin__
sys.stdout = sys.__stdout__
# Utility.
def touch(path):
open(syspath(path), "a").close()
class Bag:
"""An object that exposes a set of fields given as keyword
arguments. Any field not found in the dictionary appears to be None.
Used for mocking Album objects and the like.
"""
def __init__(self, **fields):
self.fields = fields
def __getattr__(self, key):
return self.fields.get(key)
# Platform mocking.
@contextmanager
def platform_windows():
import ntpath
old_path = os.path
try:
os.path = ntpath
yield
finally:
os.path = old_path
@contextmanager
def platform_posix():
import posixpath
old_path = os.path
try:
os.path = posixpath
yield
finally:
os.path = old_path
@contextmanager
def system_mock(name):
import platform
old_system = platform.system
platform.system = lambda: name
try:
yield
finally:
platform.system = old_system
def slow_test(unused=None):
def _id(obj):
return obj
if "SKIP_SLOW_TESTS" in os.environ:
return unittest.skip("test is slow")
return _id
File diff suppressed because it is too large Load Diff
+884 -304
View File
File diff suppressed because it is too large Load Diff
+1309 -654
View File
File diff suppressed because it is too large Load Diff
+28 -5
View File
@@ -31,7 +31,7 @@
# plugins dynamically
#
# Currently, only Bash 3.2 and newer is supported and the
# `bash-completion` package is requied.
# `bash-completion` package (v2.8 or newer) is required.
#
# TODO
# ----
@@ -46,7 +46,30 @@
# * Support long options with `=`, e.g. `--config=file`. Debian's bash
# completion package can handle this.
#
# Note that 'bash-completion' v2.8 is a part of Debian 10, which is part of
# LTS until 2024-06-30. After this date, the minimum version requirement can
# be changed, and newer features can be used unconditionally. See PR#5301.
#
if [[ ${BASH_COMPLETION_VERSINFO[0]} -ne 2 \
|| ${BASH_COMPLETION_VERSINFO[1]} -lt 8 ]]; then
echo "Incompatible version of 'bash-completion'!"
return 1
fi
# The later code relies on 'bash-completion' version 2.12, but older versions
# are still supported. Here, we provide implementations of the newer functions
# in terms of older ones, if 'bash-completion' is too old to have them.
if [[ ${BASH_COMPLETION_VERSINFO[1]} -lt 12 ]]; then
_comp_get_words() {
_get_comp_words_by_ref "$@"
}
_comp_compgen_filedir() {
_filedir "$@"
}
fi
# Determines the beets subcommand and dispatches the completion
# accordingly.
@@ -54,7 +77,7 @@ _beet_dispatch() {
local cur prev cmd=
COMPREPLY=()
_get_comp_words_by_ref -n : cur prev
_comp_get_words -n : cur prev
# Look for the beets subcommand
local arg
@@ -99,7 +122,7 @@ _beet_complete() {
completions="${flags___common} ${opts} ${flags}"
COMPREPLY+=( $(compgen -W "$completions" -- $cur) )
else
_filedir
_comp_compgen_filedir
fi
}
@@ -114,12 +137,12 @@ _beet_complete_global() {
;;
-l|--library|-c|--config)
# Filename completion
_filedir
_comp_compgen_filedir
return
;;
-d|--directory)
# Directory completion
_filedir -d
_comp_compgen_filedir -d
return
;;
esac
+365 -351
View File
File diff suppressed because it is too large Load Diff
+522 -315
View File
@@ -16,23 +16,20 @@
public resizing proxy if neither is available.
"""
import subprocess
import os
import os.path
import platform
import re
from tempfile import NamedTemporaryFile
import subprocess
from itertools import chain
from urllib.parse import urlencode
from beets import logging
from beets import util
# Resizing methods
PIL = 1
IMAGEMAGICK = 2
WEBPROXY = 3
from beets import logging, util
from beets.util import displayable_path, get_temp_filename, syspath
PROXY_URL = 'https://images.weserv.nl/'
PROXY_URL = "https://images.weserv.nl/"
log = logging.getLogger('beets')
log = logging.getLogger("beets")
def resize_url(url, maxwidth, quality=0):
@@ -40,265 +37,473 @@ def resize_url(url, maxwidth, quality=0):
maxwidth (preserving aspect ratio).
"""
params = {
'url': url.replace('http://', ''),
'w': maxwidth,
"url": url.replace("http://", ""),
"w": maxwidth,
}
if quality > 0:
params['q'] = quality
params["q"] = quality
return '{}?{}'.format(PROXY_URL, urlencode(params))
return "{}?{}".format(PROXY_URL, urlencode(params))
def temp_file_for(path):
"""Return an unused filename with the same extension as the
specified path.
"""
ext = os.path.splitext(path)[1]
with NamedTemporaryFile(suffix=util.py3_path(ext), delete=False) as f:
return util.bytestring_path(f.name)
class LocalBackendNotAvailableError(Exception):
pass
def pil_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
path_out = path_out or temp_file_for(path_in)
from PIL import Image
_NOT_AVAILABLE = object()
log.debug('artresizer: PIL resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out))
try:
im = Image.open(util.syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.ANTIALIAS)
class LocalBackend:
@classmethod
def available(cls):
try:
cls.version()
return True
except LocalBackendNotAvailableError:
return False
if quality == 0:
# Use PIL's default quality.
quality = -1
# progressive=False only affects JPEGs and is the default,
# but we include it here for explicitness.
im.save(util.py3_path(path_out), quality=quality, progressive=False)
class IMBackend(LocalBackend):
NAME = "ImageMagick"
if max_filesize > 0:
# If maximum filesize is set, we attempt to lower the quality of
# jpeg conversion by a proportional amount, up to 3 attempts
# First, set the maximum quality to either provided, or 95
if quality > 0:
lower_qual = quality
else:
lower_qual = 95
for i in range(5):
# 5 attempts is an abitrary choice
filesize = os.stat(util.syspath(path_out)).st_size
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
if filesize <= max_filesize:
return path_out
# The relationship between filesize & quality will be
# image dependent.
lower_qual -= 10
# Restrict quality dropping below 10
if lower_qual < 10:
lower_qual = 10
# Use optimize flag to improve filesize decrease
im.save(util.py3_path(path_out), quality=lower_qual,
optimize=True, progressive=False)
log.warning("PIL Failed to resize file to below {0}B",
max_filesize)
return path_out
# These fields are used as a cache for `version()`. `_legacy` indicates
# whether the modern `magick` binary is available or whether to fall back
# to the old-style `convert`, `identify`, etc. commands.
_version = None
_legacy = None
@classmethod
def version(cls):
"""Obtain and cache ImageMagick version.
Raises `LocalBackendNotAvailableError` if not available.
"""
if cls._version is None:
for cmd_name, legacy in (("magick", False), ("convert", True)):
try:
out = util.command_output([cmd_name, "--version"]).stdout
except (subprocess.CalledProcessError, OSError) as exc:
log.debug("ImageMagick version check failed: {}", exc)
cls._version = _NOT_AVAILABLE
else:
if b"imagemagick" in out.lower():
pattern = rb".+ (\d+)\.(\d+)\.(\d+).*"
match = re.search(pattern, out)
if match:
cls._version = (
int(match.group(1)),
int(match.group(2)),
int(match.group(3)),
)
cls._legacy = legacy
if cls._version is _NOT_AVAILABLE:
raise LocalBackendNotAvailableError()
else:
return path_out
except OSError:
log.error("PIL cannot create thumbnail for '{0}'",
util.displayable_path(path_in))
return path_in
return cls._version
def __init__(self):
"""Initialize a wrapper around ImageMagick for local image operations.
def im_resize(maxwidth, path_in, path_out=None, quality=0, max_filesize=0):
"""Resize using ImageMagick.
Stores the ImageMagick version and legacy flag. If ImageMagick is not
available, raise an Exception.
"""
self.version()
Use the ``magick`` program or ``convert`` on older versions. Return
the output path of resized image.
"""
path_out = path_out or temp_file_for(path_in)
log.debug('artresizer: ImageMagick resizing {0} to {1}',
util.displayable_path(path_in), util.displayable_path(path_out))
# 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._legacy:
self.convert_cmd = ["convert"]
self.identify_cmd = ["identify"]
self.compare_cmd = ["compare"]
else:
self.convert_cmd = ["magick"]
self.identify_cmd = ["magick", "identify"]
self.compare_cmd = ["magick", "compare"]
# "-resize WIDTHx>" shrinks images with the width larger
# than the given width while maintaining the aspect ratio
# with regards to the height.
# ImageMagick already seems to default to no interlace, but we include it
# here for the sake of explicitness.
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(path_in, prefix=False),
'-resize', f'{maxwidth}x>',
'-interlace', 'none',
]
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
"""Resize using ImageMagick.
if quality > 0:
cmd += ['-quality', f'{quality}']
Use the ``magick`` program or ``convert`` on older versions. Return
the output path of resized image.
"""
if not path_out:
path_out = get_temp_filename(__name__, "resize_IM_", path_in)
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick to
# SIZE in bytes.
if max_filesize > 0:
cmd += ['-define', f'jpeg:extent={max_filesize}b']
cmd.append(util.syspath(path_out, prefix=False))
try:
util.command_output(cmd)
except subprocess.CalledProcessError:
log.warning('artresizer: IM convert failed for {0}',
util.displayable_path(path_in))
return path_in
return path_out
BACKEND_FUNCS = {
PIL: pil_resize,
IMAGEMAGICK: im_resize,
}
def pil_getsize(path_in):
from PIL import Image
try:
im = Image.open(util.syspath(path_in))
return im.size
except OSError as exc:
log.error("PIL could not read file {}: {}",
util.displayable_path(path_in), exc)
def im_getsize(path_in):
cmd = ArtResizer.shared.im_identify_cmd + \
['-format', '%w %h', util.syspath(path_in, prefix=False)]
try:
out = util.command_output(cmd).stdout
except subprocess.CalledProcessError as exc:
log.warning('ImageMagick size query failed')
log.debug(
'`convert` exited with (status {}) when '
'getting size with command {}:\n{}',
exc.returncode, cmd, exc.output.strip()
"artresizer: ImageMagick resizing {0} to {1}",
displayable_path(path_in),
displayable_path(path_out),
)
return
try:
return tuple(map(int, out.split(b' ')))
except IndexError:
log.warning('Could not understand IM output: {0!r}', out)
# "-resize WIDTHx>" shrinks images with the width larger
# than the given width while maintaining the aspect ratio
# with regards to the height.
# ImageMagick already seems to default to no interlace, but we include
# it here for the sake of explicitness.
cmd = self.convert_cmd + [
syspath(path_in, prefix=False),
"-resize",
f"{maxwidth}x>",
"-interlace",
"none",
]
BACKEND_GET_SIZE = {
PIL: pil_getsize,
IMAGEMAGICK: im_getsize,
}
if quality > 0:
cmd += ["-quality", f"{quality}"]
# "-define jpeg:extent=SIZEb" sets the target filesize for imagemagick
# to SIZE in bytes.
if max_filesize > 0:
cmd += ["-define", f"jpeg:extent={max_filesize}b"]
def pil_deinterlace(path_in, path_out=None):
path_out = path_out or temp_file_for(path_in)
from PIL import Image
cmd.append(syspath(path_out, prefix=False))
try:
util.command_output(cmd)
except subprocess.CalledProcessError:
log.warning(
"artresizer: IM convert failed for {0}",
displayable_path(path_in),
)
return path_in
try:
im = Image.open(util.syspath(path_in))
im.save(util.py3_path(path_out), progressive=False)
return path_out
except IOError:
return path_in
def get_size(self, path_in):
cmd = self.identify_cmd + [
"-format",
"%w %h",
syspath(path_in, prefix=False),
]
def im_deinterlace(path_in, path_out=None):
path_out = path_out or temp_file_for(path_in)
try:
out = util.command_output(cmd).stdout
except subprocess.CalledProcessError as exc:
log.warning("ImageMagick size query failed")
log.debug(
"`convert` exited with (status {}) when "
"getting size with command {}:\n{}",
exc.returncode,
cmd,
exc.output.strip(),
)
return None
try:
return tuple(map(int, out.split(b" ")))
except IndexError:
log.warning("Could not understand IM output: {0!r}", out)
return None
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(path_in, prefix=False),
'-interlace', 'none',
util.syspath(path_out, prefix=False),
]
def deinterlace(self, path_in, path_out=None):
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_IM_", path_in)
try:
util.command_output(cmd)
return path_out
except subprocess.CalledProcessError:
return path_in
cmd = self.convert_cmd + [
syspath(path_in, prefix=False),
"-interlace",
"none",
syspath(path_out, prefix=False),
]
try:
util.command_output(cmd)
return path_out
except subprocess.CalledProcessError:
# FIXME: Should probably issue a warning?
return path_in
DEINTERLACE_FUNCS = {
PIL: pil_deinterlace,
IMAGEMAGICK: im_deinterlace,
}
def get_format(self, filepath):
cmd = self.identify_cmd + ["-format", "%[magick]", syspath(filepath)]
try:
return util.command_output(cmd).stdout
except subprocess.CalledProcessError:
# FIXME: Should probably issue a warning?
return None
def im_get_format(filepath):
cmd = ArtResizer.shared.im_identify_cmd + [
'-format', '%[magick]',
util.syspath(filepath)
]
def convert_format(self, source, target, deinterlaced):
cmd = self.convert_cmd + [
syspath(source),
*(["-interlace", "none"] if deinterlaced else []),
syspath(target),
]
try:
return util.command_output(cmd).stdout
except subprocess.CalledProcessError:
return None
def pil_get_format(filepath):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(util.syspath(filepath)) as im:
return im.format
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError):
log.exception("failed to detect image format for {}", filepath)
return None
BACKEND_GET_FORMAT = {
PIL: pil_get_format,
IMAGEMAGICK: im_get_format,
}
def im_convert_format(source, target, deinterlaced):
cmd = ArtResizer.shared.im_convert_cmd + [
util.syspath(source),
*(["-interlace", "none"] if deinterlaced else []),
util.syspath(target),
]
try:
subprocess.check_call(
cmd,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL
)
return target
except subprocess.CalledProcessError:
return source
def pil_convert_format(source, target, deinterlaced):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(util.syspath(source)) as im:
im.save(util.py3_path(target), progressive=not deinterlaced)
try:
subprocess.check_call(
cmd, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL
)
return target
except (ValueError, TypeError, UnidentifiedImageError, FileNotFoundError,
OSError):
log.exception("failed to convert image {} -> {}", source, target)
return source
except subprocess.CalledProcessError:
# FIXME: Should probably issue a warning?
return source
@property
def can_compare(self):
return self.version() > (6, 8, 7)
def compare(self, im1, im2, compare_threshold):
is_windows = platform.system() == "Windows"
# Converting images to grayscale tends to minimize the weight
# of colors in the diff score. So we first convert both images
# to grayscale and then pipe them into the `compare` command.
# On Windows, ImageMagick doesn't support the magic \\?\ prefix
# on paths, so we pass `prefix=False` to `syspath`.
convert_cmd = self.convert_cmd + [
syspath(im2, prefix=False),
syspath(im1, prefix=False),
"-colorspace",
"gray",
"MIFF:-",
]
compare_cmd = self.compare_cmd + [
"-define",
"phash:colorspaces=sRGB,HCLp",
"-metric",
"PHASH",
"-",
"null:",
]
log.debug(
"comparing images with pipeline {} | {}", convert_cmd, compare_cmd
)
convert_proc = subprocess.Popen(
convert_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
compare_proc = subprocess.Popen(
compare_cmd,
stdin=convert_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
close_fds=not is_windows,
)
# Check the convert output. We're not interested in the
# standard output; that gets piped to the next stage.
convert_proc.stdout.close()
convert_stderr = convert_proc.stderr.read()
convert_proc.stderr.close()
convert_proc.wait()
if convert_proc.returncode:
log.debug(
"ImageMagick convert failed with status {}: {!r}",
convert_proc.returncode,
convert_stderr,
)
return None
# Check the compare output.
stdout, stderr = compare_proc.communicate()
if compare_proc.returncode:
if compare_proc.returncode != 1:
log.debug(
"ImageMagick compare failed: {0}, {1}",
displayable_path(im2),
displayable_path(im1),
)
return None
out_str = stderr
else:
out_str = stdout
try:
phash_diff = float(out_str)
except ValueError:
log.debug("IM output is not a number: {0!r}", out_str)
return None
log.debug("ImageMagick compare score: {0}", phash_diff)
return phash_diff <= compare_threshold
@property
def can_write_metadata(self):
return True
def write_metadata(self, file, metadata):
assignments = list(
chain.from_iterable(("-set", k, v) for k, v in metadata.items())
)
command = self.convert_cmd + [file, *assignments, file]
util.command_output(command)
BACKEND_CONVERT_IMAGE_FORMAT = {
PIL: pil_convert_format,
IMAGEMAGICK: im_convert_format,
}
class PILBackend(LocalBackend):
NAME = "PIL"
@classmethod
def version(cls):
try:
__import__("PIL", fromlist=["Image"])
except ImportError:
raise LocalBackendNotAvailableError()
def __init__(self):
"""Initialize a wrapper around PIL for local image operations.
If PIL is not available, raise an Exception.
"""
self.version()
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
):
"""Resize using Python Imaging Library (PIL). Return the output path
of resized image.
"""
if not path_out:
path_out = get_temp_filename(__name__, "resize_PIL_", path_in)
from PIL import Image
log.debug(
"artresizer: PIL resizing {0} to {1}",
displayable_path(path_in),
displayable_path(path_out),
)
try:
im = Image.open(syspath(path_in))
size = maxwidth, maxwidth
im.thumbnail(size, Image.Resampling.LANCZOS)
if quality == 0:
# Use PIL's default quality.
quality = -1
# progressive=False only affects JPEGs and is the default,
# but we include it here for explicitness.
im.save(os.fsdecode(path_out), quality=quality, progressive=False)
if max_filesize > 0:
# If maximum filesize is set, we attempt to lower the quality
# of jpeg conversion by a proportional amount, up to 3 attempts
# First, set the maximum quality to either provided, or 95
if quality > 0:
lower_qual = quality
else:
lower_qual = 95
for i in range(5):
# 5 attempts is an arbitrary choice
filesize = os.stat(syspath(path_out)).st_size
log.debug("PIL Pass {0} : Output size: {1}B", i, filesize)
if filesize <= max_filesize:
return path_out
# The relationship between filesize & quality will be
# image dependent.
lower_qual -= 10
# Restrict quality dropping below 10
if lower_qual < 10:
lower_qual = 10
# Use optimize flag to improve filesize decrease
im.save(
os.fsdecode(path_out),
quality=lower_qual,
optimize=True,
progressive=False,
)
log.warning(
"PIL Failed to resize file to below {0}B", max_filesize
)
return path_out
else:
return path_out
except OSError:
log.error(
"PIL cannot create thumbnail for '{0}'",
displayable_path(path_in),
)
return path_in
def get_size(self, path_in):
from PIL import Image
try:
im = Image.open(syspath(path_in))
return im.size
except OSError as exc:
log.error(
"PIL could not read file {}: {}", displayable_path(path_in), exc
)
return None
def deinterlace(self, path_in, path_out=None):
if not path_out:
path_out = get_temp_filename(__name__, "deinterlace_PIL_", path_in)
from PIL import Image
try:
im = Image.open(syspath(path_in))
im.save(os.fsdecode(path_out), progressive=False)
return path_out
except OSError:
# FIXME: Should probably issue a warning?
return path_in
def get_format(self, filepath):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(syspath(filepath)) as im:
return im.format
except (
ValueError,
TypeError,
UnidentifiedImageError,
FileNotFoundError,
):
log.exception("failed to detect image format for {}", filepath)
return None
def convert_format(self, source, target, deinterlaced):
from PIL import Image, UnidentifiedImageError
try:
with Image.open(syspath(source)) as im:
im.save(os.fsdecode(target), progressive=not deinterlaced)
return target
except (
ValueError,
TypeError,
UnidentifiedImageError,
FileNotFoundError,
OSError,
):
log.exception("failed to convert image {} -> {}", source, target)
return source
@property
def can_compare(self):
return False
def compare(self, im1, im2, compare_threshold):
# It is an error to call this when ArtResizer.can_compare is not True.
raise NotImplementedError()
@property
def can_write_metadata(self):
return True
def write_metadata(self, file, metadata):
from PIL import Image, PngImagePlugin
# FIXME: Detect and handle other file types (currently, the only user
# is the thumbnails plugin, which generates PNG images).
im = Image.open(syspath(file))
meta = PngImagePlugin.PngInfo()
for k, v in metadata.items():
meta.add_text(k, v, 0)
im.save(os.fsdecode(file), "PNG", pnginfo=meta)
class Shareable(type):
@@ -319,28 +524,36 @@ class Shareable(type):
return cls._instance
BACKEND_CLASSES = [
IMBackend,
PILBackend,
]
class ArtResizer(metaclass=Shareable):
"""A singleton class that performs image resizes.
"""
"""A singleton class that performs image resizes."""
def __init__(self):
"""Create a resizer object with an inferred method.
"""
self.method = self._check_method()
log.debug("artresizer: method is {0}", self.method)
self.can_compare = self._can_compare()
"""Create a resizer object with an inferred method."""
# Check if a local backend is available, and store an instance of the
# backend class. Otherwise, fallback to the web proxy.
for backend_cls in BACKEND_CLASSES:
try:
self.local_method = backend_cls()
log.debug(f"artresizer: method is {self.local_method.NAME}")
break
except LocalBackendNotAvailableError:
continue
else:
log.debug("artresizer: method is WEBPROXY")
self.local_method = None
# 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']
@property
def method(self):
if self.local:
return self.local_method.NAME
else:
return "WEBPROXY"
def resize(
self, maxwidth, path_in, path_out=None, quality=0, max_filesize=0
@@ -351,17 +564,26 @@ class ArtResizer(metaclass=Shareable):
For WEBPROXY, returns `path_in` unmodified.
"""
if self.local:
func = BACKEND_FUNCS[self.method[0]]
return func(maxwidth, path_in, path_out,
quality=quality, max_filesize=max_filesize)
return self.local_method.resize(
maxwidth,
path_in,
path_out,
quality=quality,
max_filesize=max_filesize,
)
else:
# Handled by `proxy_url` already.
return path_in
def deinterlace(self, path_in, path_out=None):
"""Deinterlace an image.
Only available locally.
"""
if self.local:
func = DEINTERLACE_FUNCS[self.method[0]]
return func(path_in, path_out)
return self.local_method.deinterlace(path_in, path_out)
else:
# FIXME: Should probably issue a warning?
return path_in
def proxy_url(self, maxwidth, url, quality=0):
@@ -370,6 +592,7 @@ class ArtResizer(metaclass=Shareable):
Otherwise, the URL is returned unmodified.
"""
if self.local:
# Going to be handled by `resize()`.
return url
else:
return resize_url(url, maxwidth, quality)
@@ -379,7 +602,7 @@ class ArtResizer(metaclass=Shareable):
"""A boolean indicating whether the resizing method is performed
locally (i.e., PIL or ImageMagick).
"""
return self.method[0] in BACKEND_FUNCS
return self.local_method is not None
def get_size(self, path_in):
"""Return the size of an image file as an int couple (width, height)
@@ -388,8 +611,10 @@ class ArtResizer(metaclass=Shareable):
Only available locally.
"""
if self.local:
func = BACKEND_GET_SIZE[self.method[0]]
return func(path_in)
return self.local_method.get_size(path_in)
else:
# FIXME: Should probably issue a warning?
return path_in
def get_format(self, path_in):
"""Returns the format of the image as a string.
@@ -397,8 +622,10 @@ class ArtResizer(metaclass=Shareable):
Only available locally.
"""
if self.local:
func = BACKEND_GET_FORMAT[self.method[0]]
return func(path_in)
return self.local_method.get_format(path_in)
else:
# FIXME: Should probably issue a warning?
return None
def reformat(self, path_in, new_format, deinterlaced=True):
"""Converts image to desired format, updating its extension, but
@@ -407,86 +634,66 @@ class ArtResizer(metaclass=Shareable):
Only available locally.
"""
if not self.local:
# FIXME: Should probably issue a warning?
return path_in
new_format = new_format.lower()
# A nonexhaustive map of image "types" to extensions overrides
new_format = {
'jpeg': 'jpg',
"jpeg": "jpg",
}.get(new_format, new_format)
fname, ext = os.path.splitext(path_in)
path_new = fname + b'.' + new_format.encode('utf8')
func = BACKEND_CONVERT_IMAGE_FORMAT[self.method[0]]
path_new = fname + b"." + new_format.encode("utf8")
# allows the exception to propagate, while still making sure a changed
# file path was removed
result_path = path_in
try:
result_path = func(path_in, path_new, deinterlaced)
result_path = self.local_method.convert_format(
path_in, path_new, deinterlaced
)
finally:
if result_path != path_in:
os.unlink(path_in)
return result_path
def _can_compare(self):
@property
def can_compare(self):
"""A boolean indicating whether image comparison is available"""
return self.method[0] == IMAGEMAGICK and self.method[1] > (6, 8, 7)
@staticmethod
def _check_method():
"""Return a tuple indicating an available method and its version.
The result has at least two elements:
- The method, eitehr WEBPROXY, PIL, or IMAGEMAGICK.
- The version.
If the method is IMAGEMAGICK, there is also a third element: a
bool flag indicating whether to use the `magick` binary or
legacy single-purpose executables (`convert`, `identify`, etc.)
"""
version = get_im_version()
if version:
version, legacy = version
return IMAGEMAGICK, version, legacy
version = get_pil_version()
if version:
return PIL, version
return WEBPROXY, (0)
def get_im_version():
"""Get the ImageMagick version and legacy flag as a pair. Or return
None if ImageMagick is not available.
"""
for cmd_name, legacy in ((['magick'], False), (['convert'], True)):
cmd = cmd_name + ['--version']
try:
out = util.command_output(cmd).stdout
except (subprocess.CalledProcessError, OSError) as exc:
log.debug('ImageMagick version check failed: {}', exc)
if self.local:
return self.local_method.can_compare
else:
if b'imagemagick' in out.lower():
pattern = br".+ (\d+)\.(\d+)\.(\d+).*"
match = re.search(pattern, out)
if match:
version = (int(match.group(1)),
int(match.group(2)),
int(match.group(3)))
return version, legacy
return False
return None
def compare(self, im1, im2, compare_threshold):
"""Return a boolean indicating whether two images are similar.
Only available locally.
"""
if self.local:
return self.local_method.compare(im1, im2, compare_threshold)
else:
# FIXME: Should probably issue a warning?
return None
def get_pil_version():
"""Get the PIL/Pillow version, or None if it is unavailable.
"""
try:
__import__('PIL', fromlist=['Image'])
return (0,)
except ImportError:
return None
@property
def can_write_metadata(self):
"""A boolean indicating whether writing image metadata is supported."""
if self.local:
return self.local_method.can_write_metadata
else:
return False
def write_metadata(self, file, metadata):
"""Write key-value metadata to the image file.
Only available locally. Currently, expects the image to be a PNG file.
"""
if self.local:
self.local_method.write_metadata(file, metadata)
else:
# FIXME: Should probably issue a warning?
pass
+60 -41
View File
@@ -6,23 +6,24 @@ asyncore.
Bluelet: easy concurrency without all the messy parallelism.
"""
import socket
import select
import sys
import types
import errno
import traceback
import time
import collections
import errno
import select
import socket
import sys
import time
import traceback
import types
# Basic events used for thread scheduling.
class Event:
"""Just a base class identifying Bluelet events. An event is an
object yielded from a Bluelet thread coroutine to suspend operation
and communicate with the scheduler.
"""
pass
@@ -31,6 +32,7 @@ class WaitableEvent(Event):
waited for using a select() call. That is, it's an event with an
associated file descriptor.
"""
def waitables(self):
"""Return "waitable" objects to pass to select(). Should return
three iterables for input readiness, output readiness, and
@@ -48,18 +50,21 @@ class WaitableEvent(Event):
class ValueEvent(Event):
"""An event that does nothing but return a fixed value."""
def __init__(self, value):
self.value = value
class ExceptionEvent(Event):
"""Raise an exception at the yield point. Used internally."""
def __init__(self, exc_info):
self.exc_info = exc_info
class SpawnEvent(Event):
"""Add a new coroutine thread to the scheduler."""
def __init__(self, coro):
self.spawned = coro
@@ -68,12 +73,14 @@ class JoinEvent(Event):
"""Suspend the thread until the specified child thread has
completed.
"""
def __init__(self, child):
self.child = child
class KillEvent(Event):
"""Unschedule a child thread."""
def __init__(self, child):
self.child = child
@@ -83,6 +90,7 @@ class DelegationEvent(Event):
once the child thread finished, return control to the parent
thread.
"""
def __init__(self, coro):
self.spawned = coro
@@ -91,13 +99,14 @@ class ReturnEvent(Event):
"""Return a value the current thread's delegator at the point of
delegation. Ends the current (delegate) thread.
"""
def __init__(self, value):
self.value = value
class SleepEvent(WaitableEvent):
"""Suspend the thread for a given duration.
"""
"""Suspend the thread for a given duration."""
def __init__(self, duration):
self.wakeup_time = time.time() + duration
@@ -107,6 +116,7 @@ class SleepEvent(WaitableEvent):
class ReadEvent(WaitableEvent):
"""Reads from a file-like object."""
def __init__(self, fd, bufsize):
self.fd = fd
self.bufsize = bufsize
@@ -120,6 +130,7 @@ class ReadEvent(WaitableEvent):
class WriteEvent(WaitableEvent):
"""Writes to a file-like object."""
def __init__(self, fd, data):
self.fd = fd
self.data = data
@@ -133,6 +144,7 @@ class WriteEvent(WaitableEvent):
# Core logic for executing and scheduling threads.
def _event_select(events):
"""Perform a select() over all the Events provided, returning the
ones ready to be fired. Only WaitableEvents (including SleepEvents)
@@ -154,11 +166,11 @@ def _event_select(events):
wlist += w
xlist += x
for waitable in r:
waitable_to_event[('r', waitable)] = event
waitable_to_event[("r", waitable)] = event
for waitable in w:
waitable_to_event[('w', waitable)] = event
waitable_to_event[("w", waitable)] = event
for waitable in x:
waitable_to_event[('x', waitable)] = event
waitable_to_event[("x", waitable)] = event
# If we have a any sleeping threads, determine how long to sleep.
if earliest_wakeup:
@@ -177,11 +189,11 @@ def _event_select(events):
# Gather ready events corresponding to the ready waitables.
ready_events = set()
for ready in rready:
ready_events.add(waitable_to_event[('r', ready)])
ready_events.add(waitable_to_event[("r", ready)])
for ready in wready:
ready_events.add(waitable_to_event[('w', ready)])
ready_events.add(waitable_to_event[("w", ready)])
for ready in xready:
ready_events.add(waitable_to_event[('x', ready)])
ready_events.add(waitable_to_event[("x", ready)])
# Gather any finished sleeps.
for event in events:
@@ -207,6 +219,7 @@ class Delegated(Event):
"""Placeholder indicating that a thread has delegated execution to a
different thread.
"""
def __init__(self, child):
self.child = child
@@ -277,8 +290,7 @@ def run(root_coro):
threads[coro] = next_event
def kill_thread(coro):
"""Unschedule this thread and its (recursive) delegates.
"""
"""Unschedule this thread and its (recursive) delegates."""
# Collect all coroutines in the delegation stack.
coros = [coro]
while isinstance(threads[coro], Delegated):
@@ -338,12 +350,16 @@ def run(root_coro):
try:
value = event.fire()
except OSError as exc:
if isinstance(exc.args, tuple) and \
exc.args[0] == errno.EPIPE:
if (
isinstance(exc.args, tuple)
and exc.args[0] == errno.EPIPE
):
# Broken pipe. Remote host disconnected.
pass
elif isinstance(exc.args, tuple) and \
exc.args[0] == errno.ECONNRESET:
elif (
isinstance(exc.args, tuple)
and exc.args[0] == errno.ECONNRESET
):
# Connection was reset by peer.
pass
else:
@@ -382,16 +398,16 @@ def run(root_coro):
# Sockets and their associated events.
class SocketClosedError(Exception):
pass
class Listener:
"""A socket wrapper object for listening sockets.
"""
"""A socket wrapper object for listening sockets."""
def __init__(self, host, port):
"""Create a listening socket on the given hostname and port.
"""
"""Create a listening socket on the given hostname and port."""
self._closed = False
self.host = host
self.port = port
@@ -410,19 +426,18 @@ class Listener:
return AcceptEvent(self)
def close(self):
"""Immediately close the listening socket. (Not an event.)
"""
"""Immediately close the listening socket. (Not an event.)"""
self._closed = True
self.sock.close()
class Connection:
"""A socket wrapper object for connected sockets.
"""
"""A socket wrapper object for connected sockets."""
def __init__(self, sock, addr):
self.sock = sock
self.addr = addr
self._buf = b''
self._buf = b""
self._closed = False
def close(self):
@@ -473,7 +488,7 @@ class Connection:
self._buf += data
else:
line = self._buf
self._buf = b''
self._buf = b""
yield ReturnEvent(line)
break
@@ -482,6 +497,7 @@ class AcceptEvent(WaitableEvent):
"""An event for Listener objects (listening sockets) that suspends
execution until the socket gets a connection.
"""
def __init__(self, listener):
self.listener = listener
@@ -497,6 +513,7 @@ class ReceiveEvent(WaitableEvent):
"""An event for Connection objects (connected sockets) for
asynchronously reading data.
"""
def __init__(self, conn, bufsize):
self.conn = conn
self.bufsize = bufsize
@@ -512,6 +529,7 @@ class SendEvent(WaitableEvent):
"""An event for Connection objects (connected sockets) for
asynchronously writing data.
"""
def __init__(self, conn, data, sendall=False):
self.conn = conn
self.data = data
@@ -530,9 +548,9 @@ class SendEvent(WaitableEvent):
# Public interface for threads; each returns an event object that
# can immediately be "yield"ed.
def null():
"""Event: yield to the scheduler without doing anything special.
"""
"""Event: yield to the scheduler without doing anything special."""
return ValueEvent(None)
@@ -541,7 +559,7 @@ def spawn(coro):
and child coroutines run concurrently.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError('%s is not a coroutine' % coro)
raise ValueError("%s is not a coroutine" % coro)
return SpawnEvent(coro)
@@ -551,7 +569,7 @@ def call(coro):
returns a value using end(), then this event returns that value.
"""
if not isinstance(coro, types.GeneratorType):
raise ValueError('%s is not a coroutine' % coro)
raise ValueError("%s is not a coroutine" % coro)
return DelegationEvent(coro)
@@ -573,7 +591,8 @@ def read(fd, bufsize=None):
if not data:
break
buf.append(data)
yield ReturnEvent(''.join(buf))
yield ReturnEvent("".join(buf))
return DelegationEvent(reader())
else:
@@ -595,8 +614,7 @@ def connect(host, port):
def sleep(duration):
"""Event: suspend the thread for ``duration`` seconds.
"""
"""Event: suspend the thread for ``duration`` seconds."""
return SleepEvent(duration)
@@ -608,19 +626,20 @@ def join(coro):
def kill(coro):
"""Halt the execution of a different `spawn`ed thread.
"""
"""Halt the execution of a different `spawn`ed thread."""
return KillEvent(coro)
# Convenience function for running socket servers.
def server(host, port, func):
"""A coroutine that runs a network server. Host and port specify the
listening address. func should be a coroutine that takes a single
parameter, a Connection object. The coroutine is invoked for every
incoming connection on the listening socket.
"""
def handler(conn):
try:
yield func(conn)
+1
View File
@@ -20,6 +20,7 @@ class OrderedEnum(Enum):
"""
An Enum subclass that allows comparison of members.
"""
def __ge__(self, other):
if self.__class__ is other.__class__:
return self.value >= other.value
+105 -116
View File
@@ -27,22 +27,21 @@ engine like Jinja2 or Mustache.
"""
import re
import ast
import dis
import types
import sys
import functools
import re
import types
SYMBOL_DELIM = '$'
FUNC_DELIM = '%'
GROUP_OPEN = '{'
GROUP_CLOSE = '}'
ARG_SEP = ','
ESCAPE_CHAR = '$'
SYMBOL_DELIM = "$"
FUNC_DELIM = "%"
GROUP_OPEN = "{"
GROUP_CLOSE = "}"
ARG_SEP = ","
ESCAPE_CHAR = "$"
VARIABLE_PREFIX = '__var_'
FUNCTION_PREFIX = '__func_'
VARIABLE_PREFIX = "__var_"
FUNCTION_PREFIX = "__func_"
class Environment:
@@ -57,10 +56,6 @@ class Environment:
# Code generation helpers.
def ex_lvalue(name):
"""A variable load expression."""
return ast.Name(name, ast.Store())
def ex_rvalue(name):
"""A variable store expression."""
@@ -74,15 +69,6 @@ def ex_literal(val):
return ast.Constant(val)
def ex_varassign(name, expr):
"""Assign an expression into a single variable. The expression may
either be an `ast.expr` object or a value to be used as a literal.
"""
if not isinstance(expr, ast.expr):
expr = ex_literal(expr)
return ast.Assign([ex_lvalue(name)], expr)
def ex_call(func, args):
"""A function-call expression with only positional parameters. The
function may be an expression or the name of a function. Each
@@ -99,19 +85,18 @@ def ex_call(func, args):
return ast.Call(func, args, [])
def compile_func(arg_names, statements, name='_the_func', debug=False):
def compile_func(arg_names, statements, name="_the_func", debug=False):
"""Compile a list of statements as the body of a function and return
the resulting Python function. If `debug`, then print out the
bytecode of the compiled function.
"""
args_fields = {
'args': [ast.arg(arg=n, annotation=None) for n in arg_names],
'kwonlyargs': [],
'kw_defaults': [],
'defaults': [ex_literal(None) for _ in arg_names],
"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_fields["posonlyargs"] = []
args = ast.arguments(**args_fields)
func_def = ast.FunctionDef(
@@ -123,14 +108,11 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
# 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')
prog = compile(mod, "<generated>", "exec")
# Debug: show bytecode.
if debug:
@@ -146,6 +128,7 @@ def compile_func(arg_names, statements, name='_the_func', debug=False):
# AST nodes for the template language.
class Symbol:
"""A variable-substitution symbol in a template."""
@@ -154,7 +137,7 @@ class Symbol:
self.original = original
def __repr__(self):
return 'Symbol(%s)' % repr(self.ident)
return "Symbol(%s)" % repr(self.ident)
def evaluate(self, env):
"""Evaluate the symbol in the environment, returning a Unicode
@@ -183,8 +166,9 @@ class Call:
self.original = original
def __repr__(self):
return 'Call({}, {}, {})'.format(repr(self.ident), repr(self.args),
repr(self.original))
return "Call({}, {}, {})".format(
repr(self.ident), repr(self.args), repr(self.original)
)
def evaluate(self, env):
"""Evaluate the function call in the environment, returning a
@@ -197,7 +181,7 @@ class Call:
except Exception as exc:
# Function raised exception! Maybe inlining the name of
# the exception will help debug.
return '<%s>' % str(exc)
return "<%s>" % str(exc)
return str(out)
else:
return self.original
@@ -215,21 +199,22 @@ class Call:
# Create a subexpression that joins the result components of
# the arguments.
arg_exprs.append(ex_call(
ast.Attribute(ex_literal(''), 'join', ast.Load()),
[ex_call(
'map',
arg_exprs.append(
ex_call(
ast.Attribute(ex_literal(""), "join", ast.Load()),
[
ex_rvalue(str.__name__),
ast.List(subexprs, ast.Load()),
]
)],
))
ex_call(
"map",
[
ex_rvalue(str.__name__),
ast.List(subexprs, ast.Load()),
],
)
],
)
)
subexpr_call = ex_call(
FUNCTION_PREFIX + self.ident,
arg_exprs
)
subexpr_call = ex_call(FUNCTION_PREFIX + self.ident, arg_exprs)
return [subexpr_call], varnames, funcnames
@@ -242,7 +227,7 @@ class Expression:
self.parts = parts
def __repr__(self):
return 'Expression(%s)' % (repr(self.parts))
return "Expression(%s)" % (repr(self.parts))
def evaluate(self, env):
"""Evaluate the entire expression in the environment, returning
@@ -254,7 +239,7 @@ class Expression:
out.append(part)
else:
out.append(part.evaluate(env))
return ''.join(map(str, out))
return "".join(map(str, out))
def translate(self):
"""Compile the expression to a list of Python AST expressions, a
@@ -276,6 +261,7 @@ class Expression:
# Parser.
class ParseError(Exception):
pass
@@ -295,7 +281,7 @@ class Parser:
"""
def __init__(self, string, in_argument=False):
""" Create a new parser.
"""Create a new parser.
:param in_arguments: boolean that indicates the parser is to be
used for parsing function arguments, ie. considering commas
(`ARG_SEP`) a special character
@@ -306,10 +292,16 @@ class Parser:
self.parts = []
# Common parsing resources.
special_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_OPEN, GROUP_CLOSE,
ESCAPE_CHAR)
special_char_re = re.compile(r'[%s]|\Z' %
''.join(re.escape(c) for c in special_chars))
special_chars = (
SYMBOL_DELIM,
FUNC_DELIM,
GROUP_OPEN,
GROUP_CLOSE,
ESCAPE_CHAR,
)
special_char_re = re.compile(
r"[%s]|\Z" % "".join(re.escape(c) for c in special_chars)
)
escapable_chars = (SYMBOL_DELIM, FUNC_DELIM, GROUP_CLOSE, ARG_SEP)
terminator_chars = (GROUP_CLOSE,)
@@ -326,9 +318,10 @@ class Parser:
if self.in_argument:
extra_special_chars = (ARG_SEP,)
special_char_re = re.compile(
r'[%s]|\Z' % ''.join(
re.escape(c) for c in
self.special_chars + extra_special_chars
r"[%s]|\Z"
% "".join(
re.escape(c)
for c in self.special_chars + extra_special_chars
)
)
@@ -341,10 +334,10 @@ class Parser:
# A non-special character. Skip to the next special
# character, treating the interstice as literal text.
next_pos = (
special_char_re.search(
self.string[self.pos:]).start() + self.pos
special_char_re.search(self.string[self.pos :]).start()
+ self.pos
)
text_parts.append(self.string[self.pos:next_pos])
text_parts.append(self.string[self.pos : next_pos])
self.pos = next_pos
continue
@@ -358,8 +351,9 @@ class Parser:
break
next_char = self.string[self.pos + 1]
if char == ESCAPE_CHAR and next_char in (self.escapable_chars +
extra_special_chars):
if char == ESCAPE_CHAR and next_char in (
self.escapable_chars + extra_special_chars
):
# An escaped special character ($$, $}, etc.). Note that
# ${ is not an escape sequence: this is ambiguous with
# the start of a symbol and it's not necessary (just
@@ -370,7 +364,7 @@ class Parser:
# Shift all characters collected so far into a single string.
if text_parts:
self.parts.append(''.join(text_parts))
self.parts.append("".join(text_parts))
text_parts = []
if char == SYMBOL_DELIM:
@@ -392,7 +386,7 @@ class Parser:
# If any parsed characters remain, shift them into a string.
if text_parts:
self.parts.append(''.join(text_parts))
self.parts.append("".join(text_parts))
def parse_symbol(self):
"""Parse a variable reference (like ``$foo`` or ``${foo}``)
@@ -419,21 +413,23 @@ class Parser:
closer = self.string.find(GROUP_CLOSE, self.pos)
if closer == -1 or closer == self.pos:
# No closing brace found or identifier is empty.
self.parts.append(self.string[start_pos:self.pos])
self.parts.append(self.string[start_pos : self.pos])
else:
# Closer found.
ident = self.string[self.pos:closer]
ident = self.string[self.pos : closer]
self.pos = closer + 1
self.parts.append(Symbol(ident,
self.string[start_pos:self.pos]))
self.parts.append(
Symbol(ident, self.string[start_pos : self.pos])
)
else:
# A bare-word symbol.
ident = self._parse_ident()
if ident:
# Found a real symbol.
self.parts.append(Symbol(ident,
self.string[start_pos:self.pos]))
self.parts.append(
Symbol(ident, self.string[start_pos : self.pos])
)
else:
# A standalone $.
self.parts.append(SYMBOL_DELIM)
@@ -457,25 +453,24 @@ class Parser:
if self.pos >= len(self.string):
# Identifier terminates string.
self.parts.append(self.string[start_pos:self.pos])
self.parts.append(self.string[start_pos : self.pos])
return
if self.string[self.pos] != GROUP_OPEN:
# Argument list not opened.
self.parts.append(self.string[start_pos:self.pos])
self.parts.append(self.string[start_pos : self.pos])
return
# Skip past opening brace and try to parse an argument list.
self.pos += 1
args = self.parse_argument_list()
if self.pos >= len(self.string) or \
self.string[self.pos] != GROUP_CLOSE:
if self.pos >= len(self.string) or self.string[self.pos] != GROUP_CLOSE:
# Arguments unclosed.
self.parts.append(self.string[start_pos:self.pos])
self.parts.append(self.string[start_pos : self.pos])
return
self.pos += 1 # Move past closing brace.
self.parts.append(Call(ident, args, self.string[start_pos:self.pos]))
self.parts.append(Call(ident, args, self.string[start_pos : self.pos]))
def parse_argument_list(self):
"""Parse a list of arguments starting at ``pos``, returning a
@@ -487,15 +482,17 @@ class Parser:
expressions = []
while self.pos < len(self.string):
subparser = Parser(self.string[self.pos:], in_argument=True)
subparser = Parser(self.string[self.pos :], in_argument=True)
subparser.parse_expression()
# Extract and advance past the parsed expression.
expressions.append(Expression(subparser.parts))
self.pos += subparser.pos
if self.pos >= len(self.string) or \
self.string[self.pos] == GROUP_CLOSE:
if (
self.pos >= len(self.string)
or self.string[self.pos] == GROUP_CLOSE
):
# Argument list terminated by EOF or closing brace.
break
@@ -510,8 +507,8 @@ class Parser:
"""Parse an identifier and return it (possibly an empty string).
Updates ``pos``.
"""
remainder = self.string[self.pos:]
ident = re.match(r'\w*', remainder).group(0)
remainder = self.string[self.pos :]
ident = re.match(r"\w*", remainder).group(0)
self.pos += len(ident)
return ident
@@ -524,32 +521,20 @@ def _parse(template):
parser.parse_expression()
parts = parser.parts
remainder = parser.string[parser.pos:]
remainder = parser.string[parser.pos :]
if remainder:
parts.append(remainder)
return Expression(parts)
def cached(func):
"""Like the `functools.lru_cache` decorator, but works (as a no-op)
on Python < 3.2.
"""
if hasattr(functools, 'lru_cache'):
return functools.lru_cache(maxsize=128)(func)
else:
# Do nothing when lru_cache is not available.
return func
@cached
@functools.lru_cache(maxsize=128)
def template(fmt):
return Template(fmt)
# External interface.
class Template:
"""A string template, including text, Symbols, and Calls.
"""
"""A string template, including text, Symbols, and Calls."""
def __init__(self, template):
self.expr = _parse(template)
@@ -568,8 +553,7 @@ class Template:
return self.expr.evaluate(Environment(values, functions))
def substitute(self, values={}, functions={}):
"""Evaluate the template given the values and functions.
"""
"""Evaluate the template given the values and functions."""
try:
res = self.compiled(values, functions)
except Exception: # Handle any exceptions thrown by compiled version.
@@ -599,24 +583,29 @@ class Template:
for funcname in funcnames:
args[FUNCTION_PREFIX + funcname] = functions[funcname]
parts = func(**args)
return ''.join(parts)
return "".join(parts)
return wrapper_func
# Performance tests.
if __name__ == '__main__':
if __name__ == "__main__":
import timeit
_tmpl = Template('foo $bar %baz{foozle $bar barzle} $bar')
_vars = {'bar': 'qux'}
_funcs = {'baz': str.upper}
interp_time = timeit.timeit('_tmpl.interpret(_vars, _funcs)',
'from __main__ import _tmpl, _vars, _funcs',
number=10000)
_tmpl = Template("foo $bar %baz{foozle $bar barzle} $bar")
_vars = {"bar": "qux"}
_funcs = {"baz": str.upper}
interp_time = timeit.timeit(
"_tmpl.interpret(_vars, _funcs)",
"from __main__ import _tmpl, _vars, _funcs",
number=10000,
)
print(interp_time)
comp_time = timeit.timeit('_tmpl.substitute(_vars, _funcs)',
'from __main__ import _tmpl, _vars, _funcs',
number=10000)
comp_time = timeit.timeit(
"_tmpl.substitute(_vars, _funcs)",
"from __main__ import _tmpl, _vars, _funcs",
number=10000,
)
print(comp_time)
print('Speedup:', interp_time / comp_time)
print("Speedup:", interp_time / comp_time)
+30 -51
View File
@@ -1,5 +1,6 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
# Copyright 2024, Arav K.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
@@ -14,71 +15,49 @@
"""Simple library to work out if a file is hidden on different platforms."""
import ctypes
import os
import stat
import ctypes
import sys
import beets.util
from pathlib import Path
from typing import Union
def _is_hidden_osx(path):
"""Return whether or not a file is hidden on OS X.
This uses os.lstat to work out if a file has the "hidden" flag.
def is_hidden(path: Union[bytes, Path]) -> bool:
"""
file_stat = os.lstat(beets.util.syspath(path))
if hasattr(file_stat, 'st_flags') and hasattr(stat, 'UF_HIDDEN'):
return bool(file_stat.st_flags & stat.UF_HIDDEN)
else:
return False
def _is_hidden_win(path):
"""Return whether or not a file is hidden on Windows.
This uses GetFileAttributes to work out if a file has the "hidden" flag
(FILE_ATTRIBUTE_HIDDEN).
Determine whether the given path is treated as a 'hidden file' by the OS.
"""
# FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation.
hidden_mask = 2
# Retrieve the attributes for the file.
attrs = ctypes.windll.kernel32.GetFileAttributesW(beets.util.syspath(path))
if isinstance(path, bytes):
path = Path(os.fsdecode(path))
# Ensure we have valid attribues and compare them against the mask.
return attrs >= 0 and attrs & hidden_mask
# TODO: Avoid doing a platform check on every invocation of the function.
# TODO: Stop supporting 'bytes' inputs once 'pathlib' is fully integrated.
if sys.platform == "win32":
# On Windows, we check for an FS-provided attribute.
def _is_hidden_dot(path):
"""Return whether or not a file starts with a dot.
# FILE_ATTRIBUTE_HIDDEN = 2 (0x2) from GetFileAttributes documentation.
hidden_mask = 2
Files starting with a dot are seen as "hidden" files on Unix-based OSes.
"""
return os.path.basename(path).startswith(b'.')
# Retrieve the attributes for the file.
attrs = ctypes.windll.kernel32.GetFileAttributesW(str(path))
# Ensure the attribute mask is valid.
if attrs < 0:
return False
def is_hidden(path):
"""Return whether or not a file is hidden. `path` should be a
bytestring filename.
# Check for the hidden attribute.
return attrs & hidden_mask
This method works differently depending on the platform it is called on.
# On OS X, we check for an FS-provided attribute.
if sys.platform == "darwin":
if hasattr(os.stat_result, "st_flags") and hasattr(stat, "UF_HIDDEN"):
if path.lstat().st_flags & stat.UF_HIDDEN:
return True
On OS X, it uses both the result of `is_hidden_osx` and `is_hidden_dot` to
work out if a file is hidden.
# On all non-Windows platforms, we check for a '.'-prefixed file name.
if path.name.startswith("."):
return True
On Windows, it uses the result of `is_hidden_win` to work out if a file is
hidden.
On any other operating systems (i.e. Linux), it uses `is_hidden_dot` to
work out if a file is hidden.
"""
# Run platform specific functions depending on the platform
if sys.platform == 'darwin':
return _is_hidden_osx(path) or _is_hidden_dot(path)
elif sys.platform == 'win32':
return _is_hidden_win(path)
else:
return _is_hidden_dot(path)
__all__ = ['is_hidden']
return False
+65
View File
@@ -0,0 +1,65 @@
# This file is part of beets.
# Copyright 2016, Adrian Sampson.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Helpers around the extraction of album/track ID's from metadata sources."""
import re
# Spotify IDs consist of 22 alphanumeric characters
# (zero-left-padded base62 representation of randomly generated UUID4)
spotify_id_regex = {
"pattern": r"(^|open\.spotify\.com/{}/)([0-9A-Za-z]{{22}})",
"match_group": 2,
}
deezer_id_regex = {
"pattern": r"(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)",
"match_group": 4,
}
beatport_id_regex = {
"pattern": r"(^|beatport\.com/release/.+/)(\d+)$",
"match_group": 2,
}
# A note on Bandcamp: There is no such thing as a Bandcamp album or artist ID,
# the URL can be used as the identifier. The Bandcamp metadata source plugin
# works that way - https://github.com/snejus/beetcamp. Bandcamp album
# URLs usually look like: https://nameofartist.bandcamp.com/album/nameofalbum
def extract_discogs_id_regex(album_id):
"""Returns the Discogs_id or None."""
# Discogs-IDs are simple integers. In order to avoid confusion with
# other metadata plugins, we only look for very specific formats of the
# input string:
# - plain integer, optionally wrapped in brackets and prefixed by an
# 'r', as this is how discogs displays the release ID on its webpage.
# - legacy url format: discogs.com/<name of release>/release/<id>
# - legacy url short format: discogs.com/release/<id>
# - current url format: discogs.com/release/<id>-<name of release>
# See #291, #4080 and #4085 for the discussions leading up to these
# patterns.
# Regex has been tested here https://regex101.com/r/TOu7kw/1
for pattern in [
r"^\[?r?(?P<id>\d+)\]?$",
r"discogs\.com/release/(?P<id>\d+)-?",
r"discogs\.com/[^/]+/release/(?P<id>\d+)",
]:
match = re.search(pattern, album_id)
if match:
return int(match.group("id"))
return None
+97
View File
@@ -0,0 +1,97 @@
# This file is part of beets.
# Copyright 2022, J0J0 Todos.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Provides utilities to read, write and manipulate m3u playlist files."""
import traceback
from beets.util import FilesystemError, mkdirall, normpath, syspath
class EmptyPlaylistError(Exception):
"""Raised when a playlist file without media files is saved or loaded."""
pass
class M3UFile:
"""Reads and writes m3u or m3u8 playlist files."""
def __init__(self, path):
"""``path`` is the absolute path to the playlist file.
The playlist file type, m3u or m3u8 is determined by 1) the ending
being m3u8 and 2) the file paths contained in the list being utf-8
encoded. Since the list is passed from the outside, this is currently
out of control of this class.
"""
self.path = path
self.extm3u = False
self.media_list = []
def load(self):
"""Reads the m3u file from disk and sets the object's attributes."""
pl_normpath = normpath(self.path)
try:
with open(syspath(pl_normpath), "rb") as pl_file:
raw_contents = pl_file.readlines()
except OSError as exc:
raise FilesystemError(
exc, "read", (pl_normpath,), traceback.format_exc()
)
self.extm3u = True if raw_contents[0].rstrip() == b"#EXTM3U" else False
for line in raw_contents[1:]:
if line.startswith(b"#"):
# Support for specific EXTM3U comments could be added here.
continue
self.media_list.append(normpath(line.rstrip()))
if not self.media_list:
raise EmptyPlaylistError
def set_contents(self, media_list, extm3u=True):
"""Sets self.media_list to a list of media file paths.
Also sets additional flags, changing the final m3u-file's format.
``media_list`` is a list of paths to media files that should be added
to the playlist (relative or absolute paths, that's the responsibility
of the caller). By default the ``extm3u`` flag is set, to ensure a
save-operation writes an m3u-extended playlist (comment "#EXTM3U" at
the top of the file).
"""
self.media_list = media_list
self.extm3u = extm3u
def write(self):
"""Writes the m3u file to disk.
Handles the creation of potential parent directories.
"""
header = [b"#EXTM3U"] if self.extm3u else []
if not self.media_list:
raise EmptyPlaylistError
contents = header + self.media_list
pl_normpath = normpath(self.path)
mkdirall(pl_normpath)
try:
with open(syspath(pl_normpath), "wb") as pl_file:
for line in contents:
pl_file.write(line + b"\n")
pl_file.write(b"\n") # Final linefeed to prevent noeol file.
except OSError as exc:
raise FilesystemError(
exc, "create", (pl_normpath,), traceback.format_exc()
)
+29 -28
View File
@@ -33,11 +33,11 @@ in place of any single coroutine.
import queue
from threading import Thread, Lock
import sys
from threading import Lock, Thread
BUBBLE = '__PIPELINE_BUBBLE__'
POISON = '__PIPELINE_POISON__'
BUBBLE = "__PIPELINE_BUBBLE__"
POISON = "__PIPELINE_POISON__"
DEFAULT_QUEUE_SIZE = 16
@@ -48,6 +48,7 @@ def _invalidate_queue(q, val=None, sync=True):
which defaults to None. `sync` controls whether a lock is
required (because it's not reentrant!).
"""
def _qsize(len=len):
return 1
@@ -75,8 +76,8 @@ def _invalidate_queue(q, val=None, sync=True):
q._qsize = _qsize
q._put = _put
q._get = _get
q.not_empty.notifyAll()
q.not_full.notifyAll()
q.not_empty.notify_all()
q.not_full.notify_all()
finally:
if sync:
@@ -168,6 +169,7 @@ def stage(func):
while True:
task = yield task
task = func(*(args + (task,)))
return coro
@@ -191,6 +193,7 @@ def mutator_stage(func):
while True:
task = yield task
func(*(args + (task,)))
return coro
@@ -218,20 +221,18 @@ class PipelineThread(Thread):
self.exc_info = None
def abort(self):
"""Shut down the thread at the next chance possible.
"""
"""Shut down the thread at the next chance possible."""
with self.abort_lock:
self.abort_flag = True
# Ensure that we are not blocking on a queue read or write.
if hasattr(self, 'in_queue'):
if hasattr(self, "in_queue"):
_invalidate_queue(self.in_queue, POISON)
if hasattr(self, 'out_queue'):
if hasattr(self, "out_queue"):
_invalidate_queue(self.out_queue, POISON)
def abort_all(self, exc_info):
"""Abort all other threads in the system for an exception.
"""
"""Abort all other threads in the system for an exception."""
self.exc_info = exc_info
for thread in self.all_threads:
thread.abort()
@@ -373,7 +374,7 @@ class Pipeline:
be at least two stages.
"""
if len(stages) < 2:
raise ValueError('pipeline must have at least two stages')
raise ValueError("pipeline must have at least two stages")
self.stages = []
for stage in stages:
if isinstance(stage, (list, tuple)):
@@ -405,15 +406,15 @@ class Pipeline:
# Middle stages.
for i in range(1, queue_count):
for coro in self.stages[i]:
threads.append(MiddlePipelineThread(
coro, queues[i - 1], queues[i], threads
))
threads.append(
MiddlePipelineThread(
coro, queues[i - 1], queues[i], threads
)
)
# Last stage.
for coro in self.stages[-1]:
threads.append(
LastPipelineThread(coro, queues[-1], threads)
)
threads.append(LastPipelineThread(coro, queues[-1], threads))
# Start threads.
for thread in threads:
@@ -472,21 +473,21 @@ class Pipeline:
# Smoke test.
if __name__ == '__main__':
if __name__ == "__main__":
import time
# Test a normally-terminating pipeline both in sequence and
# in parallel.
def produce():
for i in range(5):
print('generating %i' % i)
print("generating %i" % i)
time.sleep(1)
yield i
def work():
num = yield
while True:
print('processing %i' % num)
print("processing %i" % num)
time.sleep(2)
num = yield num * 2
@@ -494,7 +495,7 @@ if __name__ == '__main__':
while True:
num = yield
time.sleep(1)
print('received %i' % num)
print("received %i" % num)
ts_start = time.time()
Pipeline([produce(), work(), consume()]).run_sequential()
@@ -503,22 +504,22 @@ if __name__ == '__main__':
ts_par = time.time()
Pipeline([produce(), (work(), work()), consume()]).run_parallel()
ts_end = time.time()
print('Sequential time:', ts_seq - ts_start)
print('Parallel time:', ts_par - ts_seq)
print('Multiply-parallel time:', ts_end - ts_par)
print("Sequential time:", ts_seq - ts_start)
print("Parallel time:", ts_par - ts_seq)
print("Multiply-parallel time:", ts_end - ts_par)
print()
# Test a pipeline that raises an exception.
def exc_produce():
for i in range(10):
print('generating %i' % i)
print("generating %i" % i)
time.sleep(1)
yield i
def exc_work():
num = yield
while True:
print('processing %i' % num)
print("processing %i" % num)
time.sleep(3)
if num == 3:
raise Exception()
@@ -527,6 +528,6 @@ if __name__ == '__main__':
def exc_consume():
while True:
num = yield
print('received %i' % num)
print("received %i" % num)
Pipeline([exc_produce(), exc_work(), exc_consume()]).run_parallel(1)
+2 -1
View File
@@ -17,9 +17,10 @@ libraries.
"""
from collections import namedtuple
from beets import util
Node = namedtuple('Node', ['files', 'dirs'])
Node = namedtuple("Node", ["files", "dirs"])
def _insert(node, path, itemid):
+1
View File
@@ -17,4 +17,5 @@
# Make this a namespace package.
from pkgutil import extend_path
__path__ = extend_path(__path__, __name__)
+86 -55
View File
@@ -22,16 +22,14 @@ import json
import os
import subprocess
import tempfile
from distutils.spawn import find_executable
import requests
from beets import plugins
from beets import util
from beets import ui
from beets import plugins, ui, util
# We use this field to check whether AcousticBrainz info is present.
PROBE_FIELD = 'mood_acoustic'
PROBE_FIELD = "mood_acoustic"
class ABSubmitError(Exception):
@@ -47,39 +45,39 @@ def call(args):
return util.command_output(args).stdout
except subprocess.CalledProcessError as e:
raise ABSubmitError(
'{} exited with status {}'.format(args[0], e.returncode)
"{} exited with status {}".format(args[0], e.returncode)
)
class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'extractor': '',
'force': False,
'pretend': False
})
self._log.warning("This plugin is deprecated.")
self.extractor = self.config['extractor'].as_str()
self.config.add(
{"extractor": "", "force": False, "pretend": False, "base_url": ""}
)
self.extractor = self.config["extractor"].as_str()
if self.extractor:
self.extractor = util.normpath(self.extractor)
# Expicit path to extractor
# Explicit path to extractor
if not os.path.isfile(self.extractor):
raise ui.UserError(
'Extractor command does not exist: {0}.'.
format(self.extractor)
"Extractor command does not exist: {0}.".format(
self.extractor
)
)
else:
# Implicit path to extractor, search for it in path
self.extractor = 'streaming_extractor_music'
self.extractor = "streaming_extractor_music"
try:
call([self.extractor])
except OSError:
raise ui.UserError(
'No extractor command found: please install the extractor'
' binary from https://acousticbrainz.org/download'
"No extractor command found: please install the extractor"
" binary from https://essentia.upf.edu/"
)
except ABSubmitError:
# Extractor found, will exit with an error if not called with
@@ -92,36 +90,58 @@ class AcousticBrainzSubmitPlugin(plugins.BeetsPlugin):
# Calculate extractor hash.
self.extractor_sha = hashlib.sha1()
with open(self.extractor, 'rb') as extractor:
with open(self.extractor, "rb") as extractor:
self.extractor_sha.update(extractor.read())
self.extractor_sha = self.extractor_sha.hexdigest()
base_url = 'https://acousticbrainz.org/api/v1/{mbid}/low-level'
self.url = ""
base_url = self.config["base_url"].as_str()
if base_url:
if not base_url.startswith("http"):
raise ui.UserError(
"AcousticBrainz server base URL must start "
"with an HTTP scheme"
)
elif base_url[-1] != "/":
base_url = base_url + "/"
self.url = base_url + "{mbid}/low-level"
def commands(self):
cmd = ui.Subcommand(
'absubmit',
help='calculate and submit AcousticBrainz analysis'
"absubmit", help="calculate and submit AcousticBrainz analysis"
)
cmd.parser.add_option(
'-f', '--force', dest='force_refetch',
action='store_true', default=False,
help='re-download data when already present'
"-f",
"--force",
dest="force_refetch",
action="store_true",
default=False,
help="re-download data when already present",
)
cmd.parser.add_option(
'-p', '--pretend', dest='pretend_fetch',
action='store_true', default=False,
help='pretend to perform action, but show \
only files which would be processed'
"-p",
"--pretend",
dest="pretend_fetch",
action="store_true",
default=False,
help="pretend to perform action, but show \
only files which would be processed",
)
cmd.func = self.command
return [cmd]
def command(self, lib, opts, args):
# Get items from arguments
items = lib.items(ui.decargs(args))
self.opts = opts
util.par_map(self.analyze_submit, items)
if not self.url:
raise ui.UserError(
"This plugin is deprecated since AcousticBrainz no longer "
"accepts new submissions. See the base_url configuration "
"option."
)
else:
# Get items from arguments
items = lib.items(ui.decargs(args))
self.opts = opts
util.par_map(self.analyze_submit, items)
def analyze_submit(self, item):
analysis = self._get_analysis(item)
@@ -129,28 +149,29 @@ only files which would be processed'
self._submit_data(item, analysis)
def _get_analysis(self, item):
mbid = item['mb_trackid']
mbid = item["mb_trackid"]
# Avoid re-analyzing files that already have AB data.
if not self.opts.force_refetch and not self.config['force']:
if not self.opts.force_refetch and not self.config["force"]:
if item.get(PROBE_FIELD):
return None
# If file has no MBID, skip it.
if not mbid:
self._log.info('Not analysing {}, missing '
'musicbrainz track id.', item)
self._log.info(
"Not analysing {}, missing " "musicbrainz track id.", item
)
return None
if self.opts.pretend_fetch or self.config['pretend']:
self._log.info('pretend action - extract item: {}', item)
if self.opts.pretend_fetch or self.config["pretend"]:
self._log.info("pretend action - extract item: {}", item)
return None
# Temporary file to save extractor output to, extractor only works
# if an output file is given. Here we use a temporary file to copy
# the data into a python object and then remove the file from the
# system.
tmp_file, filename = tempfile.mkstemp(suffix='.json')
tmp_file, filename = tempfile.mkstemp(suffix=".json")
try:
# Close the file, so the extractor can overwrite it.
os.close(tmp_file)
@@ -158,15 +179,17 @@ only files which would be processed'
call([self.extractor, util.syspath(item.path), filename])
except ABSubmitError as e:
self._log.warning(
'Failed to analyse {item} for AcousticBrainz: {error}',
item=item, error=e
"Failed to analyse {item} for AcousticBrainz: {error}",
item=item,
error=e,
)
return None
with open(filename) as tmp_file:
analysis = json.load(tmp_file)
# Add the hash to the output.
analysis['metadata']['version']['essentia_build_sha'] = \
self.extractor_sha
analysis["metadata"]["version"][
"essentia_build_sha"
] = self.extractor_sha
return analysis
finally:
try:
@@ -177,20 +200,28 @@ only files which would be processed'
raise
def _submit_data(self, item, data):
mbid = item['mb_trackid']
headers = {'Content-Type': 'application/json'}
response = requests.post(self.base_url.format(mbid=mbid),
json=data, headers=headers)
mbid = item["mb_trackid"]
headers = {"Content-Type": "application/json"}
response = requests.post(
self.url.format(mbid=mbid),
json=data,
headers=headers,
timeout=10,
)
# Test that request was successful and raise an error on failure.
if response.status_code != 200:
try:
message = response.json()['message']
message = response.json()["message"]
except (ValueError, KeyError) as e:
message = f'unable to get error message: {e}'
message = f"unable to get error message: {e}"
self._log.error(
'Failed to submit AcousticBrainz analysis of {item}: '
'{message}).', item=item, message=message
"Failed to submit AcousticBrainz analysis of {item}: "
"{message}).",
item=item,
message=message,
)
else:
self._log.debug('Successfully submitted AcousticBrainz analysis '
'for {}.', item)
self._log.debug(
"Successfully submitted AcousticBrainz analysis " "for {}.",
item,
)
+131 -161
View File
@@ -22,220 +22,187 @@ import requests
from beets import plugins, ui
from beets.dbcore import types
ACOUSTIC_BASE = "https://acousticbrainz.org/"
LEVELS = ["/low-level", "/high-level"]
ABSCHEME = {
'highlevel': {
'danceability': {
'all': {
'danceable': 'danceable'
}
},
'gender': {
'value': 'gender'
},
'genre_rosamerica': {
'value': 'genre_rosamerica'
},
'mood_acoustic': {
'all': {
'acoustic': 'mood_acoustic'
}
},
'mood_aggressive': {
'all': {
'aggressive': 'mood_aggressive'
}
},
'mood_electronic': {
'all': {
'electronic': 'mood_electronic'
}
},
'mood_happy': {
'all': {
'happy': 'mood_happy'
}
},
'mood_party': {
'all': {
'party': 'mood_party'
}
},
'mood_relaxed': {
'all': {
'relaxed': 'mood_relaxed'
}
},
'mood_sad': {
'all': {
'sad': 'mood_sad'
}
},
'moods_mirex': {
'value': 'moods_mirex'
},
'ismir04_rhythm': {
'value': 'rhythm'
},
'tonal_atonal': {
'all': {
'tonal': 'tonal'
}
},
'timbre': {
'value': 'timbre'
},
'voice_instrumental': {
'value': 'voice_instrumental'
},
"highlevel": {
"danceability": {"all": {"danceable": "danceable"}},
"gender": {"value": "gender"},
"genre_rosamerica": {"value": "genre_rosamerica"},
"mood_acoustic": {"all": {"acoustic": "mood_acoustic"}},
"mood_aggressive": {"all": {"aggressive": "mood_aggressive"}},
"mood_electronic": {"all": {"electronic": "mood_electronic"}},
"mood_happy": {"all": {"happy": "mood_happy"}},
"mood_party": {"all": {"party": "mood_party"}},
"mood_relaxed": {"all": {"relaxed": "mood_relaxed"}},
"mood_sad": {"all": {"sad": "mood_sad"}},
"moods_mirex": {"value": "moods_mirex"},
"ismir04_rhythm": {"value": "rhythm"},
"tonal_atonal": {"all": {"tonal": "tonal"}},
"timbre": {"value": "timbre"},
"voice_instrumental": {"value": "voice_instrumental"},
},
'lowlevel': {
'average_loudness': 'average_loudness'
"lowlevel": {"average_loudness": "average_loudness"},
"rhythm": {"bpm": "bpm"},
"tonal": {
"chords_changes_rate": "chords_changes_rate",
"chords_key": "chords_key",
"chords_number_rate": "chords_number_rate",
"chords_scale": "chords_scale",
"key_key": ("initial_key", 0),
"key_scale": ("initial_key", 1),
"key_strength": "key_strength",
},
'rhythm': {
'bpm': 'bpm'
},
'tonal': {
'chords_changes_rate': 'chords_changes_rate',
'chords_key': 'chords_key',
'chords_number_rate': 'chords_number_rate',
'chords_scale': 'chords_scale',
'key_key': ('initial_key', 0),
'key_scale': ('initial_key', 1),
'key_strength': 'key_strength'
}
}
class AcousticPlugin(plugins.BeetsPlugin):
item_types = {
'average_loudness': types.Float(6),
'chords_changes_rate': types.Float(6),
'chords_key': types.STRING,
'chords_number_rate': types.Float(6),
'chords_scale': types.STRING,
'danceable': types.Float(6),
'gender': types.STRING,
'genre_rosamerica': types.STRING,
'initial_key': types.STRING,
'key_strength': types.Float(6),
'mood_acoustic': types.Float(6),
'mood_aggressive': types.Float(6),
'mood_electronic': types.Float(6),
'mood_happy': types.Float(6),
'mood_party': types.Float(6),
'mood_relaxed': types.Float(6),
'mood_sad': types.Float(6),
'moods_mirex': types.STRING,
'rhythm': types.Float(6),
'timbre': types.STRING,
'tonal': types.Float(6),
'voice_instrumental': types.STRING,
"average_loudness": types.Float(6),
"chords_changes_rate": types.Float(6),
"chords_key": types.STRING,
"chords_number_rate": types.Float(6),
"chords_scale": types.STRING,
"danceable": types.Float(6),
"gender": types.STRING,
"genre_rosamerica": types.STRING,
"initial_key": types.STRING,
"key_strength": types.Float(6),
"mood_acoustic": types.Float(6),
"mood_aggressive": types.Float(6),
"mood_electronic": types.Float(6),
"mood_happy": types.Float(6),
"mood_party": types.Float(6),
"mood_relaxed": types.Float(6),
"mood_sad": types.Float(6),
"moods_mirex": types.STRING,
"rhythm": types.Float(6),
"timbre": types.STRING,
"tonal": types.Float(6),
"voice_instrumental": types.STRING,
}
def __init__(self):
super().__init__()
self.config.add({
'auto': True,
'force': False,
'tags': []
})
self._log.warning("This plugin is deprecated.")
if self.config['auto']:
self.register_listener('import_task_files',
self.import_task_files)
self.config.add(
{"auto": True, "force": False, "tags": [], "base_url": ""}
)
self.base_url = self.config["base_url"].as_str()
if self.base_url:
if not self.base_url.startswith("http"):
raise ui.UserError(
"AcousticBrainz server base URL must start "
"with an HTTP scheme"
)
elif self.base_url[-1] != "/":
self.base_url = self.base_url + "/"
if self.config["auto"]:
self.register_listener("import_task_files", self.import_task_files)
def commands(self):
cmd = ui.Subcommand('acousticbrainz',
help="fetch metadata from AcousticBrainz")
cmd = ui.Subcommand(
"acousticbrainz", help="fetch metadata from AcousticBrainz"
)
cmd.parser.add_option(
'-f', '--force', dest='force_refetch',
action='store_true', default=False,
help='re-download data when already present'
"-f",
"--force",
dest="force_refetch",
action="store_true",
default=False,
help="re-download data when already present",
)
def func(lib, opts, args):
items = lib.items(ui.decargs(args))
self._fetch_info(items, ui.should_write(),
opts.force_refetch or self.config['force'])
self._fetch_info(
items,
ui.should_write(),
opts.force_refetch or self.config["force"],
)
cmd.func = func
return [cmd]
def import_task_files(self, session, task):
"""Function is called upon beet import.
"""
"""Function is called upon beet import."""
self._fetch_info(task.imported_items(), False, True)
def _get_data(self, mbid):
if not self.base_url:
raise ui.UserError(
"This plugin is deprecated since AcousticBrainz has shut "
"down. See the base_url configuration option."
)
data = {}
for url in _generate_urls(mbid):
self._log.debug('fetching URL: {}', url)
for url in _generate_urls(self.base_url, mbid):
self._log.debug("fetching URL: {}", url)
try:
res = requests.get(url)
res = requests.get(url, timeout=10)
except requests.RequestException as exc:
self._log.info('request error: {}', exc)
self._log.info("request error: {}", exc)
return {}
if res.status_code == 404:
self._log.info('recording ID {} not found', mbid)
self._log.info("recording ID {} not found", mbid)
return {}
try:
data.update(res.json())
except ValueError:
self._log.debug('Invalid Response: {}', res.text)
self._log.debug("Invalid Response: {}", res.text)
return {}
return data
def _fetch_info(self, items, write, force):
"""Fetch additional information from AcousticBrainz for the `item`s.
"""
tags = self.config['tags'].as_str_seq()
"""Fetch additional information from AcousticBrainz for the `item`s."""
tags = self.config["tags"].as_str_seq()
for item in items:
# If we're not forcing re-downloading for all tracks, check
# whether the data is already present. We use one
# representative field name to check for previously fetched
# data.
if not force:
mood_str = item.get('mood_acoustic', '')
mood_str = item.get("mood_acoustic", "")
if mood_str:
self._log.info('data already present for: {}', item)
self._log.info("data already present for: {}", item)
continue
# We can only fetch data for tracks with MBIDs.
if not item.mb_trackid:
continue
self._log.info('getting data for: {}', item)
self._log.info("getting data for: {}", item)
data = self._get_data(item.mb_trackid)
if data:
for attr, val in self._map_data_to_scheme(data, ABSCHEME):
if not tags or attr in tags:
self._log.debug('attribute {} of {} set to {}',
attr,
item,
val)
self._log.debug(
"attribute {} of {} set to {}", attr, item, val
)
setattr(item, attr, val)
else:
self._log.debug('skipping attribute {} of {}'
' (value {}) due to config',
attr,
item,
val)
self._log.debug(
"skipping attribute {} of {}"
" (value {}) due to config",
attr,
item,
val,
)
item.store()
if write:
item.try_write()
def _map_data_to_scheme(self, data, scheme):
"""Given `data` as a structure of nested dictionaries, and `scheme` as a
structure of nested dictionaries , `yield` tuples `(attr, val)` where
`attr` and `val` are corresponding leaf nodes in `scheme` and `data`.
"""Given `data` as a structure of nested dictionaries, and
`scheme` as a structure of nested dictionaries , `yield` tuples
`(attr, val)` where `attr` and `val` are corresponding leaf
nodes in `scheme` and `data`.
As its name indicates, `scheme` defines how the data is structured,
so this function tries to find leaf nodes in `data` that correspond
@@ -286,14 +253,12 @@ class AcousticPlugin(plugins.BeetsPlugin):
# The recursive traversal.
composites = defaultdict(list)
yield from self._data_to_scheme_child(data,
scheme,
composites)
yield from self._data_to_scheme_child(data, scheme, composites)
# When composites has been populated, yield the composite attributes
# by joining their parts.
for composite_attr, value_parts in composites.items():
yield composite_attr, ' '.join(value_parts)
yield composite_attr, " ".join(value_parts)
def _data_to_scheme_child(self, subdata, subscheme, composites):
"""The recursive business logic of :meth:`_map_data_to_scheme`:
@@ -307,28 +272,33 @@ class AcousticPlugin(plugins.BeetsPlugin):
"""
for k, v in subscheme.items():
if k in subdata:
if type(v) == dict:
yield from self._data_to_scheme_child(subdata[k],
v,
composites)
elif type(v) == tuple:
if isinstance(v, dict):
yield from self._data_to_scheme_child(
subdata[k], v, composites
)
elif isinstance(v, tuple):
composite_attribute, part_number = v
attribute_parts = composites[composite_attribute]
# Parts are not guaranteed to be inserted in order
while len(attribute_parts) <= part_number:
attribute_parts.append('')
attribute_parts.append("")
attribute_parts[part_number] = subdata[k]
else:
yield v, subdata[k]
else:
self._log.warning('Acousticbrainz did not provide info'
'about {}', k)
self._log.debug('Data {} could not be mapped to scheme {} '
'because key {} was not found', subdata, v, k)
self._log.warning(
"Acousticbrainz did not provide info " "about {}", k
)
self._log.debug(
"Data {} could not be mapped to scheme {} "
"because key {} was not found",
subdata,
v,
k,
)
def _generate_urls(mbid):
"""Generates AcousticBrainz end point urls for given `mbid`.
"""
def _generate_urls(base_url, mbid):
"""Generates AcousticBrainz end point urls for given `mbid`."""
for level in LEVELS:
yield ACOUSTIC_BASE + mbid + level
yield base_url + mbid + level
+174
View File
@@ -0,0 +1,174 @@
# This file is part of beets.
# Copyright 2023, Max Rumpf.
#
# 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.
"""Plugin to rewrite fields based on a given query."""
import re
import shlex
from collections import defaultdict
import confuse
from beets.dbcore import AndQuery, query_from_strings
from beets.dbcore.types import MULTI_VALUE_DSV
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import UserError
def rewriter(field, simple_rules, advanced_rules):
"""Template field function factory.
Create a template field function that rewrites the given field
with the given rewriting rules.
``simple_rules`` must be a list of (pattern, replacement) pairs.
``advanced_rules`` must be a list of (query, replacement) pairs.
"""
def fieldfunc(item):
value = item._values_fixed[field]
for pattern, replacement in simple_rules:
if pattern.match(value.lower()):
# Rewrite activated.
return replacement
for query, replacement in advanced_rules:
if query.match(item):
# Rewrite activated.
return replacement
# Not activated; return original value.
return value
return fieldfunc
class AdvancedRewritePlugin(BeetsPlugin):
"""Plugin to rewrite fields based on a given query."""
def __init__(self):
"""Parse configuration and register template fields for rewriting."""
super().__init__()
template = confuse.Sequence(
confuse.OneOf(
[
confuse.MappingValues(str),
{
"match": str,
"replacements": confuse.MappingValues(
confuse.OneOf([str, confuse.Sequence(str)]),
),
},
]
)
)
# Used to apply the same rewrite to the corresponding album field.
corresponding_album_fields = {
"artist": "albumartist",
"artists": "albumartists",
"artist_sort": "albumartist_sort",
"artists_sort": "albumartists_sort",
}
# Gather all the rewrite rules for each field.
class RulesContainer:
def __init__(self):
self.simple = []
self.advanced = []
rules = defaultdict(RulesContainer)
for rule in self.config.get(template):
if "match" not in rule:
# Simple syntax
if len(rule) != 1:
raise UserError(
"Simple rewrites must have only one rule, "
"but found multiple entries. "
"Did you forget to prepend a dash (-)?"
)
key, value = next(iter(rule.items()))
try:
fieldname, pattern = key.split(None, 1)
except ValueError:
raise UserError(
f"Invalid simple rewrite specification {key}"
)
if fieldname not in Item._fields:
raise UserError(
f"invalid field name {fieldname} in rewriter"
)
self._log.debug(
f"adding simple rewrite '{pattern}''{value}' "
f"for field {fieldname}"
)
pattern = re.compile(pattern.lower())
rules[fieldname].simple.append((pattern, value))
# Apply the same rewrite to the corresponding album field.
if fieldname in corresponding_album_fields:
album_fieldname = corresponding_album_fields[fieldname]
rules[album_fieldname].simple.append((pattern, value))
else:
# Advanced syntax
match = rule["match"]
replacements = rule["replacements"]
if len(replacements) == 0:
raise UserError(
"Advanced rewrites must have at least one replacement"
)
query = query_from_strings(
AndQuery,
Item,
prefixes={},
query_parts=shlex.split(match),
)
for fieldname, replacement in replacements.items():
if fieldname not in Item._fields:
raise UserError(
f"Invalid field name {fieldname} in rewriter"
)
self._log.debug(
f"adding advanced rewrite to '{replacement}' "
f"for field {fieldname}"
)
if isinstance(replacement, list):
if Item._fields[fieldname] is not MULTI_VALUE_DSV:
raise UserError(
f"Field {fieldname} is not a multi-valued field "
f"but a list was given: {', '.join(replacement)}"
)
elif isinstance(replacement, str):
if Item._fields[fieldname] is MULTI_VALUE_DSV:
replacement = [replacement]
else:
raise UserError(
f"Invalid type of replacement {replacement} "
f"for field {fieldname}"
)
rules[fieldname].advanced.append((query, replacement))
# Apply the same rewrite to the corresponding album field.
if fieldname in corresponding_album_fields:
album_fieldname = corresponding_album_fields[fieldname]
rules[album_fieldname].advanced.append(
(query, replacement)
)
# Replace each template field with the new rewriter function.
for fieldname, fieldrules in rules.items():
getter = rewriter(fieldname, fieldrules.simple, fieldrules.advanced)
self.template_fields[fieldname] = getter
if fieldname in Album._fields:
self.album_template_fields[fieldname] = getter
+23 -21
View File
@@ -26,40 +26,42 @@ class AlbumTypesPlugin(BeetsPlugin):
def __init__(self):
"""Init AlbumTypesPlugin."""
super().__init__()
self.album_template_fields['atypes'] = self._atypes
self.config.add({
'types': [
('ep', 'EP'),
('single', 'Single'),
('soundtrack', 'OST'),
('live', 'Live'),
('compilation', 'Anthology'),
('remix', 'Remix')
],
'ignore_va': ['compilation'],
'bracket': '[]'
})
self.album_template_fields["atypes"] = self._atypes
self.config.add(
{
"types": [
("ep", "EP"),
("single", "Single"),
("soundtrack", "OST"),
("live", "Live"),
("compilation", "Anthology"),
("remix", "Remix"),
],
"ignore_va": ["compilation"],
"bracket": "[]",
}
)
def _atypes(self, item: Album):
"""Returns a formatted string based on album's types."""
types = self.config['types'].as_pairs()
ignore_va = self.config['ignore_va'].as_str_seq()
bracket = self.config['bracket'].as_str()
types = self.config["types"].as_pairs()
ignore_va = self.config["ignore_va"].as_str_seq()
bracket = self.config["bracket"].as_str()
# Assign a left and right bracket or leave blank if argument is empty.
if len(bracket) == 2:
bracket_l = bracket[0]
bracket_r = bracket[1]
else:
bracket_l = ''
bracket_r = ''
bracket_l = ""
bracket_r = ""
res = ''
albumtypes = item.albumtypes.split('; ')
res = ""
albumtypes = item.albumtypes
is_va = item.mb_albumartistid == VARIOUS_ARTISTS_ID
for type in types:
if type[0] in albumtypes and type[1]:
if not is_va or (type[0] not in ignore_va and is_va):
res += f'{bracket_l}{type[1]}{bracket_r}'
res += f"{bracket_l}{type[1]}{bracket_r}"
return res
+129 -119
View File
@@ -15,35 +15,41 @@
"""An AURA server using Flask."""
from mimetypes import guess_type
import os
import re
import os.path
from os.path import isfile, getsize
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, _open_library
from beets import config
from beets.util import py3_path
from beets.library import Item, Album
from beets.dbcore.query import (
MatchQuery,
NotQuery,
RegexpQuery,
AndQuery,
FixedFieldSort,
SlowFieldSort,
MultipleSort,
)
import sys
from dataclasses import dataclass
from mimetypes import guess_type
from typing import ClassVar, Mapping, Type
from flask import (
Blueprint,
Flask,
current_app,
send_file,
make_response,
request,
send_file,
)
if sys.version_info >= (3, 11):
from typing import Self
else:
from typing_extensions import Self
from beets import config
from beets.dbcore.query import (
AndQuery,
FixedFieldSort,
MatchQuery,
MultipleSort,
NotQuery,
RegexpQuery,
SlowFieldSort,
SQLiteType,
)
from beets.library import Album, Item, LibModel, Library
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, _open_library
# Constants
@@ -118,9 +124,20 @@ ARTIST_ATTR_MAP = {
}
@dataclass
class AURADocument:
"""Base class for building AURA documents."""
model_cls: ClassVar[Type[LibModel]]
lib: Library
args: Mapping[str, str]
@classmethod
def from_app(cls) -> Self:
"""Initialise the document using the global app and request."""
return cls(current_app.config["lib"], request.args)
@staticmethod
def error(status, title, detail):
"""Make a response for an error following the JSON:API spec.
@@ -136,13 +153,29 @@ class AURADocument:
}
return make_response(document, status)
@classmethod
def get_attribute_converter(cls, beets_attr: str) -> Type[SQLiteType]:
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "title".
"""
try:
# Look for field in list of Album fields
# and get python type of database type.
# See beets.library.Album and beets.dbcore.types
return cls.model_cls._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
return str
def translate_filters(self):
"""Translate filters from request arguments to a beets Query."""
# The format of each filter key in the request parameter is:
# filter[<attribute>]. This regex extracts <attribute>.
pattern = re.compile(r"filter\[(?P<attribute>[a-zA-Z0-9_-]+)\]")
queries = []
for key, value in request.args.items():
for key, value in self.args.items():
match = pattern.match(key)
if match:
# Extract attribute name from key
@@ -191,10 +224,10 @@ class AURADocument:
albums) or a list of strings (artists).
"""
# Pages start from zero
page = request.args.get("page", 0, int)
page = self.args.get("page", 0, int)
# Use page limit defined in config by default.
default_limit = config["aura"]["page_limit"].get(int)
limit = request.args.get("limit", default_limit, int)
limit = self.args.get("limit", default_limit, int)
# start = offset of first item to return
start = page * limit
# end = offset of last item + 1
@@ -204,10 +237,10 @@ class AURADocument:
next_url = None
else:
# Not the last page so work out links.next url
if not request.args:
if not self.args:
# No existing arguments, so current page is 0
next_url = request.url + "?page=1"
elif not request.args.get("page", None):
elif not self.args.get("page", None):
# No existing page argument, so add one to the end
next_url = request.url + "&page=1"
else:
@@ -216,7 +249,10 @@ class AURADocument:
f"page={page}", "page={}".format(page + 1)
)
# Get only the items in the page range
data = [self.resource_object(collection[i]) for i in range(start, end)]
data = [
self.get_resource_object(self.lib, collection[i])
for i in range(start, end)
]
return data, next_url
def get_included(self, data, include_str):
@@ -250,18 +286,26 @@ class AURADocument:
res_type = identifier["type"]
if res_type == "track":
track_id = int(identifier["id"])
track = current_app.config["lib"].get_item(track_id)
included.append(TrackDocument.resource_object(track))
track = self.lib.get_item(track_id)
included.append(
TrackDocument.get_resource_object(self.lib, track)
)
elif res_type == "album":
album_id = int(identifier["id"])
album = current_app.config["lib"].get_album(album_id)
included.append(AlbumDocument.resource_object(album))
album = self.lib.get_album(album_id)
included.append(
AlbumDocument.get_resource_object(self.lib, album)
)
elif res_type == "artist":
artist_id = identifier["id"]
included.append(ArtistDocument.resource_object(artist_id))
included.append(
ArtistDocument.get_resource_object(self.lib, artist_id)
)
elif res_type == "image":
image_id = identifier["id"]
included.append(ImageDocument.resource_object(image_id))
included.append(
ImageDocument.get_resource_object(self.lib, image_id)
)
else:
raise ValueError(f"Invalid resource type: {res_type}")
return included
@@ -269,7 +313,7 @@ class AURADocument:
def all_resources(self):
"""Build document for /tracks, /albums or /artists."""
query = self.translate_filters()
sort_arg = request.args.get("sort", None)
sort_arg = self.args.get("sort", None)
if sort_arg:
sort = self.translate_sorts(sort_arg)
# For each sort field add a query which ensures all results
@@ -292,7 +336,7 @@ class AURADocument:
if next_url:
document["links"] = {"next": next_url}
# Include related resources for each element in "data"
include_str = request.args.get("include", None)
include_str = self.args.get("include", None)
if include_str:
document["included"] = self.get_included(data, include_str)
return document
@@ -305,7 +349,7 @@ class AURADocument:
resource object.
"""
document = {"data": resource_object}
include_str = request.args.get("include", None)
include_str = self.args.get("include", None)
if include_str:
# [document["data"]] is because arg needs to be list
document["included"] = self.get_included(
@@ -317,6 +361,8 @@ class AURADocument:
class TrackDocument(AURADocument):
"""Class for building documents for /tracks endpoints."""
model_cls = Item
attribute_map = TRACK_ATTR_MAP
def get_collection(self, query=None, sort=None):
@@ -326,9 +372,10 @@ class TrackDocument(AURADocument):
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
return current_app.config["lib"].items(query, sort)
return self.lib.items(query, sort)
def get_attribute_converter(self, beets_attr):
@classmethod
def get_attribute_converter(cls, beets_attr: str) -> Type[SQLiteType]:
"""Work out what data type an attribute should be for beets.
Args:
@@ -336,20 +383,12 @@ class TrackDocument(AURADocument):
"""
# filesize is a special field (read from disk not db?)
if beets_attr == "filesize":
converter = int
else:
try:
# Look for field in list of Item fields
# and get python type of database type.
# See beets.library.Item and beets.dbcore.types
converter = Item._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
return int
return super().get_attribute_converter(beets_attr)
@staticmethod
def resource_object(track):
def get_resource_object(lib: Library, track):
"""Construct a JSON:API resource object from a beets Item.
Args:
@@ -387,7 +426,7 @@ class TrackDocument(AURADocument):
Args:
track_id: The beets id of the track (integer).
"""
track = current_app.config["lib"].get_item(track_id)
track = self.lib.get_item(track_id)
if not track:
return self.error(
"404 Not Found",
@@ -396,12 +435,16 @@ class TrackDocument(AURADocument):
track_id
),
)
return self.single_resource_document(self.resource_object(track))
return self.single_resource_document(
self.get_resource_object(self.lib, track)
)
class AlbumDocument(AURADocument):
"""Class for building documents for /albums endpoints."""
model_cls = Album
attribute_map = ALBUM_ATTR_MAP
def get_collection(self, query=None, sort=None):
@@ -411,26 +454,10 @@ class AlbumDocument(AURADocument):
query: A beets Query object or a beets query string.
sort: A beets Sort object.
"""
return current_app.config["lib"].albums(query, sort)
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "title".
"""
try:
# Look for field in list of Album fields
# and get python type of database type.
# See beets.library.Album and beets.dbcore.types
converter = Album._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
return self.lib.albums(query, sort)
@staticmethod
def resource_object(album):
def get_resource_object(lib: Library, album):
"""Construct a JSON:API resource object from a beets Album.
Args:
@@ -449,7 +476,7 @@ class AlbumDocument(AURADocument):
# track number. Sorting is not required but it's nice.
query = MatchQuery("album_id", album.id)
sort = FixedFieldSort("track", ascending=True)
tracks = current_app.config["lib"].items(query, sort)
tracks = lib.items(query, sort)
# JSON:API one-to-many relationship to tracks on the album
relationships = {
"tracks": {
@@ -458,7 +485,7 @@ class AlbumDocument(AURADocument):
}
# Add images relationship if album has associated images
if album.artpath:
path = py3_path(album.artpath)
path = os.fsdecode(album.artpath)
filename = path.split("/")[-1]
image_id = f"album-{album.id}-{filename}"
relationships["images"] = {
@@ -485,7 +512,7 @@ class AlbumDocument(AURADocument):
Args:
album_id: The beets id of the album (integer).
"""
album = current_app.config["lib"].get_album(album_id)
album = self.lib.get_album(album_id)
if not album:
return self.error(
"404 Not Found",
@@ -494,12 +521,16 @@ class AlbumDocument(AURADocument):
album_id
),
)
return self.single_resource_document(self.resource_object(album))
return self.single_resource_document(
self.get_resource_object(self.lib, album)
)
class ArtistDocument(AURADocument):
"""Class for building documents for /artists endpoints."""
model_cls = Item
attribute_map = ARTIST_ATTR_MAP
def get_collection(self, query=None, sort=None):
@@ -510,7 +541,7 @@ class ArtistDocument(AURADocument):
sort: A beets Sort object.
"""
# Gets only tracks with matching artist information
tracks = current_app.config["lib"].items(query, sort)
tracks = self.lib.items(query, sort)
collection = []
for track in tracks:
# Do not add duplicates
@@ -518,24 +549,8 @@ class ArtistDocument(AURADocument):
collection.append(track.artist)
return collection
def get_attribute_converter(self, beets_attr):
"""Work out what data type an attribute should be for beets.
Args:
beets_attr: The name of the beets attribute, e.g. "artist".
"""
try:
# Look for field in list of Item fields
# and get python type of database type.
# See beets.library.Item and beets.dbcore.types
converter = Item._fields[beets_attr].model_type
except KeyError:
# Fall back to string (NOTE: probably not good)
converter = str
return converter
@staticmethod
def resource_object(artist_id):
def get_resource_object(lib: Library, artist_id):
"""Construct a JSON:API resource object for the given artist.
Args:
@@ -543,7 +558,7 @@ class ArtistDocument(AURADocument):
"""
# Get tracks where artist field exactly matches artist_id
query = MatchQuery("artist", artist_id)
tracks = current_app.config["lib"].items(query)
tracks = lib.items(query)
if not tracks:
return None
@@ -565,7 +580,7 @@ class ArtistDocument(AURADocument):
}
}
album_query = MatchQuery("albumartist", artist_id)
albums = current_app.config["lib"].albums(query=album_query)
albums = lib.albums(query=album_query)
if len(albums) != 0:
relationships["albums"] = {
"data": [{"type": "album", "id": str(a.id)} for a in albums]
@@ -584,7 +599,7 @@ class ArtistDocument(AURADocument):
Args:
artist_id: A string which is the artist's name.
"""
artist_resource = self.resource_object(artist_id)
artist_resource = self.get_resource_object(self.lib, artist_id)
if not artist_resource:
return self.error(
"404 Not Found",
@@ -608,7 +623,7 @@ def safe_filename(fn):
return False
# In single names, rule out Unix directory traversal names.
if fn in ('.', '..'):
if fn in (".", ".."):
return False
return True
@@ -617,8 +632,10 @@ def safe_filename(fn):
class ImageDocument(AURADocument):
"""Class for building documents for /images/(id) endpoints."""
model_cls = Album
@staticmethod
def get_image_path(image_id):
def get_image_path(lib: Library, image_id):
"""Works out the full path to the image with the given id.
Returns None if there is no such image.
@@ -640,13 +657,13 @@ class ImageDocument(AURADocument):
# Get the path to the directory parent's images are in
if parent_type == "album":
album = current_app.config["lib"].get_album(int(parent_id))
album = lib.get_album(int(parent_id))
if not album or not album.artpath:
return None
# Cut the filename off of artpath
# This is in preparation for supporting images in the same
# directory that are not tracked by beets.
artpath = py3_path(album.artpath)
artpath = os.fsdecode(album.artpath)
dir_path = "/".join(artpath.split("/")[:-1])
else:
# Images for other resource types are not supported
@@ -654,13 +671,13 @@ class ImageDocument(AURADocument):
img_path = os.path.join(dir_path, img_filename)
# Check the image actually exists
if isfile(img_path):
if os.path.isfile(img_path):
return img_path
else:
return None
@staticmethod
def resource_object(image_id):
def get_resource_object(lib: Library, image_id):
"""Construct a JSON:API resource object for the given image.
Args:
@@ -669,14 +686,14 @@ class ImageDocument(AURADocument):
"""
# Could be called as a static method, so can't use
# self.get_image_path()
image_path = ImageDocument.get_image_path(image_id)
image_path = ImageDocument.get_image_path(lib, image_id)
if not image_path:
return None
attributes = {
"role": "cover",
"mimetype": guess_type(image_path)[0],
"size": getsize(image_path),
"size": os.path.getsize(image_path),
}
try:
from PIL import Image
@@ -709,7 +726,7 @@ class ImageDocument(AURADocument):
image_id: A string in the form
"<parent_type>-<parent_id>-<img_filename>".
"""
image_resource = self.resource_object(image_id)
image_resource = self.get_resource_object(self.lib, image_id)
if not image_resource:
return self.error(
"404 Not Found",
@@ -737,8 +754,7 @@ def server_info():
@aura_bp.route("/tracks")
def all_tracks():
"""Respond with a list of all tracks and related information."""
doc = TrackDocument()
return doc.all_resources()
return TrackDocument.from_app().all_resources()
@aura_bp.route("/tracks/<int:track_id>")
@@ -748,8 +764,7 @@ def single_track(track_id):
Args:
track_id: The id of the track provided in the URL (integer).
"""
doc = TrackDocument()
return doc.single_resource(track_id)
return TrackDocument.from_app().single_resource(track_id)
@aura_bp.route("/tracks/<int:track_id>/audio")
@@ -769,8 +784,8 @@ def audio_file(track_id):
),
)
path = py3_path(track.path)
if not isfile(path):
path = os.fsdecode(track.path)
if not os.path.isfile(path):
return AURADocument.error(
"404 Not Found",
"No audio file for the requested track.",
@@ -821,8 +836,7 @@ def audio_file(track_id):
@aura_bp.route("/albums")
def all_albums():
"""Respond with a list of all albums and related information."""
doc = AlbumDocument()
return doc.all_resources()
return AlbumDocument.from_app().all_resources()
@aura_bp.route("/albums/<int:album_id>")
@@ -832,8 +846,7 @@ def single_album(album_id):
Args:
album_id: The id of the album provided in the URL (integer).
"""
doc = AlbumDocument()
return doc.single_resource(album_id)
return AlbumDocument.from_app().single_resource(album_id)
# Artist endpoints
@@ -843,8 +856,7 @@ def single_album(album_id):
@aura_bp.route("/artists")
def all_artists():
"""Respond with a list of all artists and related information."""
doc = ArtistDocument()
return doc.all_resources()
return ArtistDocument.from_app().all_resources()
# Using the path converter allows slashes in artist_id
@@ -856,8 +868,7 @@ def single_artist(artist_id):
artist_id: The id of the artist provided in the URL. A string
which is the artist's name.
"""
doc = ArtistDocument()
return doc.single_resource(artist_id)
return ArtistDocument.from_app().single_resource(artist_id)
# Image endpoints
@@ -873,8 +884,7 @@ def single_image(image_id):
image_id: The id of the image provided in the URL. A string in
the form "<parent_type>-<parent_id>-<img_filename>".
"""
doc = ImageDocument()
return doc.single_resource(image_id)
return ImageDocument.from_app().single_resource(image_id)
@aura_bp.route("/images/<string:image_id>/file")
@@ -885,7 +895,7 @@ def image_file(image_id):
image_id: The id of the image provided in the URL. A string in
the form "<parent_type>-<parent_id>-<img_filename>".
"""
img_path = ImageDocument.get_image_path(image_id)
img_path = ImageDocument.get_image_path(current_app.config["lib"], image_id)
if not img_path:
return AURADocument.error(
"404 Not Found",
+92
View File
@@ -0,0 +1,92 @@
# 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.
"""Uses Librosa to calculate the `bpm` field.
"""
from librosa import beat, load
from soundfile import LibsndfileError
from beets import ui, util
from beets.plugins import BeetsPlugin
class AutoBPMPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add(
{
"auto": True,
"overwrite": False,
}
)
if self.config["auto"].get(bool):
self.import_stages = [self.imported]
def commands(self):
cmd = ui.Subcommand(
"autobpm", help="detect and add bpm from audio using Librosa"
)
cmd.func = self.command
return [cmd]
def command(self, lib, opts, args):
self.calculate_bpm(lib.items(ui.decargs(args)), write=ui.should_write())
def imported(self, session, task):
self.calculate_bpm(task.imported_items())
def calculate_bpm(self, items, write=False):
overwrite = self.config["overwrite"].get(bool)
for item in items:
if item["bpm"]:
self._log.info(
"found bpm {0} for {1}",
item["bpm"],
util.displayable_path(item.path),
)
if not overwrite:
continue
try:
y, sr = load(util.syspath(item.path), res_type="kaiser_fast")
except LibsndfileError as exc:
self._log.error(
"LibsndfileError: failed to load {0} {1}",
util.displayable_path(item.path),
exc,
)
continue
except ValueError as exc:
self._log.error(
"ValueError: failed to load {0} {1}",
util.displayable_path(item.path),
exc,
)
continue
tempo, _ = beat.beat_track(y=y, sr=sr)
bpm = round(tempo)
item["bpm"] = bpm
self._log.info(
"added computed bpm {0} for {1}",
bpm,
util.displayable_path(item.path),
)
if write:
item.try_write()
item.store()
+56 -40
View File
@@ -16,18 +16,18 @@
"""
from subprocess import check_output, CalledProcessError, list2cmdline, STDOUT
import shlex
import os
import errno
import os
import shlex
import sys
from subprocess import STDOUT, CalledProcessError, check_output, list2cmdline
import confuse
from beets import importer, ui
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.util import displayable_path, par_map
from beets import ui
from beets import importer
class CheckerCommandException(Exception):
@@ -52,14 +52,15 @@ class BadFiles(BeetsPlugin):
super().__init__()
self.verbose = False
self.register_listener('import_task_start',
self.on_import_task_start)
self.register_listener('import_task_before_choice',
self.on_import_task_before_choice)
self.register_listener("import_task_start", self.on_import_task_start)
self.register_listener(
"import_task_before_choice", self.on_import_task_before_choice
)
def run_command(self, cmd):
self._log.debug("running command: {}",
displayable_path(list2cmdline(cmd)))
self._log.debug(
"running command: {}", displayable_path(list2cmdline(cmd))
)
try:
output = check_output(cmd, stderr=STDOUT)
errors = 0
@@ -70,7 +71,7 @@ class BadFiles(BeetsPlugin):
status = e.returncode
except OSError as e:
raise CheckerCommandException(cmd, e)
output = output.decode(sys.getdefaultencoding(), 'replace')
output = output.decode(sys.getdefaultencoding(), "replace")
return status, errors, [line for line in output.split("\n") if line]
def check_mp3val(self, path):
@@ -88,12 +89,13 @@ class BadFiles(BeetsPlugin):
cmd = shlex.split(command)
cmd.append(path)
return self.run_command(cmd)
return checker
def get_checker(self, ext):
ext = ext.lower()
try:
command = self.config['commands'].get(dict).get(ext)
command = self.config["commands"].get(dict).get(ext)
except confuse.NotFoundError:
command = None
if command:
@@ -109,15 +111,17 @@ class BadFiles(BeetsPlugin):
dpath = displayable_path(item.path)
self._log.debug("checking path: {}", dpath)
if not os.path.exists(item.path):
ui.print_("{}: file does not exist".format(
ui.colorize('text_error', dpath)))
ui.print_(
"{}: file does not exist".format(
ui.colorize("text_error", dpath)
)
)
# Run the checker against the file if one is found
ext = os.path.splitext(item.path)[1][1:].decode('utf8', 'ignore')
ext = os.path.splitext(item.path)[1][1:].decode("utf8", "ignore")
checker = self.get_checker(ext)
if not checker:
self._log.error("no checker specified in the config for {}",
ext)
self._log.error("no checker specified in the config for {}", ext)
return []
path = item.path
if not isinstance(path, str):
@@ -129,7 +133,7 @@ class BadFiles(BeetsPlugin):
self._log.error(
"command not found: {} when validating file: {}",
e.checker,
e.path
e.path,
)
else:
self._log.error("error invoking {}: {}", e.checker, e.msg)
@@ -139,25 +143,30 @@ class BadFiles(BeetsPlugin):
if status > 0:
error_lines.append(
"{}: checker exited with status {}"
.format(ui.colorize('text_error', dpath), status))
"{}: checker exited with status {}".format(
ui.colorize("text_error", dpath), status
)
)
for line in output:
error_lines.append(f" {line}")
elif errors > 0:
error_lines.append(
"{}: checker found {} errors or warnings"
.format(ui.colorize('text_warning', dpath), errors))
"{}: checker found {} errors or warnings".format(
ui.colorize("text_warning", dpath), errors
)
)
for line in output:
error_lines.append(f" {line}")
elif self.verbose:
error_lines.append(
"{}: ok".format(ui.colorize('text_success', dpath)))
"{}: ok".format(ui.colorize("text_success", dpath))
)
return error_lines
def on_import_task_start(self, task, session):
if not self.config['check_on_import'].get(False):
if not self.config["check_on_import"].get(False):
return
checks_failed = []
@@ -171,26 +180,29 @@ class BadFiles(BeetsPlugin):
task._badfiles_checks_failed = checks_failed
def on_import_task_before_choice(self, task, session):
if hasattr(task, '_badfiles_checks_failed'):
ui.print_('{} one or more files failed checks:'
.format(ui.colorize('text_warning', 'BAD')))
if hasattr(task, "_badfiles_checks_failed"):
ui.print_(
"{} one or more files failed checks:".format(
ui.colorize("text_warning", "BAD")
)
)
for error in task._badfiles_checks_failed:
for error_line in error:
ui.print_(error_line)
ui.print_()
ui.print_('What would you like to do?')
ui.print_("What would you like to do?")
sel = ui.input_options(['aBort', 'skip', 'continue'])
sel = ui.input_options(["aBort", "skip", "continue"])
if sel == 's':
if sel == "s":
return importer.action.SKIP
elif sel == 'c':
elif sel == "c":
return None
elif sel == 'b':
elif sel == "b":
raise importer.ImportAbort()
else:
raise Exception(f'Unexpected selection: {sel}')
raise Exception(f"Unexpected selection: {sel}")
def command(self, lib, opts, args):
# Get items from arguments
@@ -204,12 +216,16 @@ class BadFiles(BeetsPlugin):
par_map(check_and_print, items)
def commands(self):
bad_command = Subcommand('bad',
help='check for corrupt or missing files')
bad_command = Subcommand(
"bad", help="check for corrupt or missing files"
)
bad_command.parser.add_option(
'-v', '--verbose',
action='store_true', default=False, dest='verbose',
help='view results for both the bad and uncorrupted files'
"-v",
"--verbose",
action="store_true",
default=False,
dest="verbose",
help="view results for both the bad and uncorrupted files",
)
bad_command.func = self.command
return [bad_command]
+28 -13
View File
@@ -19,15 +19,17 @@
"""Provides a bare-ASCII matching query."""
from beets import ui
from beets.ui import print_, decargs
from beets.plugins import BeetsPlugin
from beets.dbcore.query import StringFieldQuery
from unidecode import unidecode
from beets import ui
from beets.dbcore.query import StringFieldQuery
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_
class BareascQuery(StringFieldQuery):
class BareascQuery(StringFieldQuery[str]):
"""Compare items using bare ASCII, without accents etc."""
@classmethod
def string_match(cls, pattern, val):
"""Convert both pattern and string to plain ASCII before matching.
@@ -42,27 +44,40 @@ class BareascQuery(StringFieldQuery):
val = unidecode(val)
return pattern in val
def col_clause(self):
"""Compare ascii version of the pattern."""
clause = f"unidecode({self.field})"
if self.pattern.islower():
clause = f"lower({clause})"
return rf"{clause} LIKE ? ESCAPE '\'", [f"%{unidecode(self.pattern)}%"]
class BareascPlugin(BeetsPlugin):
"""Plugin to provide bare-ASCII option for beets matching."""
def __init__(self):
"""Default prefix for selecting bare-ASCII matching is #."""
super().__init__()
self.config.add({
'prefix': '#',
})
self.config.add(
{
"prefix": "#",
}
)
def queries(self):
"""Register bare-ASCII matching."""
prefix = self.config['prefix'].as_str()
prefix = self.config["prefix"].as_str()
return {prefix: BareascQuery}
def commands(self):
"""Add bareasc command as unidecode version of 'list'."""
cmd = ui.Subcommand('bareasc',
help='unidecode version of beet list command')
cmd.parser.usage += "\n" \
'Example: %prog -f \'$album: $title\' artist:beatles'
cmd = ui.Subcommand(
"bareasc", help="unidecode version of beet list command"
)
cmd.parser.usage += (
"\n" "Example: %prog -f '$album: $title' artist:beatles"
)
cmd.parser.add_all_common_options()
cmd.func = self.unidecode_list
return [cmd]
+182 -155
View File
@@ -19,19 +19,22 @@ import json
import re
from datetime import datetime, timedelta
import confuse
from requests_oauthlib import OAuth1Session
from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing,
VerifierMissing)
from requests_oauthlib.oauth1_session import (
TokenMissing,
TokenRequestDenied,
VerifierMissing,
)
import beets
import beets.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
import confuse
from beets.util.id_extractors import beatport_id_regex
AUTH_ERRORS = (TokenRequestDenied, TokenMissing, VerifierMissing)
USER_AGENT = f'beets/{beets.__version__} +https://beets.io/'
USER_AGENT = f"beets/{beets.__version__} +https://beets.io/"
class BeatportAPIError(Exception):
@@ -40,24 +43,23 @@ class BeatportAPIError(Exception):
class BeatportObject:
def __init__(self, data):
self.beatport_id = data['id']
self.name = str(data['name'])
if 'releaseDate' in data:
self.release_date = datetime.strptime(data['releaseDate'],
'%Y-%m-%d')
if 'artists' in data:
self.artists = [(x['id'], str(x['name']))
for x in data['artists']]
if 'genres' in data:
self.genres = [str(x['name'])
for x in data['genres']]
self.beatport_id = data["id"]
self.name = str(data["name"])
if "releaseDate" in data:
self.release_date = datetime.strptime(
data["releaseDate"], "%Y-%m-%d"
)
if "artists" in data:
self.artists = [(x["id"], str(x["name"])) for x in data["artists"]]
if "genres" in data:
self.genres = [str(x["name"]) for x in data["genres"]]
class BeatportClient:
_api_base = 'https://oauth-api.beatport.com'
_api_base = "https://oauth-api.beatport.com"
def __init__(self, c_key, c_secret, auth_key=None, auth_secret=None):
""" Initiate the client with OAuth information.
"""Initiate the client with OAuth information.
For the initial authentication with the backend `auth_key` and
`auth_secret` can be `None`. Use `get_authorize_url` and
@@ -69,14 +71,16 @@ class BeatportClient:
:param auth_secret: OAuth1 resource owner secret
"""
self.api = OAuth1Session(
client_key=c_key, client_secret=c_secret,
client_key=c_key,
client_secret=c_secret,
resource_owner_key=auth_key,
resource_owner_secret=auth_secret,
callback_uri='oob')
self.api.headers = {'User-Agent': USER_AGENT}
callback_uri="oob",
)
self.api.headers = {"User-Agent": USER_AGENT}
def get_authorize_url(self):
""" Generate the URL for the user to authorize the application.
"""Generate the URL for the user to authorize the application.
Retrieves a request token from the Beatport API and returns the
corresponding authorization URL on their end that the user has
@@ -91,12 +95,14 @@ class BeatportClient:
:rtype: unicode
"""
self.api.fetch_request_token(
self._make_url('/identity/1/oauth/request-token'))
self._make_url("/identity/1/oauth/request-token")
)
return self.api.authorization_url(
self._make_url('/identity/1/oauth/authorize'))
self._make_url("/identity/1/oauth/authorize")
)
def get_access_token(self, auth_data):
""" Obtain the final access token and secret for the API.
"""Obtain the final access token and secret for the API.
:param auth_data: URL-encoded authorization data as displayed at
the authorization url (obtained via
@@ -106,13 +112,15 @@ class BeatportClient:
:rtype: (unicode, unicode) tuple
"""
self.api.parse_authorization_response(
"https://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']
self._make_url("/identity/1/oauth/access-token")
)
return access_data["oauth_token"], access_data["oauth_token_secret"]
def search(self, query, release_type='release', details=True):
""" Perform a search of the Beatport catalogue.
def search(self, query, release_type="release", details=True):
"""Perform a search of the Beatport catalogue.
:param query: Query string
:param release_type: Type of releases to search for, can be
@@ -126,27 +134,30 @@ class BeatportClient:
py:class:`BeatportRelease` or
:py:class:`BeatportTrack`
"""
response = self._get('catalog/3/search',
query=query, perPage=5,
facets=[f'fieldType:{release_type}'])
response = self._get(
"catalog/3/search",
query=query,
perPage=5,
facets=[f"fieldType:{release_type}"],
)
for item in response:
if release_type == 'release':
if release_type == "release":
if details:
release = self.get_release(item['id'])
release = self.get_release(item["id"])
else:
release = BeatportRelease(item)
yield release
elif release_type == 'track':
elif release_type == "track":
yield BeatportTrack(item)
def get_release(self, beatport_id):
""" Get information about a single release.
"""Get information about a single release.
:param beatport_id: Beatport ID of the release
:returns: The matching release
:rtype: :py:class:`BeatportRelease`
"""
response = self._get('/catalog/3/releases', id=beatport_id)
response = self._get("/catalog/3/releases", id=beatport_id)
if response:
release = BeatportRelease(response[0])
release.tracks = self.get_release_tracks(beatport_id)
@@ -154,34 +165,35 @@ class BeatportClient:
return None
def get_release_tracks(self, beatport_id):
""" Get all tracks for a given release.
"""Get all tracks for a given release.
:param beatport_id: Beatport ID of the release
:returns: Tracks in the matching release
:rtype: list of :py:class:`BeatportTrack`
"""
response = self._get('/catalog/3/tracks', releaseId=beatport_id,
perPage=100)
response = self._get(
"/catalog/3/tracks", releaseId=beatport_id, perPage=100
)
return [BeatportTrack(t) for t in response]
def get_track(self, beatport_id):
""" Get information about a single track.
"""Get information about a single track.
:param beatport_id: Beatport ID of the track
:returns: The matching track
:rtype: :py:class:`BeatportTrack`
"""
response = self._get('/catalog/3/tracks', id=beatport_id)
response = self._get("/catalog/3/tracks", id=beatport_id)
return BeatportTrack(response[0])
def _make_url(self, endpoint):
""" Get complete URL for a given API endpoint. """
if not endpoint.startswith('/'):
endpoint = '/' + endpoint
"""Get complete URL for a given API endpoint."""
if not endpoint.startswith("/"):
endpoint = "/" + endpoint
return self._api_base + endpoint
def _get(self, endpoint, **kwargs):
""" Perform a GET request on a given API endpoint.
"""Perform a GET request on a given API endpoint.
Automatically extracts result data from the response and converts HTTP
exceptions into :py:class:`BeatportAPIError` objects.
@@ -189,13 +201,16 @@ class BeatportClient:
try:
response = self.api.get(self._make_url(endpoint), params=kwargs)
except Exception as e:
raise BeatportAPIError("Error connecting to Beatport API: {}"
.format(e))
raise BeatportAPIError(
"Error connecting to Beatport API: {}".format(e)
)
if not response:
raise BeatportAPIError(
"Error {0.status_code} for '{0.request.path_url}"
.format(response))
return response.json()['results']
"Error {0.status_code} for '{0.request.path_url}".format(
response
)
)
return response.json()["results"]
class BeatportRelease(BeatportObject):
@@ -211,79 +226,83 @@ class BeatportRelease(BeatportObject):
)
def __repr__(self):
return str(self).encode('utf-8')
return str(self).encode("utf-8")
def __init__(self, data):
BeatportObject.__init__(self, data)
if 'catalogNumber' in data:
self.catalog_number = data['catalogNumber']
if 'label' in data:
self.label_name = data['label']['name']
if 'category' in data:
self.category = data['category']
if 'slug' in data:
if "catalogNumber" in data:
self.catalog_number = data["catalogNumber"]
if "label" in data:
self.label_name = data["label"]["name"]
if "category" in data:
self.category = data["category"]
if "slug" in data:
self.url = "https://beatport.com/release/{}/{}".format(
data['slug'], data['id'])
self.genre = data.get('genre')
data["slug"], data["id"]
)
self.genre = data.get("genre")
class BeatportTrack(BeatportObject):
def __str__(self):
artist_str = ", ".join(x[1] for x in self.artists)
return ("<BeatportTrack: {} - {} ({})>"
.format(artist_str, self.name, self.mix_name))
return "<BeatportTrack: {} - {} ({})>".format(
artist_str, self.name, self.mix_name
)
def __repr__(self):
return str(self).encode('utf-8')
return str(self).encode("utf-8")
def __init__(self, data):
BeatportObject.__init__(self, data)
if 'title' in data:
self.title = str(data['title'])
if 'mixName' in data:
self.mix_name = str(data['mixName'])
self.length = timedelta(milliseconds=data.get('lengthMs', 0) or 0)
if "title" in data:
self.title = str(data["title"])
if "mixName" in data:
self.mix_name = str(data["mixName"])
self.length = timedelta(milliseconds=data.get("lengthMs", 0) or 0)
if not self.length:
try:
min, sec = data.get('length', '0:0').split(':')
min, sec = data.get("length", "0:0").split(":")
self.length = timedelta(minutes=int(min), seconds=int(sec))
except ValueError:
pass
if 'slug' in data:
self.url = "https://beatport.com/track/{}/{}" \
.format(data['slug'], data['id'])
self.track_number = data.get('trackNumber')
self.bpm = data.get('bpm')
self.initial_key = str(
(data.get('key') or {}).get('shortName')
)
if "slug" in data:
self.url = "https://beatport.com/track/{}/{}".format(
data["slug"], data["id"]
)
self.track_number = data.get("trackNumber")
self.bpm = data.get("bpm")
self.initial_key = str((data.get("key") or {}).get("shortName"))
# Use 'subgenre' and if not present, 'genre' as a fallback.
if data.get('subGenres'):
self.genre = str(data['subGenres'][0].get('name'))
elif data.get('genres'):
self.genre = str(data['genres'][0].get('name'))
if data.get("subGenres"):
self.genre = str(data["subGenres"][0].get("name"))
elif data.get("genres"):
self.genre = str(data["genres"][0].get("name"))
class BeatportPlugin(BeetsPlugin):
data_source = 'Beatport'
data_source = "Beatport"
id_regex = beatport_id_regex
def __init__(self):
super().__init__()
self.config.add({
'apikey': '57713c3906af6f5def151b33601389176b37b429',
'apisecret': 'b3fe08c93c80aefd749fe871a16cd2bb32e2b954',
'tokenfile': 'beatport_token.json',
'source_weight': 0.5,
})
self.config['apikey'].redact = True
self.config['apisecret'].redact = True
self.config.add(
{
"apikey": "57713c3906af6f5def151b33601389176b37b429",
"apisecret": "b3fe08c93c80aefd749fe871a16cd2bb32e2b954",
"tokenfile": "beatport_token.json",
"source_weight": 0.5,
}
)
self.config["apikey"].redact = True
self.config["apisecret"].redact = True
self.client = None
self.register_listener('import_begin', self.setup)
self.register_listener("import_begin", self.setup)
def setup(self, session=None):
c_key = self.config['apikey'].as_str()
c_secret = self.config['apisecret'].as_str()
c_key = self.config["apikey"].as_str()
c_secret = self.config["apisecret"].as_str()
# Get the OAuth token from a file or log in.
try:
@@ -293,8 +312,8 @@ class BeatportPlugin(BeetsPlugin):
# No token yet. Generate one.
token, secret = self.authenticate(c_key, c_secret)
else:
token = tokendata['token']
secret = tokendata['secret']
token = tokendata["token"]
secret = tokendata["secret"]
self.client = BeatportClient(c_key, c_secret, token, secret)
@@ -304,8 +323,8 @@ class BeatportPlugin(BeetsPlugin):
try:
url = auth_client.get_authorize_url()
except AUTH_ERRORS as e:
self._log.debug('authentication error: {0}', e)
raise beets.ui.UserError('communication with Beatport failed')
self._log.debug("authentication error: {0}", e)
raise beets.ui.UserError("communication with Beatport failed")
beets.ui.print_("To authenticate with Beatport, visit:")
beets.ui.print_(url)
@@ -315,29 +334,26 @@ class BeatportPlugin(BeetsPlugin):
try:
token, secret = auth_client.get_access_token(data)
except AUTH_ERRORS as e:
self._log.debug('authentication error: {0}', e)
raise beets.ui.UserError('Beatport token request failed')
self._log.debug("authentication error: {0}", e)
raise beets.ui.UserError("Beatport token request failed")
# Save the token for later use.
self._log.debug('Beatport token {0}, secret {1}', token, secret)
with open(self._tokenfile(), 'w') as f:
json.dump({'token': token, 'secret': secret}, f)
self._log.debug("Beatport token {0}, secret {1}", token, secret)
with open(self._tokenfile(), "w") as f:
json.dump({"token": token, "secret": secret}, f)
return token, secret
def _tokenfile(self):
"""Get the path to the JSON file for storing the OAuth token.
"""
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
"""Get the path to the JSON file for storing the OAuth token."""
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def album_distance(self, items, album_info, mapping):
"""Returns the Beatport source weight and the maximum source weight
for albums.
"""
return get_distance(
data_source=self.data_source,
info=album_info,
config=self.config
data_source=self.data_source, info=album_info, config=self.config
)
def track_distance(self, item, track_info):
@@ -345,9 +361,7 @@ class BeatportPlugin(BeetsPlugin):
for individual tracks.
"""
return get_distance(
data_source=self.data_source,
info=track_info,
config=self.config
data_source=self.data_source, info=track_info, config=self.config
)
def candidates(self, items, artist, release, va_likely, extra_tags=None):
@@ -357,34 +371,36 @@ class BeatportPlugin(BeetsPlugin):
if va_likely:
query = release
else:
query = f'{artist} {release}'
query = f"{artist} {release}"
try:
return self._get_releases(query)
except BeatportAPIError as e:
self._log.debug('API Error: {0} (query: {1})', e, query)
self._log.debug("API Error: {0} (query: {1})", e, query)
return []
def item_candidates(self, item, artist, title):
"""Returns a list of TrackInfo objects for beatport search results
matching title and artist.
"""
query = f'{artist} {title}'
query = f"{artist} {title}"
try:
return self._get_tracks(query)
except BeatportAPIError as e:
self._log.debug('API Error: {0} (query: {1})', e, query)
self._log.debug("API Error: {0} (query: {1})", e, query)
return []
def album_for_id(self, release_id):
"""Fetches a release by its Beatport ID and returns an AlbumInfo object
or None if the query is not a valid ID or release is not found.
"""
self._log.debug('Searching for release {0}', release_id)
match = re.search(r'(^|beatport\.com/release/.+/)(\d+)$', release_id)
if not match:
self._log.debug('Not a valid Beatport release ID.')
self._log.debug("Searching for release {0}", release_id)
release_id = self._get_id("album", release_id, self.id_regex)
if release_id is None:
self._log.debug("Not a valid Beatport release ID.")
return None
release = self.client.get_release(match.group(2))
release = self.client.get_release(release_id)
if release:
return self._get_album_info(release)
return None
@@ -393,10 +409,10 @@ class BeatportPlugin(BeetsPlugin):
"""Fetches a track by its Beatport ID and returns a TrackInfo object
or None if the track is not a valid Beatport ID or track is not found.
"""
self._log.debug('Searching for track {0}', track_id)
match = re.search(r'(^|beatport\.com/track/.+/)(\d+)$', track_id)
self._log.debug("Searching for track {0}", track_id)
match = re.search(r"(^|beatport\.com/track/.+/)(\d+)$", track_id)
if not match:
self._log.debug('Not a valid Beatport track ID.')
self._log.debug("Not a valid Beatport track ID.")
return None
bp_track = self.client.get_track(match.group(2))
if bp_track is not None:
@@ -404,55 +420,67 @@ class BeatportPlugin(BeetsPlugin):
return None
def _get_releases(self, query):
"""Returns a list of AlbumInfo objects for a beatport search query.
"""
"""Returns a list of AlbumInfo objects for a beatport search query."""
# Strip non-word characters from query. Things like "!" and "-" can
# cause a query to return no results, even if they match the artist or
# album title. Use `re.UNICODE` flag to avoid stripping non-english
# word characters.
query = re.sub(r'\W+', ' ', query, flags=re.UNICODE)
query = re.sub(r"\W+", " ", query, flags=re.UNICODE)
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(r'\b(CD|disc)\s*\d+', '', query, flags=re.I)
albums = [self._get_album_info(x)
for x in self.client.search(query)]
query = re.sub(r"\b(CD|disc)\s*\d+", "", query, flags=re.I)
albums = [self._get_album_info(x) for x in self.client.search(query)]
return albums
def _get_album_info(self, release):
"""Returns an AlbumInfo object for a Beatport Release object.
"""
"""Returns an AlbumInfo object for a Beatport Release object."""
va = len(release.artists) > 3
artist, artist_id = self._get_artist(release.artists)
if va:
artist = "Various Artists"
tracks = [self._get_track_info(x) for x in release.tracks]
return AlbumInfo(album=release.name, album_id=release.beatport_id,
artist=artist, artist_id=artist_id, tracks=tracks,
albumtype=release.category, va=va,
year=release.release_date.year,
month=release.release_date.month,
day=release.release_date.day,
label=release.label_name,
catalognum=release.catalog_number, media='Digital',
data_source=self.data_source, data_url=release.url,
genre=release.genre)
return AlbumInfo(
album=release.name,
album_id=release.beatport_id,
beatport_album_id=release.beatport_id,
artist=artist,
artist_id=artist_id,
tracks=tracks,
albumtype=release.category,
va=va,
year=release.release_date.year,
month=release.release_date.month,
day=release.release_date.day,
label=release.label_name,
catalognum=release.catalog_number,
media="Digital",
data_source=self.data_source,
data_url=release.url,
genre=release.genre,
)
def _get_track_info(self, track):
"""Returns a TrackInfo object for a Beatport Track object.
"""
"""Returns a TrackInfo object for a Beatport Track object."""
title = track.name
if track.mix_name != "Original Mix":
title += f" ({track.mix_name})"
artist, artist_id = self._get_artist(track.artists)
length = track.length.total_seconds()
return TrackInfo(title=title, track_id=track.beatport_id,
artist=artist, artist_id=artist_id,
length=length, index=track.track_number,
medium_index=track.track_number,
data_source=self.data_source, data_url=track.url,
bpm=track.bpm, initial_key=track.initial_key,
genre=track.genre)
return TrackInfo(
title=title,
track_id=track.beatport_id,
artist=artist,
artist_id=artist_id,
length=length,
index=track.track_number,
medium_index=track.track_number,
data_source=self.data_source,
data_url=track.url,
bpm=track.bpm,
initial_key=track.initial_key,
genre=track.genre,
)
def _get_artist(self, artists):
"""Returns an artist string (all artists) and an artist_id (the main
@@ -463,8 +491,7 @@ class BeatportPlugin(BeetsPlugin):
)
def _get_tracks(self, query):
"""Returns a list of TrackInfo objects for a Beatport query.
"""
bp_tracks = self.client.search(query, release_type='track')
"""Returns a list of TrackInfo objects for a Beatport query."""
bp_tracks = self.client.search(query, release_type="track")
tracks = [self._get_track_info(x) for x in bp_tracks]
return tracks
+70 -44
View File
@@ -16,17 +16,14 @@
"""
from beets.plugins import BeetsPlugin
from beets import ui
from beets import vfs
from beets import library
from beets.util.functemplate import Template
from beets.autotag import match
from beets import plugins
from beets import importer
import cProfile
import timeit
from beets import importer, library, plugins, ui, vfs
from beets.autotag import match
from beets.plugins import BeetsPlugin
from beets.util.functemplate import Template
def aunique_benchmark(lib, prof):
def _build_tree():
@@ -34,74 +31,103 @@ def aunique_benchmark(lib, prof):
# Measure path generation performance with %aunique{} included.
lib.path_formats = [
(library.PF_KEY_DEFAULT,
Template('$albumartist/$album%aunique{}/$track $title')),
(
library.PF_KEY_DEFAULT,
Template("$albumartist/$album%aunique{}/$track $title"),
),
]
if prof:
cProfile.runctx('_build_tree()', {}, {'_build_tree': _build_tree},
'paths.withaunique.prof')
cProfile.runctx(
"_build_tree()",
{},
{"_build_tree": _build_tree},
"paths.withaunique.prof",
)
else:
interval = timeit.timeit(_build_tree, number=1)
print('With %aunique:', interval)
print("With %aunique:", interval)
# And with %aunique replaceed with a "cheap" no-op function.
lib.path_formats = [
(library.PF_KEY_DEFAULT,
Template('$albumartist/$album%lower{}/$track $title')),
(
library.PF_KEY_DEFAULT,
Template("$albumartist/$album%lower{}/$track $title"),
),
]
if prof:
cProfile.runctx('_build_tree()', {}, {'_build_tree': _build_tree},
'paths.withoutaunique.prof')
cProfile.runctx(
"_build_tree()",
{},
{"_build_tree": _build_tree},
"paths.withoutaunique.prof",
)
else:
interval = timeit.timeit(_build_tree, number=1)
print('Without %aunique:', interval)
print("Without %aunique:", interval)
def match_benchmark(lib, prof, query=None, album_id=None):
# If no album ID is provided, we'll match against a suitably huge
# album.
if not album_id:
album_id = '9c5c043e-bc69-4edb-81a4-1aaf9c81e6dc'
album_id = "9c5c043e-bc69-4edb-81a4-1aaf9c81e6dc"
# Get an album from the library to use as the source for the match.
items = lib.albums(query).get().items()
# Ensure fingerprinting is invoked (if enabled).
plugins.send('import_task_start',
task=importer.ImportTask(None, None, items),
session=importer.ImportSession(lib, None, None, None))
plugins.send(
"import_task_start",
task=importer.ImportTask(None, None, items),
session=importer.ImportSession(lib, None, None, None),
)
# Run the match.
def _run_match():
match.tag_album(items, search_ids=[album_id])
if prof:
cProfile.runctx('_run_match()', {}, {'_run_match': _run_match},
'match.prof')
cProfile.runctx(
"_run_match()", {}, {"_run_match": _run_match}, "match.prof"
)
else:
interval = timeit.timeit(_run_match, number=1)
print('match duration:', interval)
print("match duration:", interval)
class BenchmarkPlugin(BeetsPlugin):
"""A plugin for performing some simple performance benchmarks.
"""
def commands(self):
aunique_bench_cmd = ui.Subcommand('bench_aunique',
help='benchmark for %aunique{}')
aunique_bench_cmd.parser.add_option('-p', '--profile',
action='store_true', default=False,
help='performance profiling')
aunique_bench_cmd.func = lambda lib, opts, args: \
aunique_benchmark(lib, opts.profile)
"""A plugin for performing some simple performance benchmarks."""
match_bench_cmd = ui.Subcommand('bench_match',
help='benchmark for track matching')
match_bench_cmd.parser.add_option('-p', '--profile',
action='store_true', default=False,
help='performance profiling')
match_bench_cmd.parser.add_option('-i', '--id', default=None,
help='album ID to match against')
match_bench_cmd.func = lambda lib, opts, args: \
match_benchmark(lib, opts.profile, ui.decargs(args), opts.id)
def commands(self):
aunique_bench_cmd = ui.Subcommand(
"bench_aunique", help="benchmark for %aunique{}"
)
aunique_bench_cmd.parser.add_option(
"-p",
"--profile",
action="store_true",
default=False,
help="performance profiling",
)
aunique_bench_cmd.func = lambda lib, opts, args: aunique_benchmark(
lib, opts.profile
)
match_bench_cmd = ui.Subcommand(
"bench_match", help="benchmark for track matching"
)
match_bench_cmd.parser.add_option(
"-p",
"--profile",
action="store_true",
default=False,
help="performance profiling",
)
match_bench_cmd.parser.add_option(
"-i", "--id", default=None, help="album ID to match against"
)
match_bench_cmd.func = lambda lib, opts, args: match_benchmark(
lib, opts.profile, ui.decargs(args), opts.id
)
return [aunique_bench_cmd, match_bench_cmd]
File diff suppressed because it is too large Load Diff
+22 -20
View File
@@ -17,18 +17,19 @@ music player.
"""
import _thread
import copy
import os
import sys
import time
import _thread
import os
import copy
import urllib
from beets import ui
import gi
gi.require_version('Gst', '1.0')
from gi.repository import GLib, Gst # noqa: E402
from beets import ui
gi.require_version("Gst", "1.0")
from gi.repository import GLib, Gst # noqa: E402
Gst.init(None)
@@ -128,8 +129,8 @@ class GstPlayer:
"""
self.player.set_state(Gst.State.NULL)
if isinstance(path, str):
path = path.encode('utf-8')
uri = 'file://' + urllib.parse.quote(path)
path = path.encode("utf-8")
uri = "file://" + urllib.parse.quote(path)
self.player.set_property("uri", uri)
self.player.set_state(Gst.State.PLAYING)
self.playing = True
@@ -175,12 +176,12 @@ class GstPlayer:
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)
@@ -202,7 +203,7 @@ class GstPlayer:
return
fmt = Gst.Format(Gst.Format.TIME)
ns = position * 10 ** 9 # convert to nanoseconds
ns = position * 10**9 # convert to nanoseconds
self.player.seek_simple(fmt, Gst.SeekFlags.FLUSH, ns)
# save new cached time
@@ -223,11 +224,13 @@ def get_decoders():
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)
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()
@@ -239,7 +242,7 @@ def get_decoders():
for i in range(caps.get_size()):
struct = caps.get_structure(i)
mime = struct.get_name()
if mime == 'unknown/unknown':
if mime == "unknown/unknown":
continue
mimes.add(mime)
mime_types.add(mime)
@@ -295,10 +298,9 @@ def play_complicated(paths):
time.sleep(1)
if __name__ == '__main__':
if __name__ == "__main__":
# A very simple command-line player. Just give it names of audio
# files on the command line; these are all played in sequence.
paths = [os.path.abspath(os.path.expanduser(p))
for p in sys.argv[1:]]
paths = [os.path.abspath(os.path.expanduser(p)) for p in sys.argv[1:]]
# play_simple(paths)
play_complicated(paths)
+22 -18
View File
@@ -30,7 +30,7 @@ def bpm(max_strokes):
for i in range(max_strokes):
# Press enter to the rhythm...
s = input()
if s == '':
if s == "":
t1 = time.time()
# Only start measuring at the second stroke
if t0:
@@ -46,18 +46,20 @@ def bpm(max_strokes):
class BPMPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'max_strokes': 3,
'overwrite': True,
})
self.config.add(
{
"max_strokes": 3,
"overwrite": True,
}
)
def commands(self):
cmd = ui.Subcommand('bpm',
help='determine bpm of a song by pressing '
'a key to the rhythm')
cmd = ui.Subcommand(
"bpm",
help="determine bpm of a song by pressing " "a key to the rhythm",
)
cmd.func = self.command
return [cmd]
@@ -67,21 +69,23 @@ class BPMPlugin(BeetsPlugin):
self.get_bpm(items, write)
def get_bpm(self, items, write=False):
overwrite = self.config['overwrite'].get(bool)
overwrite = self.config["overwrite"].get(bool)
if len(items) > 1:
raise ValueError('Can only get bpm of one song at time')
raise ValueError("Can only get bpm of one song at time")
item = items[0]
if item['bpm']:
self._log.info('Found bpm {0}', item['bpm'])
if item["bpm"]:
self._log.info("Found bpm {0}", item["bpm"])
if not overwrite:
return
self._log.info('Press Enter {0} times to the rhythm or Ctrl-D '
'to exit', self.config['max_strokes'].get(int))
new_bpm = bpm(self.config['max_strokes'].get(int))
item['bpm'] = int(new_bpm)
self._log.info(
"Press Enter {0} times to the rhythm or Ctrl-D " "to exit",
self.config["max_strokes"].get(int),
)
new_bpm = bpm(self.config["max_strokes"].get(int))
item["bpm"] = int(new_bpm)
if write:
item.try_write()
item.store()
self._log.info('Added new bpm {0}', item['bpm'])
self._log.info("Added new bpm {0}", item["bpm"])
+30 -31
View File
@@ -15,8 +15,8 @@
"""Update library's tags using Beatport.
"""
from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from beets.plugins import BeetsPlugin, apply_item_changes
from .beatport import BeatportPlugin
@@ -28,33 +28,33 @@ class BPSyncPlugin(BeetsPlugin):
self.beatport_plugin.setup()
def commands(self):
cmd = ui.Subcommand('bpsync', help='update metadata from Beatport')
cmd = ui.Subcommand("bpsync", help="update metadata from Beatport")
cmd.parser.add_option(
'-p',
'--pretend',
action='store_true',
help='show all changes but do nothing',
"-p",
"--pretend",
action="store_true",
help="show all changes but do nothing",
)
cmd.parser.add_option(
'-m',
'--move',
action='store_true',
dest='move',
"-m",
"--move",
action="store_true",
dest="move",
help="move files in the library directory",
)
cmd.parser.add_option(
'-M',
'--nomove',
action='store_false',
dest='move',
"-M",
"--nomove",
action="store_false",
dest="move",
help="don't move files in library",
)
cmd.parser.add_option(
'-W',
'--nowrite',
action='store_false',
"-W",
"--nowrite",
action="store_false",
default=None,
dest='write',
dest="write",
help="don't write updated metadata to files",
)
cmd.parser.add_format_option()
@@ -62,8 +62,7 @@ class BPSyncPlugin(BeetsPlugin):
return [cmd]
def func(self, lib, opts, args):
"""Command handler for the bpsync function.
"""
"""Command handler for the bpsync function."""
move = ui.should_move(opts.move)
pretend = opts.pretend
write = ui.should_write(opts.write)
@@ -76,16 +75,16 @@ class BPSyncPlugin(BeetsPlugin):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + ['singleton:true']):
for item in lib.items(query + ["singleton:true"]):
if not item.mb_trackid:
self._log.info(
'Skipping singleton with no mb_trackid: {}', item
"Skipping singleton with no mb_trackid: {}", item
)
continue
if not self.is_beatport_track(item):
self._log.info(
'Skipping non-{} singleton: {}',
"Skipping non-{} singleton: {}",
self.beatport_plugin.data_source,
item,
)
@@ -100,27 +99,27 @@ class BPSyncPlugin(BeetsPlugin):
@staticmethod
def is_beatport_track(item):
return (
item.get('data_source') == BeatportPlugin.data_source
item.get("data_source") == BeatportPlugin.data_source
and item.mb_trackid.isnumeric()
)
def get_album_tracks(self, album):
if not album.mb_albumid:
self._log.info('Skipping album with no mb_albumid: {}', album)
self._log.info("Skipping album with no mb_albumid: {}", album)
return False
if not album.mb_albumid.isnumeric():
self._log.info(
'Skipping album with invalid {} ID: {}',
"Skipping album with invalid {} ID: {}",
self.beatport_plugin.data_source,
album,
)
return False
items = list(album.items())
if album.get('data_source') == self.beatport_plugin.data_source:
if album.get("data_source") == self.beatport_plugin.data_source:
return items
if not all(self.is_beatport_track(item) for item in items):
self._log.info(
'Skipping non-{} release: {}',
"Skipping non-{} release: {}",
self.beatport_plugin.data_source,
album,
)
@@ -142,7 +141,7 @@ class BPSyncPlugin(BeetsPlugin):
albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
if not albuminfo:
self._log.info(
'Release ID {} not found for album {}',
"Release ID {} not found for album {}",
album.mb_albumid,
album,
)
@@ -159,7 +158,7 @@ class BPSyncPlugin(BeetsPlugin):
for track_id, item in library_trackid_to_item.items()
}
self._log.info('applying changes to {}', album)
self._log.info("applying changes to {}", album)
with lib.transaction():
autotag.apply_metadata(albuminfo, item_to_trackinfo)
changed = False
@@ -182,5 +181,5 @@ class BPSyncPlugin(BeetsPlugin):
# Move album art (and any inconsistent items).
if move and lib.directory in util.ancestry(items[0].path):
self._log.debug('moving album {}', album)
self._log.debug("moving album {}", album)
album.move()
+89 -78
View File
@@ -16,14 +16,13 @@
"""
from datetime import datetime
import re
import string
from datetime import datetime
from itertools import tee
from beets import plugins, ui
ASCII_DIGITS = string.digits + string.ascii_lowercase
@@ -39,12 +38,10 @@ def pairwise(iterable):
def span_from_str(span_str):
"""Build a span dict from the span string representation.
"""
"""Build a span dict from the span string representation."""
def normalize_year(d, yearfrom):
"""Convert string to a 4 digits year
"""
"""Convert string to a 4 digits year"""
if yearfrom < 100:
raise BucketError("%d must be expressed on 4 digits" % yearfrom)
@@ -57,31 +54,33 @@ def span_from_str(span_str):
d = (yearfrom - yearfrom % 100) + d
return d
years = [int(x) for x in re.findall(r'\d+', span_str)]
years = [int(x) for x in re.findall(r"\d+", span_str)]
if not years:
raise ui.UserError("invalid range defined for year bucket '%s': no "
"year found" % span_str)
raise ui.UserError(
"invalid range defined for year bucket '%s': no "
"year found" % span_str
)
try:
years = [normalize_year(x, years[0]) for x in years]
except BucketError as exc:
raise ui.UserError("invalid range defined for year bucket '%s': %s" %
(span_str, exc))
raise ui.UserError(
"invalid range defined for year bucket '%s': %s" % (span_str, exc)
)
res = {'from': years[0], 'str': span_str}
res = {"from": years[0], "str": span_str}
if len(years) > 1:
res['to'] = years[-1]
res["to"] = years[-1]
return res
def complete_year_spans(spans):
"""Set the `to` value of spans if empty and sort them chronologically.
"""
spans.sort(key=lambda x: x['from'])
for (x, y) in pairwise(spans):
if 'to' not in x:
x['to'] = y['from'] - 1
if spans and 'to' not in spans[-1]:
spans[-1]['to'] = datetime.now().year
"""Set the `to` value of spans if empty and sort them chronologically."""
spans.sort(key=lambda x: x["from"])
for x, y in pairwise(spans):
if "to" not in x:
x["to"] = y["from"] - 1
if spans and "to" not in spans[-1]:
spans[-1]["to"] = datetime.now().year
def extend_year_spans(spans, spanlen, start=1900, end=2014):
@@ -89,17 +88,17 @@ def extend_year_spans(spans, spanlen, start=1900, end=2014):
belongs to a span.
"""
extended_spans = spans[:]
for (x, y) in pairwise(spans):
for x, y in pairwise(spans):
# if a gap between two spans, fill the gap with as much spans of
# spanlen length as necessary
for span_from in range(x['to'] + 1, y['from'], spanlen):
extended_spans.append({'from': span_from})
for span_from in range(x["to"] + 1, y["from"], spanlen):
extended_spans.append({"from": span_from})
# Create spans prior to declared ones
for span_from in range(spans[0]['from'] - spanlen, start, -spanlen):
extended_spans.append({'from': span_from})
for span_from in range(spans[0]["from"] - spanlen, start, -spanlen):
extended_spans.append({"from": span_from})
# Create spans after the declared ones
for span_from in range(spans[-1]['to'] + 1, end, spanlen):
extended_spans.append({'from': span_from})
for span_from in range(spans[-1]["to"] + 1, end, spanlen):
extended_spans.append({"from": span_from})
complete_year_spans(extended_spans)
return extended_spans
@@ -117,25 +116,29 @@ def build_year_spans(year_spans_str):
def str2fmt(s):
"""Deduces formatting syntax from a span string.
"""
regex = re.compile(r"(?P<bef>\D*)(?P<fromyear>\d+)(?P<sep>\D*)"
r"(?P<toyear>\d*)(?P<after>\D*)")
"""Deduces formatting syntax from a span string."""
regex = re.compile(
r"(?P<bef>\D*)(?P<fromyear>\d+)(?P<sep>\D*)"
r"(?P<toyear>\d*)(?P<after>\D*)"
)
m = re.match(regex, s)
res = {'fromnchars': len(m.group('fromyear')),
'tonchars': len(m.group('toyear'))}
res['fmt'] = "{}%s{}{}{}".format(m.group('bef'),
m.group('sep'),
'%s' if res['tonchars'] else '',
m.group('after'))
res = {
"fromnchars": len(m.group("fromyear")),
"tonchars": len(m.group("toyear")),
}
res["fmt"] = "{}%s{}{}{}".format(
m.group("bef"),
m.group("sep"),
"%s" if res["tonchars"] else "",
m.group("after"),
)
return res
def format_span(fmt, yearfrom, yearto, fromnchars, tonchars):
"""Return a span string representation.
"""
args = (str(yearfrom)[-fromnchars:])
"""Return a span string representation."""
args = str(yearfrom)[-fromnchars:]
if tonchars:
args = (str(yearfrom)[-fromnchars:], str(yearto)[-tonchars:])
@@ -143,11 +146,10 @@ def format_span(fmt, yearfrom, yearto, fromnchars, tonchars):
def extract_modes(spans):
"""Extract the most common spans lengths and representation formats
"""
rangelen = sorted([x['to'] - x['from'] + 1 for x in spans])
"""Extract the most common spans lengths and representation formats"""
rangelen = sorted([x["to"] - x["from"] + 1 for x in spans])
deflen = sorted(rangelen, key=rangelen.count)[-1]
reprs = [str2fmt(x['str']) for x in spans]
reprs = [str2fmt(x["str"]) for x in spans]
deffmt = sorted(reprs, key=reprs.count)[-1]
return deflen, deffmt
@@ -167,13 +169,16 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
begin_index = ASCII_DIGITS.index(bucket[0])
end_index = ASCII_DIGITS.index(bucket[-1])
else:
raise ui.UserError("invalid range defined for alpha bucket "
"'%s': no alphanumeric character found" %
elem)
raise ui.UserError(
"invalid range defined for alpha bucket "
"'%s': no alphanumeric character found" % elem
)
spans.append(
re.compile(
"^[" + ASCII_DIGITS[begin_index:end_index + 1] +
ASCII_DIGITS[begin_index:end_index + 1].upper() + "]"
"^["
+ ASCII_DIGITS[begin_index : end_index + 1]
+ ASCII_DIGITS[begin_index : end_index + 1].upper()
+ "]"
)
)
return spans
@@ -182,29 +187,32 @@ def build_alpha_spans(alpha_spans_str, alpha_regexs):
class BucketPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.template_funcs['bucket'] = self._tmpl_bucket
self.template_funcs["bucket"] = self._tmpl_bucket
self.config.add({
'bucket_year': [],
'bucket_alpha': [],
'bucket_alpha_regex': {},
'extrapolate': False
})
self.config.add(
{
"bucket_year": [],
"bucket_alpha": [],
"bucket_alpha_regex": {},
"extrapolate": False,
}
)
self.setup()
def setup(self):
"""Setup plugin from config options
"""
self.year_spans = build_year_spans(self.config['bucket_year'].get())
if self.year_spans and self.config['extrapolate']:
[self.ys_len_mode,
self.ys_repr_mode] = extract_modes(self.year_spans)
self.year_spans = extend_year_spans(self.year_spans,
self.ys_len_mode)
"""Setup plugin from config options"""
self.year_spans = build_year_spans(self.config["bucket_year"].get())
if self.year_spans and self.config["extrapolate"]:
[self.ys_len_mode, self.ys_repr_mode] = extract_modes(
self.year_spans
)
self.year_spans = extend_year_spans(
self.year_spans, self.ys_len_mode
)
self.alpha_spans = build_alpha_spans(
self.config['bucket_alpha'].get(),
self.config['bucket_alpha_regex'].get()
self.config["bucket_alpha"].get(),
self.config["bucket_alpha_regex"].get(),
)
def find_bucket_year(self, year):
@@ -212,30 +220,33 @@ class BucketPlugin(plugins.BeetsPlugin):
if no matching bucket.
"""
for ys in self.year_spans:
if ys['from'] <= int(year) <= ys['to']:
if 'str' in ys:
return ys['str']
if ys["from"] <= int(year) <= ys["to"]:
if "str" in ys:
return ys["str"]
else:
return format_span(self.ys_repr_mode['fmt'],
ys['from'], ys['to'],
self.ys_repr_mode['fromnchars'],
self.ys_repr_mode['tonchars'])
return format_span(
self.ys_repr_mode["fmt"],
ys["from"],
ys["to"],
self.ys_repr_mode["fromnchars"],
self.ys_repr_mode["tonchars"],
)
return year
def find_bucket_alpha(self, s):
"""Return alpha-range bucket that matches given string or return the
string initial if no matching bucket.
"""
for (i, span) in enumerate(self.alpha_spans):
for i, span in enumerate(self.alpha_spans):
if span.match(s):
return self.config['bucket_alpha'].get()[i]
return self.config["bucket_alpha"].get()[i]
return s[0].upper()
def _tmpl_bucket(self, text, field=None):
if not field and len(text) == 4 and text.isdigit():
field = 'year'
field = "year"
if field == 'year':
if field == "year":
func = self.find_bucket_year
else:
func = self.find_bucket_alpha
+105 -94
View File
@@ -16,18 +16,17 @@
autotagger. Requires the pyacoustid library.
"""
from beets import plugins
from beets import ui
from beets import util
from beets import config
from beets.autotag import hooks
import confuse
import acoustid
import re
from collections import defaultdict
from functools import partial
import re
API_KEY = '1vOwZtEn'
import acoustid
import confuse
from beets import config, plugins, ui, util
from beets.autotag import hooks
API_KEY = "1vOwZtEn"
SCORE_THRESH = 0.5
TRACK_ID_WEIGHT = 10.0
COMMON_REL_THRESH = 0.6 # How many tracks must have an album in common?
@@ -49,8 +48,7 @@ _acoustids = {}
def prefix(it, count):
"""Truncate an iterable to at most `count` items.
"""
"""Truncate an iterable to at most `count` items."""
for i, v in enumerate(it):
if i >= count:
break
@@ -58,13 +56,12 @@ def prefix(it, count):
def releases_key(release, countries, original_year):
"""Used as a key to sort releases by date then preferred country
"""
date = release.get('date')
"""Used as a key to sort releases by date then preferred country"""
date = release.get("date")
if date and original_year:
year = date.get('year', 9999)
month = date.get('month', 99)
day = date.get('day', 99)
year = date.get("year", 9999)
month = date.get("month", 99)
day = date.get("day", 99)
else:
year = 9999
month = 99
@@ -72,9 +69,9 @@ def releases_key(release, countries, original_year):
# Uses index of preferred countries to sort
country_key = 99
if release.get('country'):
if release.get("country"):
for i, country in enumerate(countries):
if country.match(release['country']):
if country.match(release["country"]):
country_key = i
break
@@ -88,56 +85,63 @@ def acoustid_match(log, path):
try:
duration, fp = acoustid.fingerprint_file(util.syspath(path))
except acoustid.FingerprintGenerationError as exc:
log.error('fingerprinting of {0} failed: {1}',
util.displayable_path(repr(path)), exc)
log.error(
"fingerprinting of {0} failed: {1}",
util.displayable_path(repr(path)),
exc,
)
return None
fp = fp.decode()
_fingerprints[path] = fp
try:
res = acoustid.lookup(API_KEY, fp, duration,
meta='recordings releases')
res = acoustid.lookup(API_KEY, fp, duration, meta="recordings releases")
except acoustid.AcoustidError as exc:
log.debug('fingerprint matching {0} failed: {1}',
util.displayable_path(repr(path)), exc)
log.debug(
"fingerprint matching {0} failed: {1}",
util.displayable_path(repr(path)),
exc,
)
return None
log.debug('chroma: fingerprinted {0}',
util.displayable_path(repr(path)))
log.debug("chroma: fingerprinted {0}", util.displayable_path(repr(path)))
# Ensure the response is usable and parse it.
if res['status'] != 'ok' or not res.get('results'):
log.debug('no match found')
if res["status"] != "ok" or not res.get("results"):
log.debug("no match found")
return None
result = res['results'][0] # Best match.
if result['score'] < SCORE_THRESH:
log.debug('no results above threshold')
result = res["results"][0] # Best match.
if result["score"] < SCORE_THRESH:
log.debug("no results above threshold")
return None
_acoustids[path] = result['id']
_acoustids[path] = result["id"]
# Get recording and releases from the result
if not result.get('recordings'):
log.debug('no recordings found')
if not result.get("recordings"):
log.debug("no recordings found")
return None
recording_ids = []
releases = []
for recording in result['recordings']:
recording_ids.append(recording['id'])
if 'releases' in recording:
releases.extend(recording['releases'])
for recording in result["recordings"]:
recording_ids.append(recording["id"])
if "releases" in recording:
releases.extend(recording["releases"])
# The releases list is essentially in random order from the Acoustid lookup
# so we optionally sort it using the match.preferred configuration options.
# 'original_year' to sort the earliest first and
# 'countries' to then sort preferred countries first.
country_patterns = config['match']['preferred']['countries'].as_str_seq()
country_patterns = config["match"]["preferred"]["countries"].as_str_seq()
countries = [re.compile(pat, re.I) for pat in country_patterns]
original_year = config['match']['preferred']['original_year']
releases.sort(key=partial(releases_key,
countries=countries,
original_year=original_year))
release_ids = [rel['id'] for rel in releases]
original_year = config["match"]["preferred"]["original_year"]
releases.sort(
key=partial(
releases_key, countries=countries, original_year=original_year
)
)
release_ids = [rel["id"] for rel in releases]
log.debug('matched recordings {0} on releases {1}',
recording_ids, release_ids)
log.debug(
"matched recordings {0} on releases {1}", recording_ids, release_ids
)
_matches[path] = recording_ids, release_ids
@@ -167,14 +171,16 @@ class AcoustidPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'auto': True,
})
config['acoustid']['apikey'].redact = True
self.config.add(
{
"auto": True,
}
)
config["acoustid"]["apikey"].redact = True
if self.config['auto']:
self.register_listener('import_task_start', self.fingerprint_task)
self.register_listener('import_task_apply', apply_acoustid_metadata)
if self.config["auto"]:
self.register_listener("import_task_start", self.fingerprint_task)
self.register_listener("import_task_apply", apply_acoustid_metadata)
def fingerprint_task(self, task, session):
return fingerprint_task(self._log, task, session)
@@ -186,7 +192,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
return dist
recording_ids, _ = _matches[item.path]
dist.add_expr('track_id', info.track_id not in recording_ids)
dist.add_expr("track_id", info.track_id not in recording_ids)
return dist
def candidates(self, items, artist, album, va_likely, extra_tags=None):
@@ -196,7 +202,7 @@ class AcoustidPlugin(plugins.BeetsPlugin):
if album:
albums.append(album)
self._log.debug('acoustid album candidates: {0}', len(albums))
self._log.debug("acoustid album candidates: {0}", len(albums))
return albums
def item_candidates(self, item, artist, title):
@@ -209,29 +215,31 @@ class AcoustidPlugin(plugins.BeetsPlugin):
track = hooks.track_for_mbid(recording_id)
if track:
tracks.append(track)
self._log.debug('acoustid item candidates: {0}', len(tracks))
self._log.debug("acoustid item candidates: {0}", len(tracks))
return tracks
def commands(self):
submit_cmd = ui.Subcommand('submit',
help='submit Acoustid fingerprints')
submit_cmd = ui.Subcommand(
"submit", help="submit Acoustid fingerprints"
)
def submit_cmd_func(lib, opts, args):
try:
apikey = config['acoustid']['apikey'].as_str()
apikey = config["acoustid"]["apikey"].as_str()
except confuse.NotFoundError:
raise ui.UserError('no Acoustid user API key provided')
raise ui.UserError("no Acoustid user API key provided")
submit_items(self._log, apikey, lib.items(ui.decargs(args)))
submit_cmd.func = submit_cmd_func
fingerprint_cmd = ui.Subcommand(
'fingerprint',
help='generate fingerprints for items without them'
"fingerprint", help="generate fingerprints for items without them"
)
def fingerprint_cmd_func(lib, opts, args):
for item in lib.items(ui.decargs(args)):
fingerprint_item(self._log, item, write=ui.should_write())
fingerprint_cmd.func = fingerprint_cmd_func
return [submit_cmd, fingerprint_cmd]
@@ -250,8 +258,7 @@ def fingerprint_task(log, task, session):
def apply_acoustid_metadata(task, session):
"""Apply Acoustid metadata (fingerprint and ID) to the task's items.
"""
"""Apply Acoustid metadata (fingerprint and ID) to the task's items."""
for item in task.imported_items():
if item.path in _fingerprints:
item.acoustid_fingerprint = _fingerprints[item.path]
@@ -263,17 +270,16 @@ def apply_acoustid_metadata(task, session):
def submit_items(log, userkey, items, chunksize=64):
"""Submit fingerprints for the items to the Acoustid server.
"""
"""Submit fingerprints for the items to the Acoustid server."""
data = [] # The running list of dictionaries to submit.
def submit_chunk():
"""Submit the current accumulated fingerprint data."""
log.info('submitting {0} fingerprints', len(data))
log.info("submitting {0} fingerprints", len(data))
try:
acoustid.submit(API_KEY, userkey, data)
except acoustid.AcoustidError as exc:
log.warning('acoustid submission error: {0}', exc)
log.warning("acoustid submission error: {0}", exc)
del data[:]
for item in items:
@@ -281,23 +287,25 @@ def submit_items(log, userkey, items, chunksize=64):
# Construct a submission dictionary for this item.
item_data = {
'duration': int(item.length),
'fingerprint': fp,
"duration": int(item.length),
"fingerprint": fp,
}
if item.mb_trackid:
item_data['mbid'] = item.mb_trackid
log.debug('submitting MBID')
item_data["mbid"] = item.mb_trackid
log.debug("submitting MBID")
else:
item_data.update({
'track': item.title,
'artist': item.artist,
'album': item.album,
'albumartist': item.albumartist,
'year': item.year,
'trackno': item.track,
'discno': item.disc,
})
log.debug('submitting textual metadata')
item_data.update(
{
"track": item.title,
"artist": item.artist,
"album": item.album,
"albumartist": item.albumartist,
"year": item.year,
"trackno": item.track,
"discno": item.disc,
}
)
log.debug("submitting textual metadata")
data.append(item_data)
# If we have enough data, submit a chunk.
@@ -318,28 +326,31 @@ def fingerprint_item(log, item, write=False):
"""
# Get a fingerprint and length for this track.
if not item.length:
log.info('{0}: no duration available',
util.displayable_path(item.path))
log.info("{0}: no duration available", util.displayable_path(item.path))
elif item.acoustid_fingerprint:
if write:
log.info('{0}: fingerprint exists, skipping',
util.displayable_path(item.path))
log.info(
"{0}: fingerprint exists, skipping",
util.displayable_path(item.path),
)
else:
log.info('{0}: using existing fingerprint',
util.displayable_path(item.path))
log.info(
"{0}: using existing fingerprint",
util.displayable_path(item.path),
)
return item.acoustid_fingerprint
else:
log.info('{0}: fingerprinting',
util.displayable_path(item.path))
log.info("{0}: fingerprinting", util.displayable_path(item.path))
try:
_, fp = acoustid.fingerprint_file(util.syspath(item.path))
item.acoustid_fingerprint = fp.decode()
if write:
log.info('{0}: writing fingerprint',
util.displayable_path(item.path))
log.info(
"{0}: writing fingerprint", util.displayable_path(item.path)
)
item.try_write()
if item._db:
item.store()
return item.acoustid_fingerprint
except acoustid.FingerprintGenerationError as exc:
log.info('fingerprint generation failed: {0}', exc)
log.info("fingerprint generation failed: {0}", exc)
+461 -233
View File
@@ -14,33 +14,33 @@
"""Converts tracks or albums to external directory
"""
from beets.util import par_map, decode_commandline_path, arg_encoding
import logging
import os
import threading
import shlex
import subprocess
import tempfile
import shlex
import threading
from string import Template
from beets import ui, util, plugins, config
from confuse import ConfigTypeError, Optional
from beets import art, config, plugins, ui, util
from beets.library import Item, parse_query_string
from beets.plugins import BeetsPlugin
from confuse import ConfigTypeError
from beets import art
from beets.util import arg_encoding, par_map
from beets.util.artresizer import ArtResizer
from beets.library import parse_query_string
from beets.library import Item
from beets.util.m3u import M3UFile
_fs_lock = threading.Lock()
_temp_files = [] # Keep track of temporary transcoded files for deletion.
# Some convenient alternate names for formats.
ALIASES = {
'wma': 'windows media',
'vorbis': 'ogg',
"windows media": "wma",
"vorbis": "ogg",
}
LOSSLESS_FORMATS = ['ape', 'flac', 'alac', 'wav', 'aiff']
LOSSLESS_FORMATS = ["ape", "flac", "alac", "wave", "aiff"]
def replace_ext(path, ext):
@@ -48,140 +48,217 @@ def replace_ext(path, ext):
The new extension must not contain a leading dot.
"""
ext_dot = b'.' + ext
ext_dot = b"." + ext
return os.path.splitext(path)[0] + ext_dot
def get_format(fmt=None):
"""Return the command template and the extension from the config.
"""
"""Return the command template and the extension from the config."""
if not fmt:
fmt = config['convert']['format'].as_str().lower()
fmt = config["convert"]["format"].as_str().lower()
fmt = ALIASES.get(fmt, fmt)
try:
format_info = config['convert']['formats'][fmt].get(dict)
command = format_info['command']
extension = format_info.get('extension', fmt)
format_info = config["convert"]["formats"][fmt].get(dict)
command = format_info["command"]
extension = format_info.get("extension", fmt)
except KeyError:
raise ui.UserError(
'convert: format {} needs the "command" field'
.format(fmt)
'convert: format {} needs the "command" field'.format(fmt)
)
except ConfigTypeError:
command = config['convert']['formats'][fmt].get(str)
command = config["convert"]["formats"][fmt].get(str)
extension = fmt
# Convenience and backwards-compatibility shortcuts.
keys = config['convert'].keys()
if 'command' in keys:
command = config['convert']['command'].as_str()
elif 'opts' in keys:
keys = config["convert"].keys()
if "command" in keys:
command = config["convert"]["command"].as_str()
elif "opts" in keys:
# Undocumented option for backwards compatibility with < 1.3.1.
command = 'ffmpeg -i $source -y {} $dest'.format(
config['convert']['opts'].as_str()
command = "ffmpeg -i $source -y {} $dest".format(
config["convert"]["opts"].as_str()
)
if 'extension' in keys:
extension = config['convert']['extension'].as_str()
if "extension" in keys:
extension = config["convert"]["extension"].as_str()
return (command.encode('utf-8'), extension.encode('utf-8'))
return (command.encode("utf-8"), extension.encode("utf-8"))
def should_transcode(item, fmt):
"""Determine whether the item should be transcoded as part of
conversion (i.e., its bitrate is high or it has the wrong format).
"""
no_convert_queries = config['convert']['no_convert'].as_str_seq()
no_convert_queries = config["convert"]["no_convert"].as_str_seq()
if no_convert_queries:
for query_string in no_convert_queries:
query, _ = parse_query_string(query_string, Item)
if query.match(item):
return False
if config['convert']['never_convert_lossy_files'] and \
not (item.format.lower() in LOSSLESS_FORMATS):
if config["convert"]["never_convert_lossy_files"] and not (
item.format.lower() in LOSSLESS_FORMATS
):
return False
maxbr = config['convert']['max_bitrate'].get(int)
return fmt.lower() != item.format.lower() or \
item.bitrate >= 1000 * maxbr
maxbr = config["convert"]["max_bitrate"].get(Optional(int))
if maxbr is not None and item.bitrate >= 1000 * maxbr:
return True
return fmt.lower() != item.format.lower()
class ConvertPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'dest': None,
'pretend': False,
'link': False,
'hardlink': False,
'threads': util.cpu_count(),
'format': 'mp3',
'id3v23': 'inherit',
'formats': {
'aac': {
'command': 'ffmpeg -i $source -y -vn -acodec aac '
'-aq 1 $dest',
'extension': 'm4a',
self.config.add(
{
"dest": None,
"pretend": False,
"link": False,
"hardlink": False,
"threads": os.cpu_count(),
"format": "mp3",
"id3v23": "inherit",
"formats": {
"aac": {
"command": "ffmpeg -i $source -y -vn -acodec aac "
"-aq 1 $dest",
"extension": "m4a",
},
"alac": {
"command": "ffmpeg -i $source -y -vn -acodec alac $dest",
"extension": "m4a",
},
"flac": "ffmpeg -i $source -y -vn -acodec flac $dest",
"mp3": "ffmpeg -i $source -y -vn -aq 2 $dest",
"opus": "ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest",
"ogg": "ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest",
"wma": "ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest",
},
'alac': {
'command': 'ffmpeg -i $source -y -vn -acodec alac $dest',
'extension': 'm4a',
},
'flac': 'ffmpeg -i $source -y -vn -acodec flac $dest',
'mp3': 'ffmpeg -i $source -y -vn -aq 2 $dest',
'opus':
'ffmpeg -i $source -y -vn -acodec libopus -ab 96k $dest',
'ogg':
'ffmpeg -i $source -y -vn -acodec libvorbis -aq 3 $dest',
'wma':
'ffmpeg -i $source -y -vn -acodec wmav2 -vn $dest',
},
'max_bitrate': 500,
'auto': False,
'tmpdir': None,
'quiet': False,
'embed': True,
'paths': {},
'no_convert': '',
'never_convert_lossy_files': False,
'copy_album_art': False,
'album_art_maxwidth': 0,
'delete_originals': False,
})
self.early_import_stages = [self.auto_convert]
"max_bitrate": None,
"auto": False,
"auto_keep": False,
"tmpdir": None,
"quiet": False,
"embed": True,
"paths": {},
"no_convert": "",
"never_convert_lossy_files": False,
"copy_album_art": False,
"album_art_maxwidth": 0,
"delete_originals": False,
"playlist": None,
}
)
self.early_import_stages = [self.auto_convert, self.auto_convert_keep]
self.register_listener('import_task_files', self._cleanup)
self.register_listener("import_task_files", self._cleanup)
def commands(self):
cmd = ui.Subcommand('convert', help='convert to external location')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show actions but do nothing')
cmd.parser.add_option('-t', '--threads', action='store', type='int',
help='change the number of threads, \
defaults to maximum available processors')
cmd.parser.add_option('-k', '--keep-new', action='store_true',
dest='keep_new', help='keep only the converted \
and move the old files')
cmd.parser.add_option('-d', '--dest', action='store',
help='set the destination directory')
cmd.parser.add_option('-f', '--format', action='store', dest='format',
help='set the target format of the tracks')
cmd.parser.add_option('-y', '--yes', action='store_true', dest='yes',
help='do not ask for confirmation')
cmd.parser.add_option('-l', '--link', action='store_true', dest='link',
help='symlink files that do not \
need transcoding.')
cmd.parser.add_option('-H', '--hardlink', action='store_true',
dest='hardlink',
help='hardlink files that do not \
need transcoding. Overrides --link.')
cmd = ui.Subcommand("convert", help="convert to external location")
cmd.parser.add_option(
"-p",
"--pretend",
action="store_true",
help="show actions but do nothing",
)
cmd.parser.add_option(
"-t",
"--threads",
action="store",
type="int",
help="change the number of threads, \
defaults to maximum available processors",
)
cmd.parser.add_option(
"-k",
"--keep-new",
action="store_true",
dest="keep_new",
help="keep only the converted \
and move the old files",
)
cmd.parser.add_option(
"-d", "--dest", action="store", help="set the destination directory"
)
cmd.parser.add_option(
"-f",
"--format",
action="store",
dest="format",
help="set the target format of the tracks",
)
cmd.parser.add_option(
"-y",
"--yes",
action="store_true",
dest="yes",
help="do not ask for confirmation",
)
cmd.parser.add_option(
"-l",
"--link",
action="store_true",
dest="link",
help="symlink files that do not \
need transcoding.",
)
cmd.parser.add_option(
"-H",
"--hardlink",
action="store_true",
dest="hardlink",
help="hardlink files that do not \
need transcoding. Overrides --link.",
)
cmd.parser.add_option(
"-m",
"--playlist",
action="store",
help="""create an m3u8 playlist file containing
the converted files. The playlist file will be
saved below the destination directory, thus
PLAYLIST could be a file name or a relative path.
To ensure a working playlist when transferred to
a different computer, or opened from an external
drive, relative paths pointing to media files
will be used.""",
)
cmd.parser.add_album_option()
cmd.func = self.convert_func
return [cmd]
def auto_convert(self, config, task):
if self.config['auto']:
par_map(lambda item: self.convert_on_import(config.lib, item),
task.imported_items())
if self.config["auto"]:
par_map(
lambda item: self.convert_on_import(config.lib, item),
task.imported_items(),
)
def auto_convert_keep(self, config, task):
if self.config["auto_keep"]:
empty_opts = self.commands()[0].parser.get_default_values()
(
dest,
threads,
path_formats,
fmt,
pretend,
hardlink,
link,
playlist,
) = self._get_opts_and_config(empty_opts)
items = task.imported_items()
self._parallel_convert(
dest,
False,
path_formats,
fmt,
pretend,
link,
hardlink,
threads,
items,
)
# Utilities converted from functions to methods on logging overhaul
@@ -196,55 +273,70 @@ class ConvertPlugin(BeetsPlugin):
assert isinstance(source, bytes)
assert isinstance(dest, bytes)
quiet = self.config['quiet'].get(bool)
quiet = self.config["quiet"].get(bool)
if not quiet and not pretend:
self._log.info('Encoding {0}', util.displayable_path(source))
self._log.info("Encoding {0}", util.displayable_path(source))
command = command.decode(arg_encoding(), 'surrogateescape')
source = decode_commandline_path(source)
dest = decode_commandline_path(dest)
command = command.decode(arg_encoding(), "surrogateescape")
source = os.fsdecode(source)
dest = os.fsdecode(dest)
# Substitute $source and $dest in the argument list.
args = shlex.split(command)
encode_cmd = []
for i, arg in enumerate(args):
args[i] = Template(arg).safe_substitute({
'source': source,
'dest': dest,
})
args[i] = Template(arg).safe_substitute(
{
"source": source,
"dest": dest,
}
)
encode_cmd.append(args[i].encode(util.arg_encoding()))
if pretend:
self._log.info('{0}', ' '.join(ui.decargs(args)))
self._log.info("{0}", " ".join(ui.decargs(args)))
return
try:
util.command_output(encode_cmd)
except subprocess.CalledProcessError as exc:
# Something went wrong (probably Ctrl+C), remove temporary files
self._log.info('Encoding {0} failed. Cleaning up...',
util.displayable_path(source))
self._log.debug('Command {0} exited with status {1}: {2}',
args,
exc.returncode,
exc.output)
self._log.info(
"Encoding {0} failed. Cleaning up...",
util.displayable_path(source),
)
self._log.debug(
"Command {0} exited with status {1}: {2}",
args,
exc.returncode,
exc.output,
)
util.remove(dest)
util.prune_dirs(os.path.dirname(dest))
raise
except OSError as exc:
raise ui.UserError(
"convert: couldn't invoke '{}': {}".format(
' '.join(ui.decargs(args)), exc
" ".join(ui.decargs(args)), exc
)
)
if not quiet and not pretend:
self._log.info('Finished encoding {0}',
util.displayable_path(source))
self._log.info(
"Finished encoding {0}", util.displayable_path(source)
)
def convert_item(self, dest_dir, keep_new, path_formats, fmt,
pretend=False, link=False, hardlink=False):
def convert_item(
self,
dest_dir,
keep_new,
path_formats,
fmt,
pretend=False,
link=False,
hardlink=False,
):
"""A pipeline thread that converts `Item` objects from a
library.
"""
@@ -252,8 +344,7 @@ class ConvertPlugin(BeetsPlugin):
item, original, converted = None, None, None
while True:
item = yield (item, original, converted)
dest = item.destination(basedir=dest_dir,
path_formats=path_formats)
dest = item.destination(basedir=dest_dir, path_formats=path_formats)
# When keeping the new file in the library, we first move the
# current (pristine) file to the destination. We'll then copy it
@@ -277,18 +368,23 @@ class ConvertPlugin(BeetsPlugin):
util.mkdirall(dest)
if os.path.exists(util.syspath(dest)):
self._log.info('Skipping {0} (target file exists)',
util.displayable_path(item.path))
self._log.info(
"Skipping {0} (target file exists)",
util.displayable_path(item.path),
)
continue
if keep_new:
if pretend:
self._log.info('mv {0} {1}',
util.displayable_path(item.path),
util.displayable_path(original))
self._log.info(
"mv {0} {1}",
util.displayable_path(item.path),
util.displayable_path(original),
)
else:
self._log.info('Moving to {0}',
util.displayable_path(original))
self._log.info(
"Moving to {0}", util.displayable_path(original)
)
util.move(item.path, original)
if should_transcode(item, fmt):
@@ -300,20 +396,25 @@ class ConvertPlugin(BeetsPlugin):
else:
linked = link or hardlink
if pretend:
msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
msg = "ln" if hardlink else ("ln -s" if link else "cp")
self._log.info('{2} {0} {1}',
util.displayable_path(original),
util.displayable_path(converted),
msg)
self._log.info(
"{2} {0} {1}",
util.displayable_path(original),
util.displayable_path(converted),
msg,
)
else:
# No transcoding necessary.
msg = 'Hardlinking' if hardlink \
else ('Linking' if link else 'Copying')
msg = (
"Hardlinking"
if hardlink
else ("Linking" if link else "Copying")
)
self._log.info('{1} {0}',
util.displayable_path(item.path),
msg)
self._log.info(
"{1} {0}", util.displayable_path(item.path), msg
)
if hardlink:
util.hardlink(original, converted)
@@ -325,8 +426,8 @@ class ConvertPlugin(BeetsPlugin):
if pretend:
continue
id3v23 = self.config['id3v23'].as_choice([True, False, 'inherit'])
if id3v23 == 'inherit':
id3v23 = self.config["id3v23"].as_choice([True, False, "inherit"])
if id3v23 == "inherit":
id3v23 = None
# Write tags from the database to the converted file.
@@ -339,23 +440,41 @@ class ConvertPlugin(BeetsPlugin):
item.read()
item.store() # Store new path and audio data.
if self.config['embed'] and not linked:
if self.config["embed"] and not linked:
album = item._cached_album
if album and album.artpath:
self._log.debug('embedding album art from {}',
util.displayable_path(album.artpath))
art.embed_item(self._log, item, album.artpath,
itempath=converted, id3v23=id3v23)
maxwidth = self._get_art_resize(album.artpath)
self._log.debug(
"embedding album art from {}",
util.displayable_path(album.artpath),
)
art.embed_item(
self._log,
item,
album.artpath,
maxwidth,
itempath=converted,
id3v23=id3v23,
)
if keep_new:
plugins.send('after_convert', item=item,
dest=dest, keepnew=True)
plugins.send(
"after_convert", item=item, dest=dest, keepnew=True
)
else:
plugins.send('after_convert', item=item,
dest=converted, keepnew=False)
plugins.send(
"after_convert", item=item, dest=converted, keepnew=False
)
def copy_album_art(self, album, dest_dir, path_formats, pretend=False,
link=False, hardlink=False):
def copy_album_art(
self,
album,
dest_dir,
path_formats,
pretend=False,
link=False,
hardlink=False,
):
"""Copies or converts the associated cover art of the album. Album must
have at least one track.
"""
@@ -369,8 +488,9 @@ class ConvertPlugin(BeetsPlugin):
# Get the destination of the first item (track) of the album, we use
# this function to format the path accordingly to path_formats.
dest = album_item.destination(basedir=dest_dir,
path_formats=path_formats)
dest = album_item.destination(
basedir=dest_dir, path_formats=path_formats
)
# Remove item from the path.
dest = os.path.join(*util.components(dest)[:-1])
@@ -383,46 +503,47 @@ class ConvertPlugin(BeetsPlugin):
util.mkdirall(dest)
if os.path.exists(util.syspath(dest)):
self._log.info('Skipping {0} (target file exists)',
util.displayable_path(album.artpath))
self._log.info(
"Skipping {0} (target file exists)",
util.displayable_path(album.artpath),
)
return
# Decide whether we need to resize the cover-art image.
resize = False
maxwidth = None
if self.config['album_art_maxwidth']:
maxwidth = self.config['album_art_maxwidth'].get(int)
size = ArtResizer.shared.get_size(album.artpath)
self._log.debug('image size: {}', size)
if size:
resize = size[0] > maxwidth
else:
self._log.warning('Could not get size of image (please see '
'documentation for dependencies).')
maxwidth = self._get_art_resize(album.artpath)
# Either copy or resize (while copying) the image.
if resize:
self._log.info('Resizing cover art from {0} to {1}',
util.displayable_path(album.artpath),
util.displayable_path(dest))
if maxwidth is not None:
self._log.info(
"Resizing cover art from {0} to {1}",
util.displayable_path(album.artpath),
util.displayable_path(dest),
)
if not pretend:
ArtResizer.shared.resize(maxwidth, album.artpath, dest)
else:
if pretend:
msg = 'ln' if hardlink else ('ln -s' if link else 'cp')
msg = "ln" if hardlink else ("ln -s" if link else "cp")
self._log.info('{2} {0} {1}',
util.displayable_path(album.artpath),
util.displayable_path(dest),
msg)
self._log.info(
"{2} {0} {1}",
util.displayable_path(album.artpath),
util.displayable_path(dest),
msg,
)
else:
msg = 'Hardlinking' if hardlink \
else ('Linking' if link else 'Copying')
msg = (
"Hardlinking"
if hardlink
else ("Linking" if link else "Copying")
)
self._log.info('{2} cover art from {0} to {1}',
util.displayable_path(album.artpath),
util.displayable_path(dest),
msg)
self._log.info(
"{2} cover art from {0} to {1}",
util.displayable_path(album.artpath),
util.displayable_path(dest),
msg,
)
if hardlink:
util.hardlink(album.artpath, dest)
elif link:
@@ -431,79 +552,92 @@ class ConvertPlugin(BeetsPlugin):
util.copy(album.artpath, dest)
def convert_func(self, lib, opts, args):
dest = opts.dest or self.config['dest'].get()
if not dest:
raise ui.UserError('no convert destination set')
dest = util.bytestring_path(dest)
threads = opts.threads or self.config['threads'].get(int)
path_formats = ui.get_path_formats(self.config['paths'] or None)
fmt = opts.format or self.config['format'].as_str().lower()
if opts.pretend is not None:
pretend = opts.pretend
else:
pretend = self.config['pretend'].get(bool)
if opts.hardlink is not None:
hardlink = opts.hardlink
link = False
elif opts.link is not None:
hardlink = False
link = opts.link
else:
hardlink = self.config['hardlink'].get(bool)
link = self.config['link'].get(bool)
(
dest,
threads,
path_formats,
fmt,
pretend,
hardlink,
link,
playlist,
) = self._get_opts_and_config(opts)
if opts.album:
albums = lib.albums(ui.decargs(args))
items = [i for a in albums for i in a.items()]
if not pretend:
for a in albums:
ui.print_(format(a, ''))
ui.print_(format(a, ""))
else:
items = list(lib.items(ui.decargs(args)))
if not pretend:
for i in items:
ui.print_(format(i, ''))
ui.print_(format(i, ""))
if not items:
self._log.error('Empty query result.')
self._log.error("Empty query result.")
return
if not (pretend or opts.yes or ui.input_yn("Convert? (Y/n)")):
return
if opts.album and self.config['copy_album_art']:
if opts.album and self.config["copy_album_art"]:
for album in albums:
self.copy_album_art(album, dest, path_formats, pretend,
link, hardlink)
self.copy_album_art(
album, dest, path_formats, pretend, link, hardlink
)
convert = [self.convert_item(dest,
opts.keep_new,
path_formats,
fmt,
pretend,
link,
hardlink)
for _ in range(threads)]
pipe = util.pipeline.Pipeline([iter(items), convert])
pipe.run_parallel()
self._parallel_convert(
dest,
opts.keep_new,
path_formats,
fmt,
pretend,
link,
hardlink,
threads,
items,
)
if playlist:
# Playlist paths are understood as relative to the dest directory.
pl_normpath = util.normpath(playlist)
pl_dir = os.path.dirname(pl_normpath)
self._log.info("Creating playlist file {0}", pl_normpath)
# Generates a list of paths to media files, ensures the paths are
# relative to the playlist's location and translates the unicode
# strings we get from item.destination to bytes.
items_paths = [
os.path.relpath(
util.bytestring_path(
item.destination(
basedir=dest,
path_formats=path_formats,
fragment=False,
)
),
pl_dir,
)
for item in items
]
if not pretend:
m3ufile = M3UFile(playlist)
m3ufile.set_contents(items_paths)
m3ufile.write()
def convert_on_import(self, lib, item):
"""Transcode a file automatically after it is imported into the
library.
"""
fmt = self.config['format'].as_str().lower()
fmt = self.config["format"].as_str().lower()
if should_transcode(item, fmt):
command, ext = get_format()
# Create a temporary file for the conversion.
tmpdir = self.config['tmpdir'].get()
tmpdir = self.config["tmpdir"].get()
if tmpdir:
tmpdir = util.py3_path(util.bytestring_path(tmpdir))
fd, dest = tempfile.mkstemp(util.py3_path(b'.' + ext), dir=tmpdir)
tmpdir = os.fsdecode(util.bytestring_path(tmpdir))
fd, dest = tempfile.mkstemp(os.fsdecode(b"." + ext), dir=tmpdir)
os.close(fd)
dest = util.bytestring_path(dest)
_temp_files.append(dest) # Delete the transcode later.
@@ -522,13 +656,107 @@ class ConvertPlugin(BeetsPlugin):
item.read() # Load new audio information data.
item.store()
if self.config['delete_originals']:
self._log.info('Removing original file {0}', source_path)
if self.config["delete_originals"]:
self._log.log(
logging.DEBUG if self.config["quiet"] else logging.INFO,
"Removing original file {0}",
source_path,
)
util.remove(source_path, False)
def _get_art_resize(self, artpath):
"""For a given piece of album art, determine whether or not it needs
to be resized according to the user's settings. If so, returns the
new size. If not, returns None.
"""
newwidth = None
if self.config["album_art_maxwidth"]:
maxwidth = self.config["album_art_maxwidth"].get(int)
size = ArtResizer.shared.get_size(artpath)
self._log.debug("image size: {}", size)
if size:
if size[0] > maxwidth:
newwidth = maxwidth
else:
self._log.warning(
"Could not get size of image (please see "
"documentation for dependencies)."
)
return newwidth
def _cleanup(self, task, session):
for path in task.old_paths:
if path in _temp_files:
if os.path.isfile(path):
if os.path.isfile(util.syspath(path)):
util.remove(path)
_temp_files.remove(path)
def _get_opts_and_config(self, opts):
"""Returns parameters needed for convert function.
Get parameters from command line if available,
default to config if not available.
"""
dest = opts.dest or self.config["dest"].get()
if not dest:
raise ui.UserError("no convert destination set")
dest = util.bytestring_path(dest)
threads = opts.threads or self.config["threads"].get(int)
path_formats = ui.get_path_formats(self.config["paths"] or None)
fmt = opts.format or self.config["format"].as_str().lower()
playlist = opts.playlist or self.config["playlist"].get()
if playlist is not None:
playlist = os.path.join(dest, util.bytestring_path(playlist))
if opts.pretend is not None:
pretend = opts.pretend
else:
pretend = self.config["pretend"].get(bool)
if opts.hardlink is not None:
hardlink = opts.hardlink
link = False
elif opts.link is not None:
hardlink = False
link = opts.link
else:
hardlink = self.config["hardlink"].get(bool)
link = self.config["link"].get(bool)
return (
dest,
threads,
path_formats,
fmt,
pretend,
hardlink,
link,
playlist,
)
def _parallel_convert(
self,
dest,
keep_new,
path_formats,
fmt,
pretend,
link,
hardlink,
threads,
items,
):
"""Run the convert_item function for every items on as many thread as
defined in threads
"""
convert = [
self.convert_item(
dest, keep_new, path_formats, fmt, pretend, link, hardlink
)
for _ in range(threads)
]
pipe = util.pipeline.Pipeline([iter(items), convert])
pipe.run_parallel()
+147 -55
View File
@@ -16,32 +16,66 @@
"""
import collections
import time
import unidecode
import requests
import unidecode
from beets import ui
from beets.autotag import AlbumInfo, TrackInfo
from beets.plugins import MetadataSourcePlugin, BeetsPlugin
from beets.dbcore import types
from beets.library import DateType
from beets.plugins import BeetsPlugin, MetadataSourcePlugin
from beets.util.id_extractors import deezer_id_regex
class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
data_source = 'Deezer'
data_source = "Deezer"
item_types = {
"deezer_track_rank": types.INTEGER,
"deezer_track_id": types.INTEGER,
"deezer_updated": DateType(),
}
# Base URLs for the Deezer API
# Documentation: https://developers.deezer.com/api/
search_url = 'https://api.deezer.com/search/'
album_url = 'https://api.deezer.com/album/'
track_url = 'https://api.deezer.com/track/'
search_url = "https://api.deezer.com/search/"
album_url = "https://api.deezer.com/album/"
track_url = "https://api.deezer.com/track/"
id_regex = {
'pattern': r'(^|deezer\.com/)([a-z]*/)?({}/)?(\d+)',
'match_group': 4,
}
id_regex = deezer_id_regex
def __init__(self):
super().__init__()
def commands(self):
"""Add beet UI commands to interact with Deezer."""
deezer_update_cmd = ui.Subcommand(
"deezerupdate", help=f"Update {self.data_source} rank"
)
def func(lib, opts, args):
items = lib.items(ui.decargs(args))
self.deezerupdate(items, ui.should_write())
deezer_update_cmd.func = func
return [deezer_update_cmd]
def fetch_data(self, url):
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
data = response.json()
except requests.exceptions.RequestException as e:
self._log.error("Error fetching data from {}\n Error: {}", url, e)
return None
if "error" in data:
self._log.error("Deezer API error: {}", data["error"]["message"])
return None
return data
def album_for_id(self, album_id):
"""Fetch an album by its Deezer ID or URL and return an
AlbumInfo object or None if the album is not found.
@@ -51,15 +85,20 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
:return: AlbumInfo object for album.
:rtype: beets.autotag.hooks.AlbumInfo or None
"""
deezer_id = self._get_id('album', album_id)
deezer_id = self._get_id("album", album_id, self.id_regex)
if deezer_id is None:
return None
album_data = self.fetch_data(self.album_url + deezer_id)
if album_data is None:
return None
contributors = album_data.get("contributors")
if contributors is not None:
artist, artist_id = self.get_artist(contributors)
else:
artist, artist_id = None, None
album_data = requests.get(self.album_url + deezer_id).json()
artist, artist_id = self.get_artist(album_data['contributors'])
release_date = album_data['release_date']
date_parts = [int(part) for part in release_date.split('-')]
release_date = album_data["release_date"]
date_parts = [int(part) for part in release_date.split("-")]
num_date_parts = len(date_parts)
if num_date_parts == 3:
@@ -76,12 +115,23 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
"Invalid `release_date` returned "
"by {} API: '{}'".format(self.data_source, release_date)
)
tracks_data = requests.get(
self.album_url + deezer_id + '/tracks'
).json()['data']
tracks_obj = self.fetch_data(self.album_url + deezer_id + "/tracks")
if tracks_obj is None:
return None
try:
tracks_data = tracks_obj["data"]
except KeyError:
self._log.debug("Error fetching album tracks for {}", deezer_id)
tracks_data = None
if not tracks_data:
return None
while "next" in tracks_obj:
tracks_obj = requests.get(
tracks_obj["next"],
timeout=10,
).json()
tracks_data.extend(tracks_obj["data"])
tracks = []
medium_totals = collections.defaultdict(int)
for i, track_data in enumerate(tracks_data, start=1):
@@ -93,22 +143,24 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
track.medium_total = medium_totals[track.medium]
return AlbumInfo(
album=album_data['title'],
album=album_data["title"],
album_id=deezer_id,
deezer_album_id=deezer_id,
artist=artist,
artist_credit=self.get_artist([album_data['artist']])[0],
artist_credit=self.get_artist([album_data["artist"]])[0],
artist_id=artist_id,
tracks=tracks,
albumtype=album_data['record_type'],
va=len(album_data['contributors']) == 1
and artist.lower() == 'various artists',
albumtype=album_data["record_type"],
va=len(album_data["contributors"]) == 1
and artist.lower() == "various artists",
year=year,
month=month,
day=day,
label=album_data['label'],
label=album_data["label"],
mediums=max(medium_totals.keys()),
data_source=self.data_source,
data_url=album_data['link'],
data_url=album_data["link"],
cover_art_url=album_data.get("cover_xl"),
)
def _get_track(self, track_data):
@@ -120,19 +172,23 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
:rtype: beets.autotag.hooks.TrackInfo
"""
artist, artist_id = self.get_artist(
track_data.get('contributors', [track_data['artist']])
track_data.get("contributors", [track_data["artist"]])
)
return TrackInfo(
title=track_data['title'],
track_id=track_data['id'],
title=track_data["title"],
track_id=track_data["id"],
deezer_track_id=track_data["id"],
isrc=track_data.get("isrc"),
artist=artist,
artist_id=artist_id,
length=track_data['duration'],
index=track_data['track_position'],
medium=track_data['disk_number'],
medium_index=track_data['track_position'],
length=track_data["duration"],
index=track_data.get("track_position"),
medium=track_data.get("disk_number"),
deezer_track_rank=track_data.get("rank"),
medium_index=track_data.get("track_position"),
data_source=self.data_source,
data_url=track_data['link'],
data_url=track_data["link"],
deezer_updated=time.time(),
)
def track_for_id(self, track_id=None, track_data=None):
@@ -149,29 +205,40 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
:rtype: beets.autotag.hooks.TrackInfo or None
"""
if track_data is None:
deezer_id = self._get_id('track', track_id)
deezer_id = self._get_id("track", track_id, self.id_regex)
if deezer_id is None:
return None
track_data = requests.get(self.track_url + deezer_id).json()
track_data = self.fetch_data(self.track_url + deezer_id)
if track_data is None:
return None
track = self._get_track(track_data)
# Get album's tracks to set `track.index` (position on the entire
# release) and `track.medium_total` (total number of tracks on
# the track's disc).
album_tracks_data = requests.get(
self.album_url + str(track_data['album']['id']) + '/tracks'
).json()['data']
album_tracks_obj = self.fetch_data(
self.album_url + str(track_data["album"]["id"]) + "/tracks"
)
if album_tracks_obj is None:
return None
try:
album_tracks_data = album_tracks_obj["data"]
except KeyError:
self._log.debug(
"Error fetching album tracks for {}", track_data["album"]["id"]
)
return None
medium_total = 0
for i, track_data in enumerate(album_tracks_data, start=1):
if track_data['disk_number'] == track.medium:
if track_data["disk_number"] == track.medium:
medium_total += 1
if track_data['id'] == track.track_id:
if track_data["id"] == track.track_id:
track.index = i
track.medium_total = medium_total
return track
@staticmethod
def _construct_search_query(filters=None, keywords=''):
def _construct_search_query(filters=None, keywords=""):
"""Construct a query string with the specified filters and keywords to
be provided to the Deezer Search API
(https://developers.deezer.com/api/search).
@@ -185,14 +252,14 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
"""
query_components = [
keywords,
' '.join(f'{k}:"{v}"' for k, v in filters.items()),
" ".join(f'{k}:"{v}"' for k, v in filters.items()),
]
query = ' '.join([q for q in query_components if q])
query = " ".join([q for q in query_components if q])
if not isinstance(query, str):
query = query.decode('utf8')
query = query.decode("utf8")
return unidecode.unidecode(query)
def _search_api(self, query_type, filters=None, keywords=''):
def _search_api(self, query_type, filters=None, keywords=""):
"""Query the Deezer Search API for the specified ``keywords``, applying
the provided ``filters``.
@@ -208,19 +275,17 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
if no search results are returned.
:rtype: dict or None
"""
query = self._construct_search_query(
keywords=keywords, filters=filters
)
query = self._construct_search_query(keywords=keywords, filters=filters)
if not query:
return None
self._log.debug(
f"Searching {self.data_source} for '{query}'"
)
self._log.debug(f"Searching {self.data_source} for '{query}'")
response = requests.get(
self.search_url + query_type, params={'q': query}
self.search_url + query_type,
params={"q": query},
timeout=10,
)
response.raise_for_status()
response_data = response.json().get('data', [])
response_data = response.json().get("data", [])
self._log.debug(
"Found {} result(s) from {} for '{}'",
len(response_data),
@@ -228,3 +293,30 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
query,
)
return response_data
def deezerupdate(self, items, write):
"""Obtain rank information from Deezer."""
for index, item in enumerate(items, start=1):
self._log.info(
"Processing {}/{} tracks - {} ", index, len(items), item
)
try:
deezer_track_id = item.deezer_track_id
except AttributeError:
self._log.debug("No deezer_track_id present for: {}", item)
continue
try:
rank = self.fetch_data(
f"{self.track_url}{deezer_track_id}"
).get("rank")
self._log.debug(
"Deezer track: {} has {} rank", deezer_track_id, rank
)
except Exception as e:
self._log.debug("Invalid Deezer track_id: {}", e)
continue
item.deezer_track_rank = int(rank)
item.store()
item.deezer_updated = time.time()
if write:
item.try_write()
+354 -217
View File
@@ -16,62 +16,87 @@
python3-discogs-client library.
"""
import beets.ui
from beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import MetadataSourcePlugin, BeetsPlugin, get_distance
import confuse
from discogs_client import Release, Master, Client
from discogs_client.exceptions import DiscogsAPIError
from requests.exceptions import ConnectionError
import http.client
import beets
import re
import time
import json
import socket
import os
import re
import socket
import time
import traceback
from string import ascii_lowercase
import confuse
from discogs_client import Client, Master, Release
from discogs_client import __version__ as dc_string
from discogs_client.exceptions import DiscogsAPIError
from requests.exceptions import ConnectionError
USER_AGENT = f'beets/{beets.__version__} +https://beets.io/'
API_KEY = 'rAzVUQYRaoFjeBjyWuWZ'
API_SECRET = 'plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy'
import beets
import beets.ui
from beets import config
from beets.autotag.hooks import AlbumInfo, TrackInfo, string_dist
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
from beets.util.id_extractors import extract_discogs_id_regex
USER_AGENT = f"beets/{beets.__version__} +https://beets.io/"
API_KEY = "rAzVUQYRaoFjeBjyWuWZ"
API_SECRET = "plxtUTqoCzwxZpqdPysCwGuBSmZNdZVy"
# Exceptions that discogs_client should really handle but does not.
CONNECTION_ERRORS = (ConnectionError, socket.error, http.client.HTTPException,
ValueError, # JSON decoding raises a ValueError.
DiscogsAPIError)
CONNECTION_ERRORS = (
ConnectionError,
socket.error,
http.client.HTTPException,
ValueError, # JSON decoding raises a ValueError.
DiscogsAPIError,
)
class DiscogsPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'apikey': API_KEY,
'apisecret': API_SECRET,
'tokenfile': 'discogs_token.json',
'source_weight': 0.5,
'user_token': '',
'separator': ', ',
'index_tracks': False,
})
self.config['apikey'].redact = True
self.config['apisecret'].redact = True
self.config['user_token'].redact = True
self.check_discogs_client()
self.config.add(
{
"apikey": API_KEY,
"apisecret": API_SECRET,
"tokenfile": "discogs_token.json",
"source_weight": 0.5,
"user_token": "",
"separator": ", ",
"index_tracks": False,
"append_style_genre": False,
}
)
self.config["apikey"].redact = True
self.config["apisecret"].redact = True
self.config["user_token"].redact = True
self.discogs_client = None
self.register_listener('import_begin', self.setup)
self.register_listener("import_begin", self.setup)
def check_discogs_client(self):
"""Ensure python3-discogs-client version >= 2.3.15"""
dc_min_version = [2, 3, 15]
dc_version = [int(elem) for elem in dc_string.split(".")]
min_len = min(len(dc_version), len(dc_min_version))
gt_min = [
(elem > elem_min)
for elem, elem_min in zip(
dc_version[:min_len], dc_min_version[:min_len]
)
]
if True not in gt_min:
self._log.warning(
"python3-discogs-client version should be >= 2.3.15"
)
def setup(self, session=None):
"""Create the `discogs_client` field. Authenticate if necessary.
"""
c_key = self.config['apikey'].as_str()
c_secret = self.config['apisecret'].as_str()
"""Create the `discogs_client` field. Authenticate if necessary."""
c_key = self.config["apikey"].as_str()
c_secret = self.config["apisecret"].as_str()
# Try using a configured user token (bypassing OAuth login).
user_token = self.config['user_token'].as_str()
user_token = self.config["user_token"].as_str()
if user_token:
# The rate limit for authenticated users goes up to 60
# requests per minute.
@@ -86,22 +111,19 @@ class DiscogsPlugin(BeetsPlugin):
# No token yet. Generate one.
token, secret = self.authenticate(c_key, c_secret)
else:
token = tokendata['token']
secret = tokendata['secret']
token = tokendata["token"]
secret = tokendata["secret"]
self.discogs_client = Client(USER_AGENT, c_key, c_secret,
token, secret)
self.discogs_client = Client(USER_AGENT, c_key, c_secret, token, secret)
def reset_auth(self):
"""Delete token file & redo the auth steps.
"""
"""Delete token file & redo the auth steps."""
os.remove(self._tokenfile())
self.setup()
def _tokenfile(self):
"""Get the path to the JSON file for storing the OAuth token.
"""
return self.config['tokenfile'].get(confuse.Filename(in_app_dir=True))
"""Get the path to the JSON file for storing the OAuth token."""
return self.config["tokenfile"].get(confuse.Filename(in_app_dir=True))
def authenticate(self, c_key, c_secret):
# Get the link for the OAuth page.
@@ -109,8 +131,8 @@ class DiscogsPlugin(BeetsPlugin):
try:
_, _, url = auth_client.get_authorize_url()
except CONNECTION_ERRORS as e:
self._log.debug('connection error: {0}', e)
raise beets.ui.UserError('communication with Discogs failed')
self._log.debug("connection error: {0}", e)
raise beets.ui.UserError("communication with Discogs failed")
beets.ui.print_("To authenticate with Discogs, visit:")
beets.ui.print_(url)
@@ -120,34 +142,28 @@ class DiscogsPlugin(BeetsPlugin):
try:
token, secret = auth_client.get_access_token(code)
except DiscogsAPIError:
raise beets.ui.UserError('Discogs authorization failed')
raise beets.ui.UserError("Discogs authorization failed")
except CONNECTION_ERRORS as e:
self._log.debug('connection error: {0}', e)
raise beets.ui.UserError('Discogs token request failed')
self._log.debug("connection error: {0}", e)
raise beets.ui.UserError("Discogs token request failed")
# Save the token for later use.
self._log.debug('Discogs token {0}, secret {1}', token, secret)
with open(self._tokenfile(), 'w') as f:
json.dump({'token': token, 'secret': secret}, f)
self._log.debug("Discogs token {0}, secret {1}", token, secret)
with open(self._tokenfile(), "w") as f:
json.dump({"token": token, "secret": secret}, f)
return token, secret
def album_distance(self, items, album_info, mapping):
"""Returns the album distance.
"""
"""Returns the album distance."""
return get_distance(
data_source='Discogs',
info=album_info,
config=self.config
data_source="Discogs", info=album_info, config=self.config
)
def track_distance(self, item, track_info):
"""Returns the track distance.
"""
"""Returns the track distance."""
return get_distance(
data_source='Discogs',
info=track_info,
config=self.config
data_source="Discogs", info=track_info, config=self.config
)
def candidates(self, items, artist, album, va_likely, extra_tags=None):
@@ -157,48 +173,110 @@ class DiscogsPlugin(BeetsPlugin):
if not self.discogs_client:
return
if not album and not artist:
self._log.debug(
"Skipping Discogs query. Files missing album and "
"artist tags."
)
return []
if va_likely:
query = album
else:
query = f'{artist} {album}'
query = f"{artist} {album}"
try:
return self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug('API Error: {0} (query: {1})', e, query)
self._log.debug("API Error: {0} (query: {1})", e, query)
if e.status_code == 401:
self.reset_auth()
return self.candidates(items, artist, album, va_likely)
else:
return []
except CONNECTION_ERRORS:
self._log.debug('Connection error in album search', exc_info=True)
self._log.debug("Connection error in album search", exc_info=True)
return []
@staticmethod
def extract_release_id_regex(album_id):
"""Returns the Discogs_id or None."""
# Discogs-IDs are simple integers. In order to avoid confusion with
# other metadata plugins, we only look for very specific formats of the
# input string:
# - plain integer, optionally wrapped in brackets and prefixed by an
# 'r', as this is how discogs displays the release ID on its webpage.
# - legacy url format: discogs.com/<name of release>/release/<id>
# - current url format: discogs.com/release/<id>-<name of release>
# See #291, #4080 and #4085 for the discussions leading up to these
# patterns.
# Regex has been tested here https://regex101.com/r/wyLdB4/2
def get_track_from_album_by_title(
self, album_info, title, dist_threshold=0.3
):
def compare_func(track_info):
track_title = getattr(track_info, "title", None)
dist = string_dist(track_title, title)
return track_title and dist < dist_threshold
for pattern in [
r'^\[?r?(?P<id>\d+)\]?$',
r'discogs\.com/release/(?P<id>\d+)-',
r'discogs\.com/[^/]+/release/(?P<id>\d+)',
]:
match = re.search(pattern, album_id)
if match:
return int(match.group('id'))
return self.get_track_from_album(album_info, compare_func)
def get_track_from_album(self, album_info, compare_func):
"""Return the first track of the release where `compare_func` returns
true.
:return: TrackInfo object.
:rtype: beets.autotag.hooks.TrackInfo
"""
if not album_info:
return None
for track_info in album_info.tracks:
# check for matching position
if not compare_func(track_info):
continue
# attach artist info if not provided
if not track_info["artist"]:
track_info["artist"] = album_info.artist
track_info["artist_id"] = album_info.artist_id
# attach album info
track_info["album"] = album_info.album
return track_info
return None
def item_candidates(self, item, artist, title):
"""Returns a list of TrackInfo objects for Search API results
matching ``title`` and ``artist``.
:param item: Singleton item to be matched.
:type item: beets.library.Item
:param artist: The artist of the track to be matched.
:type artist: str
:param title: The title of the track to be matched.
:type title: str
:return: Candidate TrackInfo objects.
:rtype: list[beets.autotag.hooks.TrackInfo]
"""
if not self.discogs_client:
return []
if not artist and not title:
self._log.debug(
"Skipping Discogs query. File missing artist and " "title tags."
)
return []
query = f"{artist} {title}"
try:
albums = self.get_albums(query)
except DiscogsAPIError as e:
self._log.debug("API Error: {0} (query: {1})", e, query)
if e.status_code == 401:
self.reset_auth()
return self.item_candidates(item, artist, title)
else:
return []
except CONNECTION_ERRORS:
self._log.debug("Connection error in track search", exc_info=True)
candidates = []
for album_cur in albums:
self._log.debug("searching within album {0}", album_cur.album)
track_result = self.get_track_from_album_by_title(
album_cur, item["title"]
)
if track_result:
candidates.append(track_result)
# first 10 results, don't overwhelm with options
return candidates[:10]
def album_for_id(self, album_id):
"""Fetches an album by its Discogs ID and returns an AlbumInfo object
or None if the album is not found.
@@ -206,83 +284,90 @@ class DiscogsPlugin(BeetsPlugin):
if not self.discogs_client:
return
self._log.debug('Searching for release {0}', album_id)
self._log.debug("Searching for release {0}", album_id)
discogs_id = self.extract_release_id_regex(album_id)
discogs_id = extract_discogs_id_regex(album_id)
if not discogs_id:
return None
result = Release(self.discogs_client, {'id': discogs_id})
result = Release(self.discogs_client, {"id": discogs_id})
# Try to obtain title to verify that we indeed have a valid Release
try:
getattr(result, 'title')
getattr(result, "title")
except DiscogsAPIError as e:
if e.status_code != 404:
self._log.debug('API Error: {0} (query: {1})', e,
result.data['resource_url'])
self._log.debug(
"API Error: {0} (query: {1})",
e,
result.data["resource_url"],
)
if e.status_code == 401:
self.reset_auth()
return self.album_for_id(album_id)
return None
except CONNECTION_ERRORS:
self._log.debug('Connection error in album lookup',
exc_info=True)
self._log.debug("Connection error in album lookup", exc_info=True)
return None
return self.get_album_info(result)
def get_albums(self, query):
"""Returns a list of AlbumInfo objects for a discogs search query.
"""
"""Returns a list of AlbumInfo objects for a discogs search query."""
# Strip non-word characters from query. Things like "!" and "-" can
# cause a query to return no results, even if they match the artist or
# album title. Use `re.UNICODE` flag to avoid stripping non-english
# word characters.
query = re.sub(r'(?u)\W+', ' ', query)
query = re.sub(r"(?u)\W+", " ", query)
# Strip medium information from query, Things like "CD1" and "disk 1"
# can also negate an otherwise positive result.
query = re.sub(r'(?i)\b(CD|disc)\s*\d+', '', query)
query = re.sub(r"(?i)\b(CD|disc|vinyl)\s*\d+", "", query)
try:
releases = self.discogs_client.search(query,
type='release').page(1)
releases = self.discogs_client.search(query, type="release").page(1)
except CONNECTION_ERRORS:
self._log.debug("Communication error while searching for {0!r}",
query, exc_info=True)
self._log.debug(
"Communication error while searching for {0!r}",
query,
exc_info=True,
)
return []
return [album for album in map(self.get_album_info, releases[:5])
if album]
return [
album for album in map(self.get_album_info, releases[:5]) if album
]
def get_master_year(self, master_id):
"""Fetches a master release given its Discogs ID and returns its year
or None if the master release is not found.
"""
self._log.debug('Searching for master release {0}', master_id)
result = Master(self.discogs_client, {'id': master_id})
self._log.debug("Searching for master release {0}", master_id)
result = Master(self.discogs_client, {"id": master_id})
try:
year = result.fetch('year')
year = result.fetch("year")
return year
except DiscogsAPIError as e:
if e.status_code != 404:
self._log.debug('API Error: {0} (query: {1})', e,
result.data['resource_url'])
self._log.debug(
"API Error: {0} (query: {1})",
e,
result.data["resource_url"],
)
if e.status_code == 401:
self.reset_auth()
return self.get_master_year(master_id)
return None
except CONNECTION_ERRORS:
self._log.debug('Connection error in master release lookup',
exc_info=True)
self._log.debug(
"Connection error in master release lookup", exc_info=True
)
return None
def get_album_info(self, result):
"""Returns an AlbumInfo object for a discogs Release object.
"""
"""Returns an AlbumInfo object for a discogs Release object."""
# Explicitly reload the `Release` fields, as they might not be yet
# present if the result is from a `discogs_client.search()`.
if not result.data.get('artists'):
if not result.data.get("artists"):
result.refresh()
# Sanity check for required fields. The list of required fields is
@@ -290,99 +375,138 @@ class DiscogsPlugin(BeetsPlugin):
# lacking some of these fields. This function expects at least:
# `artists` (>0), `title`, `id`, `tracklist` (>0)
# https://www.discogs.com/help/doc/submission-guidelines-general-rules
if not all([result.data.get(k) for k in ['artists', 'title', 'id',
'tracklist']]):
if not all(
[
result.data.get(k)
for k in ["artists", "title", "id", "tracklist"]
]
):
self._log.warning("Release does not contain the required fields")
return None
artist, artist_id = MetadataSourcePlugin.get_artist(
[a.data for a in result.artists]
[a.data for a in result.artists], join_key="join"
)
album = re.sub(r' +', ' ', result.title)
album_id = result.data['id']
album = re.sub(r" +", " ", result.title)
album_id = result.data["id"]
# Use `.data` to access the tracklist directly instead of the
# convenient `.tracklist` property, which will strip out useful artist
# information and leave us with skeleton `Artist` objects that will
# each make an API call just to get the same data back.
tracks = self.get_tracks(result.data['tracklist'])
tracks = self.get_tracks(result.data["tracklist"])
# Extract information for the optional AlbumInfo fields, if possible.
va = result.data['artists'][0].get('name', '').lower() == 'various'
year = result.data.get('year')
va = result.data["artists"][0].get("name", "").lower() == "various"
year = result.data.get("year")
mediums = [t.medium for t in tracks]
country = result.data.get('country')
data_url = result.data.get('uri')
style = self.format(result.data.get('styles'))
genre = self.format(result.data.get('genres'))
discogs_albumid = self.extract_release_id(result.data.get('uri'))
country = result.data.get("country")
data_url = result.data.get("uri")
style = self.format(result.data.get("styles"))
base_genre = self.format(result.data.get("genres"))
if self.config["append_style_genre"] and style:
genre = self.config["separator"].as_str().join([base_genre, style])
else:
genre = base_genre
discogs_albumid = extract_discogs_id_regex(result.data.get("uri"))
# Extract information for the optional AlbumInfo fields that are
# contained on nested discogs fields.
albumtype = media = label = catalogno = labelid = None
if result.data.get('formats'):
albumtype = ', '.join(
result.data['formats'][0].get('descriptions', [])) or None
media = result.data['formats'][0]['name']
if result.data.get('labels'):
label = result.data['labels'][0].get('name')
catalogno = result.data['labels'][0].get('catno')
labelid = result.data['labels'][0].get('id')
if result.data.get("formats"):
albumtype = (
", ".join(result.data["formats"][0].get("descriptions", []))
or None
)
media = result.data["formats"][0]["name"]
if result.data.get("labels"):
label = result.data["labels"][0].get("name")
catalogno = result.data["labels"][0].get("catno")
labelid = result.data["labels"][0].get("id")
cover_art_url = self.select_cover_art(result)
# Additional cleanups (various artists name, catalog number, media).
if va:
artist = config['va_name'].as_str()
if catalogno == 'none':
artist = config["va_name"].as_str()
if catalogno == "none":
catalogno = None
# Explicitly set the `media` for the tracks, since it is expected by
# `autotag.apply_metadata`, and set `medium_total`.
for track in tracks:
track.media = media
track.medium_total = mediums.count(track.medium)
if not track.artist: # get_track_info often fails to find artist
track.artist = artist
if not track.artist_id:
track.artist_id = artist_id
# Discogs does not have track IDs. Invent our own IDs as proposed
# in #2336.
track.track_id = str(album_id) + "-" + track.track_alt
track.data_url = data_url
track.data_source = "Discogs"
# Retrieve master release id (returns None if there isn't one).
master_id = result.data.get('master_id')
master_id = result.data.get("master_id")
# Assume `original_year` is equal to `year` for releases without
# a master release, otherwise fetch the master release.
original_year = self.get_master_year(master_id) if master_id else year
return AlbumInfo(album=album, album_id=album_id, artist=artist,
artist_id=artist_id, tracks=tracks,
albumtype=albumtype, va=va, year=year,
label=label, mediums=len(set(mediums)),
releasegroup_id=master_id, catalognum=catalogno,
country=country, style=style, genre=genre,
media=media, original_year=original_year,
data_source='Discogs', data_url=data_url,
discogs_albumid=discogs_albumid,
discogs_labelid=labelid, discogs_artistid=artist_id)
return AlbumInfo(
album=album,
album_id=album_id,
artist=artist,
artist_id=artist_id,
tracks=tracks,
albumtype=albumtype,
va=va,
year=year,
label=label,
mediums=len(set(mediums)),
releasegroup_id=master_id,
catalognum=catalogno,
country=country,
style=style,
genre=genre,
media=media,
original_year=original_year,
data_source="Discogs",
data_url=data_url,
discogs_albumid=discogs_albumid,
discogs_labelid=labelid,
discogs_artistid=artist_id,
cover_art_url=cover_art_url,
)
def select_cover_art(self, result):
"""Returns the best candidate image, if any, from a Discogs `Release` object."""
if result.data.get("images") and len(result.data.get("images")) > 0:
# The first image in this list appears to be the one displayed first
# on the release page - even if it is not flagged as `type: "primary"` - and
# so it is the best candidate for the cover art.
return result.data.get("images")[0].get("uri")
return None
def format(self, classification):
if classification:
return self.config['separator'].as_str() \
.join(sorted(classification))
else:
return None
def extract_release_id(self, uri):
if uri:
return uri.split("/")[-1]
return (
self.config["separator"].as_str().join(sorted(classification))
)
else:
return None
def get_tracks(self, tracklist):
"""Returns a list of TrackInfo objects for a discogs tracklist.
"""
"""Returns a list of TrackInfo objects for a discogs tracklist."""
try:
clean_tracklist = self.coalesce_tracks(tracklist)
except Exception as exc:
# FIXME: this is an extra precaution for making sure there are no
# side effects after #2222. It should be removed after further
# testing.
self._log.debug('{}', traceback.format_exc())
self._log.error('uncaught exception in coalesce_tracks: {}', exc)
self._log.debug("{}", traceback.format_exc())
self._log.error("uncaught exception in coalesce_tracks: {}", exc)
clean_tracklist = tracklist
tracks = []
index_tracks = {}
@@ -391,7 +515,7 @@ class DiscogsPlugin(BeetsPlugin):
divisions, next_divisions = [], []
for track in clean_tracklist:
# Only real tracks have `position`. Otherwise, it's an index track.
if track['position']:
if track["position"]:
index += 1
if next_divisions:
# End of a block of index tracks: update the current
@@ -399,17 +523,17 @@ class DiscogsPlugin(BeetsPlugin):
divisions += next_divisions
del next_divisions[:]
track_info = self.get_track_info(track, index, divisions)
track_info.track_alt = track['position']
track_info.track_alt = track["position"]
tracks.append(track_info)
else:
next_divisions.append(track['title'])
next_divisions.append(track["title"])
# We expect new levels of division at the beginning of the
# tracklist (and possibly elsewhere).
try:
divisions.pop()
except IndexError:
pass
index_tracks[index + 1] = track['title']
index_tracks[index + 1] = track["title"]
# Fix up medium and medium_index for each track. Discogs position is
# unreliable, but tracks are in order.
@@ -423,7 +547,7 @@ class DiscogsPlugin(BeetsPlugin):
m = sorted({track.medium.lower() for track in tracks})
# If all track.medium are single consecutive letters, assume it is
# a 2-sided medium.
if ''.join(m) in ascii_lowercase:
if "".join(m) in ascii_lowercase:
sides_per_medium = 2
for track in tracks:
@@ -433,10 +557,15 @@ class DiscogsPlugin(BeetsPlugin):
# are the track index, not the medium.
# side_count is the number of mediums or medium sides (in the case
# of two-sided mediums) that were seen before.
medium_is_index = track.medium and not track.medium_index and (
len(track.medium) != 1 or
# Not within standard incremental medium values (A, B, C, ...).
ord(track.medium) - 64 != side_count + 1
medium_is_index = (
track.medium
and not track.medium_index
and (
len(track.medium) != 1
or
# Not within standard incremental medium values (A, B, C, ...).
ord(track.medium) - 64 != side_count + 1
)
)
if not medium_is_index and medium != track.medium:
@@ -473,51 +602,54 @@ class DiscogsPlugin(BeetsPlugin):
title for the merged track is the one from the previous index track,
if present; otherwise it is a combination of the subtracks titles.
"""
def add_merged_subtracks(tracklist, subtracks):
"""Modify `tracklist` in place, merging a list of `subtracks` into
a single track into `tracklist`."""
# Calculate position based on first subtrack, without subindex.
idx, medium_idx, sub_idx = \
self.get_track_index(subtracks[0]['position'])
position = '{}{}'.format(idx or '', medium_idx or '')
idx, medium_idx, sub_idx = self.get_track_index(
subtracks[0]["position"]
)
position = "{}{}".format(idx or "", medium_idx or "")
if tracklist and not tracklist[-1]['position']:
if tracklist and not tracklist[-1]["position"]:
# Assume the previous index track contains the track title.
if sub_idx:
# "Convert" the track title to a real track, discarding the
# subtracks assuming they are logical divisions of a
# physical track (12.2.9 Subtracks).
tracklist[-1]['position'] = position
tracklist[-1]["position"] = position
else:
# Promote the subtracks to real tracks, discarding the
# index track, assuming the subtracks are physical tracks.
index_track = tracklist.pop()
# Fix artists when they are specified on the index track.
if index_track.get('artists'):
if index_track.get("artists"):
for subtrack in subtracks:
if not subtrack.get('artists'):
subtrack['artists'] = index_track['artists']
if not subtrack.get("artists"):
subtrack["artists"] = index_track["artists"]
# Concatenate index with track title when index_tracks
# option is set
if self.config['index_tracks']:
if self.config["index_tracks"]:
for subtrack in subtracks:
subtrack['title'] = '{}: {}'.format(
index_track['title'], subtrack['title'])
subtrack["title"] = "{}: {}".format(
index_track["title"], subtrack["title"]
)
tracklist.extend(subtracks)
else:
# Merge the subtracks, pick a title, and append the new track.
track = subtracks[0].copy()
track['title'] = ' / '.join([t['title'] for t in subtracks])
track["title"] = " / ".join([t["title"] for t in subtracks])
tracklist.append(track)
# Pre-process the tracklist, trying to identify subtracks.
subtracks = []
tracklist = []
prev_subindex = ''
prev_subindex = ""
for track in raw_tracklist:
# Regular subtrack (track with subindex).
if track['position']:
_, _, subindex = self.get_track_index(track['position'])
if track["position"]:
_, _, subindex = self.get_track_index(track["position"])
if subindex:
if subindex.rjust(len(raw_tracklist)) > prev_subindex:
# Subtrack still part of the current main track.
@@ -530,17 +662,17 @@ class DiscogsPlugin(BeetsPlugin):
continue
# Index track with nested sub_tracks.
if not track['position'] and 'sub_tracks' in track:
if not track["position"] and "sub_tracks" in track:
# Append the index track, assuming it contains the track title.
tracklist.append(track)
add_merged_subtracks(tracklist, track['sub_tracks'])
add_merged_subtracks(tracklist, track["sub_tracks"])
continue
# Regular track or index track without nested sub_tracks.
if subtracks:
add_merged_subtracks(tracklist, subtracks)
subtracks = []
prev_subindex = ''
prev_subindex = ""
tracklist.append(track)
# Merge and add the remaining subtracks, if any.
@@ -550,22 +682,28 @@ class DiscogsPlugin(BeetsPlugin):
return tracklist
def get_track_info(self, track, index, divisions):
"""Returns a TrackInfo object for a discogs track.
"""
title = track['title']
if self.config['index_tracks']:
prefix = ', '.join(divisions)
"""Returns a TrackInfo object for a discogs track."""
title = track["title"]
if self.config["index_tracks"]:
prefix = ", ".join(divisions)
if prefix:
title = f'{prefix}: {title}'
title = f"{prefix}: {title}"
track_id = None
medium, medium_index, _ = self.get_track_index(track['position'])
medium, medium_index, _ = self.get_track_index(track["position"])
artist, artist_id = MetadataSourcePlugin.get_artist(
track.get('artists', [])
track.get("artists", []), join_key="join"
)
length = self.get_track_length(track["duration"])
return TrackInfo(
title=title,
track_id=track_id,
artist=artist,
artist_id=artist_id,
length=length,
index=index,
medium=medium,
medium_index=medium_index,
)
length = self.get_track_length(track['duration'])
return TrackInfo(title=title, track_id=track_id, artist=artist,
artist_id=artist_id, length=length, index=index,
medium=medium, medium_index=medium_index)
def get_track_index(self, position):
"""Returns the medium, medium index and subtrack index for a discogs
@@ -573,34 +711,33 @@ class DiscogsPlugin(BeetsPlugin):
# Match the standard Discogs positions (12.2.9), which can have several
# forms (1, 1-1, A1, A1.1, A1a, ...).
match = re.match(
r'^(.*?)' # medium: everything before medium_index.
r'(\d*?)' # medium_index: a number at the end of
# `position`, except if followed by a subtrack
# index.
# subtrack_index: can only be matched if medium
# or medium_index have been matched, and can be
r'((?<=\w)\.[\w]+' # - a dot followed by a string (A.1, 2.A)
r'|(?<=\d)[A-Z]+' # - a string that follows a number (1A, B2a)
r')?'
r'$',
position.upper()
r"^(.*?)" # medium: everything before medium_index.
r"(\d*?)" # medium_index: a number at the end of
# `position`, except if followed by a subtrack
# index.
# subtrack_index: can only be matched if medium
# or medium_index have been matched, and can be
r"((?<=\w)\.[\w]+" # - a dot followed by a string (A.1, 2.A)
r"|(?<=\d)[A-Z]+" # - a string that follows a number (1A, B2a)
r")?"
r"$",
position.upper(),
)
if match:
medium, index, subindex = match.groups()
if subindex and subindex.startswith('.'):
if subindex and subindex.startswith("."):
subindex = subindex[1:]
else:
self._log.debug('Invalid position: {0}', position)
self._log.debug("Invalid position: {0}", position)
medium = index = subindex = None
return medium or None, index or None, subindex or None
def get_track_length(self, duration):
"""Returns the track length in seconds for a discogs duration.
"""
"""Returns the track length in seconds for a discogs duration."""
try:
length = time.strptime(duration, '%M:%S')
length = time.strptime(duration, "%M:%S")
except ValueError:
return None
return length.tm_min * 60 + length.tm_sec
+186 -135
View File
@@ -15,122 +15,150 @@
"""List duplicate tracks or albums.
"""
import os
import shlex
from beets.library import Album, Item
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_, Subcommand, UserError
from beets.util import command_output, displayable_path, subprocess, \
bytestring_path, MoveOperation, decode_commandline_path
from beets.library import Item, Album
from beets.ui import Subcommand, UserError, decargs, print_
from beets.util import (
MoveOperation,
bytestring_path,
command_output,
displayable_path,
subprocess,
)
PLUGIN = 'duplicates'
PLUGIN = "duplicates"
class DuplicatesPlugin(BeetsPlugin):
"""List duplicate tracks or albums
"""
"""List duplicate tracks or albums"""
def __init__(self):
super().__init__()
self.config.add({
'album': False,
'checksum': '',
'copy': '',
'count': False,
'delete': False,
'format': '',
'full': False,
'keys': [],
'merge': False,
'move': '',
'path': False,
'tiebreak': {},
'strict': False,
'tag': '',
})
self.config.add(
{
"album": False,
"checksum": "",
"copy": "",
"count": False,
"delete": False,
"format": "",
"full": False,
"keys": [],
"merge": False,
"move": "",
"path": False,
"tiebreak": {},
"strict": False,
"tag": "",
}
)
self._command = Subcommand('duplicates',
help=__doc__,
aliases=['dup'])
self._command = Subcommand("duplicates", help=__doc__, aliases=["dup"])
self._command.parser.add_option(
'-c', '--count', dest='count',
action='store_true',
help='show duplicate counts',
"-c",
"--count",
dest="count",
action="store_true",
help="show duplicate counts",
)
self._command.parser.add_option(
'-C', '--checksum', dest='checksum',
action='store', metavar='PROG',
help='report duplicates based on arbitrary command',
"-C",
"--checksum",
dest="checksum",
action="store",
metavar="PROG",
help="report duplicates based on arbitrary command",
)
self._command.parser.add_option(
'-d', '--delete', dest='delete',
action='store_true',
help='delete items from library and disk',
"-d",
"--delete",
dest="delete",
action="store_true",
help="delete items from library and disk",
)
self._command.parser.add_option(
'-F', '--full', dest='full',
action='store_true',
help='show all versions of duplicate tracks or albums',
"-F",
"--full",
dest="full",
action="store_true",
help="show all versions of duplicate tracks or albums",
)
self._command.parser.add_option(
'-s', '--strict', dest='strict',
action='store_true',
help='report duplicates only if all attributes are set',
"-s",
"--strict",
dest="strict",
action="store_true",
help="report duplicates only if all attributes are set",
)
self._command.parser.add_option(
'-k', '--key', dest='keys',
action='append', metavar='KEY',
help='report duplicates based on keys (use multiple times)',
"-k",
"--key",
dest="keys",
action="append",
metavar="KEY",
help="report duplicates based on keys (use multiple times)",
)
self._command.parser.add_option(
'-M', '--merge', dest='merge',
action='store_true',
help='merge duplicate items',
"-M",
"--merge",
dest="merge",
action="store_true",
help="merge duplicate items",
)
self._command.parser.add_option(
'-m', '--move', dest='move',
action='store', metavar='DEST',
help='move items to dest',
"-m",
"--move",
dest="move",
action="store",
metavar="DEST",
help="move items to dest",
)
self._command.parser.add_option(
'-o', '--copy', dest='copy',
action='store', metavar='DEST',
help='copy items to dest',
"-o",
"--copy",
dest="copy",
action="store",
metavar="DEST",
help="copy items to dest",
)
self._command.parser.add_option(
'-t', '--tag', dest='tag',
action='store',
help='tag matched items with \'k=v\' attribute',
"-t",
"--tag",
dest="tag",
action="store",
help="tag matched items with 'k=v' attribute",
)
self._command.parser.add_all_common_options()
def commands(self):
def _dup(lib, opts, args):
self.config.set_args(opts)
album = self.config['album'].get(bool)
checksum = self.config['checksum'].get(str)
copy = bytestring_path(self.config['copy'].as_str())
count = self.config['count'].get(bool)
delete = self.config['delete'].get(bool)
fmt = self.config['format'].get(str)
full = self.config['full'].get(bool)
keys = self.config['keys'].as_str_seq()
merge = self.config['merge'].get(bool)
move = bytestring_path(self.config['move'].as_str())
path = self.config['path'].get(bool)
tiebreak = self.config['tiebreak'].get(dict)
strict = self.config['strict'].get(bool)
tag = self.config['tag'].get(str)
album = self.config["album"].get(bool)
checksum = self.config["checksum"].get(str)
copy = bytestring_path(self.config["copy"].as_str())
count = self.config["count"].get(bool)
delete = self.config["delete"].get(bool)
fmt = self.config["format"].get(str)
full = self.config["full"].get(bool)
keys = self.config["keys"].as_str_seq()
merge = self.config["merge"].get(bool)
move = bytestring_path(self.config["move"].as_str())
path = self.config["path"].get(bool)
tiebreak = self.config["tiebreak"].get(dict)
strict = self.config["strict"].get(bool)
tag = self.config["tag"].get(str)
if album:
if not keys:
keys = ['mb_albumid']
keys = ["mb_albumid"]
items = lib.albums(decargs(args))
else:
if not keys:
keys = ['mb_trackid', 'mb_albumid']
keys = ["mb_trackid", "mb_albumid"]
items = lib.items(decargs(args))
# If there's nothing to do, return early. The code below assumes
@@ -139,43 +167,47 @@ class DuplicatesPlugin(BeetsPlugin):
return
if path:
fmt = '$path'
fmt = "$path"
# Default format string for count mode.
if count and not fmt:
if album:
fmt = '$albumartist - $album'
fmt = "$albumartist - $album"
else:
fmt = '$albumartist - $album - $title'
fmt += ': {0}'
fmt = "$albumartist - $album - $title"
fmt += ": {0}"
if checksum:
for i in items:
k, _ = self._checksum(i, checksum)
keys = [k]
for obj_id, obj_count, objs in self._duplicates(items,
keys=keys,
full=full,
strict=strict,
tiebreak=tiebreak,
merge=merge):
for obj_id, obj_count, objs in self._duplicates(
items,
keys=keys,
full=full,
strict=strict,
tiebreak=tiebreak,
merge=merge,
):
if obj_id: # Skip empty IDs.
for o in objs:
self._process_item(o,
copy=copy,
move=move,
delete=delete,
tag=tag,
fmt=fmt.format(obj_count))
self._process_item(
o,
copy=copy,
move=move,
delete=delete,
tag=tag,
fmt=fmt.format(obj_count),
)
self._command.func = _dup
return [self._command]
def _process_item(self, item, copy=False, move=False, delete=False,
tag=False, fmt=''):
"""Process Item `item`.
"""
def _process_item(
self, item, copy=False, move=False, delete=False, tag=False, fmt=""
):
"""Process Item `item`."""
print_(format(item, fmt))
if copy:
item.move(basedir=copy, operation=MoveOperation.COPY)
@@ -187,11 +219,9 @@ class DuplicatesPlugin(BeetsPlugin):
item.remove(delete=True)
if tag:
try:
k, v = tag.split('=')
k, v = tag.split("=")
except Exception:
raise UserError(
f"{PLUGIN}: can't parse k=v tag: {tag}"
)
raise UserError(f"{PLUGIN}: can't parse k=v tag: {tag}")
setattr(item, k, v)
item.store()
@@ -200,27 +230,36 @@ class DuplicatesPlugin(BeetsPlugin):
output as flexattr on a key that is the name of the program, and
return the key, checksum tuple.
"""
args = [p.format(file=decode_commandline_path(item.path))
for p in shlex.split(prog)]
args = [
p.format(file=os.fsdecode(item.path)) for p in shlex.split(prog)
]
key = args[0]
checksum = getattr(item, key, False)
if not checksum:
self._log.debug('key {0} on item {1} not cached:'
'computing checksum',
key, displayable_path(item.path))
self._log.debug(
"key {0} on item {1} not cached:" "computing checksum",
key,
displayable_path(item.path),
)
try:
checksum = command_output(args).stdout
setattr(item, key, checksum)
item.store()
self._log.debug('computed checksum for {0} using {1}',
item.title, key)
self._log.debug(
"computed checksum for {0} using {1}", item.title, key
)
except subprocess.CalledProcessError as e:
self._log.debug('failed to checksum {0}: {1}',
displayable_path(item.path), e)
self._log.debug(
"failed to checksum {0}: {1}",
displayable_path(item.path),
e,
)
else:
self._log.debug('key {0} on item {1} cached:'
'not computing checksum',
key, displayable_path(item.path))
self._log.debug(
"key {0} on item {1} cached:" "not computing checksum",
key,
displayable_path(item.path),
)
return key, checksum
def _group_by(self, objs, keys, strict):
@@ -230,18 +269,23 @@ class DuplicatesPlugin(BeetsPlugin):
If strict, all attributes must be defined for a duplicate match.
"""
import collections
counts = collections.defaultdict(list)
for obj in objs:
values = [getattr(obj, k, None) for k in keys]
values = [v for v in values if v not in (None, '')]
values = [v for v in values if v not in (None, "")]
if strict and len(values) < len(keys):
self._log.debug('some keys {0} on item {1} are null or empty:'
' skipping',
keys, displayable_path(obj.path))
elif (not strict and not len(values)):
self._log.debug('all keys {0} on item {1} are null or empty:'
' skipping',
keys, displayable_path(obj.path))
self._log.debug(
"some keys {0} on item {1} are null or empty:" " skipping",
keys,
displayable_path(obj.path),
)
elif not strict and not len(values):
self._log.debug(
"all keys {0} on item {1} are null or empty:" " skipping",
keys,
displayable_path(obj.path),
)
else:
key = tuple(values)
counts[key].append(obj)
@@ -257,18 +301,21 @@ class DuplicatesPlugin(BeetsPlugin):
"completeness" (objects with more non-null fields come first)
and Albums are ordered by their track count.
"""
kind = 'items' if all(isinstance(o, Item) for o in objs) else 'albums'
kind = "items" if all(isinstance(o, Item) for o in objs) else "albums"
if tiebreak and kind in tiebreak.keys():
key = lambda x: tuple(getattr(x, k) for k in tiebreak[kind])
else:
if kind == 'items':
if kind == "items":
def truthy(v):
# Avoid a Unicode warning by avoiding comparison
# between a bytes object and the empty Unicode
# string ''.
return v is not None and \
(v != '' if isinstance(v, str) else True)
return v is not None and (
v != "" if isinstance(v, str) else True
)
fields = Item.all_keys()
key = lambda x: sum(1 for f in fields if truthy(getattr(x, f)))
else:
@@ -285,13 +332,16 @@ class DuplicatesPlugin(BeetsPlugin):
fields = Item.all_keys()
for f in fields:
for o in objs[1:]:
if getattr(objs[0], f, None) in (None, ''):
if getattr(objs[0], f, None) in (None, ""):
value = getattr(o, f, None)
if value:
self._log.debug('key {0} on item {1} is null '
'or empty: setting from item {2}',
f, displayable_path(objs[0].path),
displayable_path(o.path))
self._log.debug(
"key {0} on item {1} is null "
"or empty: setting from item {2}",
f,
displayable_path(objs[0].path),
displayable_path(o.path),
)
setattr(objs[0], f, value)
objs[0].store()
break
@@ -309,12 +359,14 @@ class DuplicatesPlugin(BeetsPlugin):
missing = Item.from_path(i.path)
missing.album_id = objs[0].id
missing.add(i._db)
self._log.debug('item {0} missing from album {1}:'
' merging from {2} into {3}',
missing,
objs[0],
displayable_path(o.path),
displayable_path(missing.destination()))
self._log.debug(
"item {0} missing from album {1}:"
" merging from {2} into {3}",
missing,
objs[0],
displayable_path(o.path),
displayable_path(missing.destination()),
)
missing.move(operation=MoveOperation.COPY)
return objs
@@ -330,8 +382,7 @@ class DuplicatesPlugin(BeetsPlugin):
return objs
def _duplicates(self, objs, keys, full, strict, tiebreak, merge):
"""Generate triples of keys, duplicate counts, and constituent objects.
"""
"""Generate triples of keys, duplicate counts, and constituent objects."""
offset = 0 if full else 1
for k, objs in self._group_by(objs, keys, strict).items():
if len(objs) > 1:
+77 -76
View File
@@ -15,23 +15,22 @@
"""Open metadata information in a text editor to let the user edit it.
"""
from beets import plugins
from beets import util
from beets import ui
from beets.dbcore import types
from beets.importer import action
from beets.ui.commands import _do_query, PromptChoice
import codecs
import subprocess
import yaml
from tempfile import NamedTemporaryFile
import os
import shlex
import subprocess
from tempfile import NamedTemporaryFile
import yaml
from beets import plugins, ui, util
from beets.dbcore import types
from beets.importer import action
from beets.ui.commands import PromptChoice, _do_query
# These "safe" types can avoid the format/parse cycle that most fields go
# through: they are safe to edit with native YAML types.
SAFE_TYPES = (types.Float, types.Integer, types.Boolean)
SAFE_TYPES = (types.BaseFloat, types.BaseInteger, types.Boolean)
class ParseError(Exception):
@@ -41,22 +40,20 @@ class ParseError(Exception):
def edit(filename, log):
"""Open `filename` in a text editor.
"""
"""Open `filename` in a text editor."""
cmd = shlex.split(util.editor_command())
cmd.append(filename)
log.debug('invoking editor command: {!r}', cmd)
log.debug("invoking editor command: {!r}", cmd)
try:
subprocess.call(cmd)
except OSError as exc:
raise ui.UserError('could not run editor command {!r}: {}'.format(
cmd[0], exc
))
raise ui.UserError(
"could not run editor command {!r}: {}".format(cmd[0], exc)
)
def dump(arg):
"""Dump a sequence of dictionaries as YAML for editing.
"""
"""Dump a sequence of dictionaries as YAML for editing."""
return yaml.safe_dump_all(
arg,
allow_unicode=True,
@@ -75,7 +72,7 @@ def load(s):
for d in yaml.safe_load_all(s):
if not isinstance(d, dict):
raise ParseError(
'each entry must be a dictionary; found {}'.format(
"each entry must be a dictionary; found {}".format(
type(d).__name__
)
)
@@ -85,7 +82,7 @@ def load(s):
out.append({str(k): v for k, v in d.items()})
except yaml.YAMLError as e:
raise ParseError(f'invalid YAML: {e}')
raise ParseError(f"invalid YAML: {e}")
return out
@@ -145,51 +142,50 @@ def apply_(obj, data):
class EditPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
# The default fields to edit.
'albumfields': 'album albumartist',
'itemfields': 'track title artist album',
self.config.add(
{
# The default fields to edit.
"albumfields": "album albumartist",
"itemfields": "track title artist album",
# Silently ignore any changes to these fields.
"ignore_fields": "id path",
}
)
# Silently ignore any changes to these fields.
'ignore_fields': 'id path',
})
self.register_listener('before_choose_candidate',
self.before_choose_candidate_listener)
self.register_listener(
"before_choose_candidate", self.before_choose_candidate_listener
)
def commands(self):
edit_command = ui.Subcommand(
'edit',
help='interactively edit metadata'
edit_command = ui.Subcommand("edit", help="interactively edit metadata")
edit_command.parser.add_option(
"-f",
"--field",
metavar="FIELD",
action="append",
help="edit this field also",
)
edit_command.parser.add_option(
'-f', '--field',
metavar='FIELD',
action='append',
help='edit this field also',
)
edit_command.parser.add_option(
'--all',
action='store_true', dest='all',
help='edit all fields',
"--all",
action="store_true",
dest="all",
help="edit all fields",
)
edit_command.parser.add_album_option()
edit_command.func = self._edit_command
return [edit_command]
def _edit_command(self, lib, opts, args):
"""The CLI command function for the `beet edit` command.
"""
"""The CLI command function for the `beet edit` command."""
# Get the objects to edit.
query = ui.decargs(args)
items, albums = _do_query(lib, query, opts.album, False)
objs = albums if opts.album else items
if not objs:
ui.print_('Nothing to edit.')
ui.print_("Nothing to edit.")
return
# Get the fields to edit.
@@ -200,20 +196,19 @@ class EditPlugin(plugins.BeetsPlugin):
self.edit(opts.album, objs, fields)
def _get_fields(self, album, extra):
"""Get the set of fields to edit.
"""
"""Get the set of fields to edit."""
# Start with the configured base fields.
if album:
fields = self.config['albumfields'].as_str_seq()
fields = self.config["albumfields"].as_str_seq()
else:
fields = self.config['itemfields'].as_str_seq()
fields = self.config["itemfields"].as_str_seq()
# Add the requested extra fields.
if extra:
fields += extra
# Ensure we always have the `id` field for identification.
fields.append('id')
fields.append("id")
return set(fields)
@@ -225,7 +220,7 @@ class EditPlugin(plugins.BeetsPlugin):
- `fields`: The set of field names to edit (or None to edit
everything).
"""
# Present the YAML to the user and let her change it.
# Present the YAML to the user and let them change it.
success = self.edit_objects(objs, fields)
# Save the new data.
@@ -242,8 +237,9 @@ class EditPlugin(plugins.BeetsPlugin):
old_data = [flatten(o, fields) for o in objs]
# Set up a temporary file with the initial data for editing.
new = NamedTemporaryFile(mode='w', suffix='.yaml', delete=False,
encoding='utf-8')
new = NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False, encoding="utf-8"
)
old_str = dump(old_data)
new.write(old_str)
new.close()
@@ -256,7 +252,7 @@ class EditPlugin(plugins.BeetsPlugin):
# Read the data back after editing and check whether anything
# changed.
with codecs.open(new.name, encoding='utf-8') as f:
with codecs.open(new.name, encoding="utf-8") as f:
new_str = f.read()
if new_str == old_str:
ui.print_("No changes; aborting.")
@@ -275,29 +271,29 @@ class EditPlugin(plugins.BeetsPlugin):
# Show the changes.
# If the objects are not on the DB yet, we need a copy of their
# original state for show_model_changes.
objs_old = [obj.copy() if obj.id < 0 else None
for obj in objs]
objs_old = [obj.copy() if obj.id < 0 else None for obj in objs]
self.apply_data(objs, old_data, new_data)
changed = False
for obj, obj_old in zip(objs, objs_old):
changed |= ui.show_model_changes(obj, obj_old)
if not changed:
ui.print_('No changes to apply.')
ui.print_("No changes to apply.")
return False
# Confirm the changes.
choice = ui.input_options(
('continue Editing', 'apply', 'cancel')
("continue Editing", "apply", "cancel")
)
if choice == 'a': # Apply.
if choice == "a": # Apply.
return True
elif choice == 'c': # Cancel.
elif choice == "c": # Cancel.
return False
elif choice == 'e': # Keep editing.
elif choice == "e": # Keep editing.
# Reset the temporary changes to the objects. I we have a
# copy from above, use that, else reload from the database.
objs = [(old_obj or obj)
for old_obj, obj in zip(objs_old, objs)]
objs = [
(old_obj or obj) for old_obj, obj in zip(objs_old, objs)
]
for obj in objs:
if not obj.id < 0:
obj.load()
@@ -315,33 +311,35 @@ class EditPlugin(plugins.BeetsPlugin):
are temporary.
"""
if len(old_data) != len(new_data):
self._log.warning('number of objects changed from {} to {}',
len(old_data), len(new_data))
self._log.warning(
"number of objects changed from {} to {}",
len(old_data),
len(new_data),
)
obj_by_id = {o.id: o for o in objs}
ignore_fields = self.config['ignore_fields'].as_str_seq()
ignore_fields = self.config["ignore_fields"].as_str_seq()
for old_dict, new_dict in zip(old_data, new_data):
# Prohibit any changes to forbidden fields to avoid
# clobbering `id` and such by mistake.
forbidden = False
for key in ignore_fields:
if old_dict.get(key) != new_dict.get(key):
self._log.warning('ignoring object whose {} changed', key)
self._log.warning("ignoring object whose {} changed", key)
forbidden = True
break
if forbidden:
continue
id_ = int(old_dict['id'])
id_ = int(old_dict["id"])
apply_(obj_by_id[id_], new_dict)
def save_changes(self, objs):
"""Save a list of updated Model objects to the database.
"""
"""Save a list of updated Model objects to the database."""
# Save to the database and possibly write tags.
for ob in objs:
if ob._dirty:
self._log.debug('saving changes to {}', ob)
self._log.debug("saving changes to {}", ob)
ob.try_sync(ui.should_write(), ui.should_move())
# Methods for interactive importer execution.
@@ -350,10 +348,13 @@ class EditPlugin(plugins.BeetsPlugin):
"""Append an "Edit" choice and an "edit Candidates" choice (if
there are candidates) to the interactive importer prompt.
"""
choices = [PromptChoice('d', 'eDit', self.importer_edit)]
choices = [PromptChoice("d", "eDit", self.importer_edit)]
if task.candidates:
choices.append(PromptChoice('c', 'edit Candidates',
self.importer_edit_candidate))
choices.append(
PromptChoice(
"c", "edit Candidates", self.importer_edit_candidate
)
)
return choices
@@ -369,7 +370,7 @@ class EditPlugin(plugins.BeetsPlugin):
if not obj._db or obj.id is None:
obj.id = -i
# Present the YAML to the user and let her change it.
# Present the YAML to the user and let them change it.
fields = self._get_fields(album=False, extra=[])
success = self.edit_objects(task.items, fields)
+155 -76
View File
@@ -15,14 +15,16 @@
"""Allows beets to embed album art into file metadata."""
import os.path
import tempfile
from mimetypes import guess_extension
import requests
from beets import art, config, ui
from beets.plugins import BeetsPlugin
from beets import ui
from beets.ui import print_, decargs
from beets.util import syspath, normpath, displayable_path, bytestring_path
from beets.ui import decargs, print_
from beets.util import bytestring_path, displayable_path, normpath, syspath
from beets.util.artresizer import ArtResizer
from beets import config
from beets import art
def _confirm(objs, album):
@@ -32,11 +34,9 @@ def _confirm(objs, album):
`album` is a Boolean indicating whether these are albums (as opposed
to items).
"""
noun = 'album' if album else 'file'
prompt = 'Modify artwork for {} {}{} (Y/n)?'.format(
len(objs),
noun,
's' if len(objs) > 1 else ''
noun = "album" if album else "file"
prompt = "Modify artwork for {} {}{} (Y/n)?".format(
len(objs), noun, "s" if len(objs) > 1 else ""
)
# Show all the items or albums.
@@ -48,54 +48,72 @@ def _confirm(objs, album):
class EmbedCoverArtPlugin(BeetsPlugin):
"""Allows albumart to be embedded into the actual files.
"""
"""Allows albumart to be embedded into the actual files."""
def __init__(self):
super().__init__()
self.config.add({
'maxwidth': 0,
'auto': True,
'compare_threshold': 0,
'ifempty': False,
'remove_art_file': False,
'quality': 0,
})
self.config.add(
{
"maxwidth": 0,
"auto": True,
"compare_threshold": 0,
"ifempty": False,
"remove_art_file": False,
"quality": 0,
}
)
if self.config['maxwidth'].get(int) and not ArtResizer.shared.local:
self.config['maxwidth'] = 0
self._log.warning("ImageMagick or PIL not found; "
"'maxwidth' option ignored")
if self.config['compare_threshold'].get(int) and not \
ArtResizer.shared.can_compare:
self.config['compare_threshold'] = 0
self._log.warning("ImageMagick 6.8.7 or higher not installed; "
"'compare_threshold' option ignored")
if self.config["maxwidth"].get(int) and not ArtResizer.shared.local:
self.config["maxwidth"] = 0
self._log.warning(
"ImageMagick or PIL not found; " "'maxwidth' option ignored"
)
if (
self.config["compare_threshold"].get(int)
and not ArtResizer.shared.can_compare
):
self.config["compare_threshold"] = 0
self._log.warning(
"ImageMagick 6.8.7 or higher not installed; "
"'compare_threshold' option ignored"
)
self.register_listener('art_set', self.process_album)
self.register_listener("art_set", self.process_album)
def commands(self):
# Embed command.
embed_cmd = ui.Subcommand(
'embedart', help='embed image files into file metadata'
"embedart", help="embed image files into file metadata"
)
embed_cmd.parser.add_option(
'-f', '--file', metavar='PATH', help='the image file to embed'
"-f", "--file", metavar="PATH", help="the image file to embed"
)
embed_cmd.parser.add_option(
"-y", "--yes", action="store_true", help="skip confirmation"
)
maxwidth = self.config['maxwidth'].get(int)
quality = self.config['quality'].get(int)
compare_threshold = self.config['compare_threshold'].get(int)
ifempty = self.config['ifempty'].get(bool)
embed_cmd.parser.add_option(
"-u",
"--url",
metavar="URL",
help="the URL of the image file to embed",
)
maxwidth = self.config["maxwidth"].get(int)
quality = self.config["quality"].get(int)
compare_threshold = self.config["compare_threshold"].get(int)
ifempty = self.config["ifempty"].get(bool)
def embed_func(lib, opts, args):
if opts.file:
imagepath = normpath(opts.file)
if not os.path.isfile(syspath(imagepath)):
raise ui.UserError('image file {} not found'.format(
displayable_path(imagepath)
))
raise ui.UserError(
"image file {} not found".format(
displayable_path(imagepath)
)
)
items = lib.items(decargs(args))
@@ -104,66 +122,122 @@ class EmbedCoverArtPlugin(BeetsPlugin):
return
for item in items:
art.embed_item(self._log, item, imagepath, maxwidth,
None, compare_threshold, ifempty,
quality=quality)
art.embed_item(
self._log,
item,
imagepath,
maxwidth,
None,
compare_threshold,
ifempty,
quality=quality,
)
elif opts.url:
try:
response = requests.get(opts.url, timeout=5)
response.raise_for_status()
except requests.exceptions.RequestException as e:
self._log.error("{}".format(e))
return
extension = guess_extension(response.headers["Content-Type"])
if extension is None:
self._log.error("Invalid image file")
return
file = f"image{extension}"
tempimg = os.path.join(tempfile.gettempdir(), file)
try:
with open(tempimg, "wb") as f:
f.write(response.content)
except Exception as e:
self._log.error("Unable to save image: {}".format(e))
return
items = lib.items(decargs(args))
# Confirm with user.
if not opts.yes and not _confirm(items, not opts.url):
os.remove(tempimg)
return
for item in items:
art.embed_item(
self._log,
item,
tempimg,
maxwidth,
None,
compare_threshold,
ifempty,
quality=quality,
)
os.remove(tempimg)
else:
albums = lib.albums(decargs(args))
# Confirm with user.
if not opts.yes and not _confirm(albums, not opts.file):
return
for album in albums:
art.embed_album(self._log, album, maxwidth,
False, compare_threshold, ifempty,
quality=quality)
art.embed_album(
self._log,
album,
maxwidth,
False,
compare_threshold,
ifempty,
quality=quality,
)
self.remove_artfile(album)
embed_cmd.func = embed_func
# Extract command.
extract_cmd = ui.Subcommand(
'extractart',
help='extract an image from file metadata',
"extractart",
help="extract an image from file metadata",
)
extract_cmd.parser.add_option(
'-o', dest='outpath',
help='image output file',
"-o",
dest="outpath",
help="image output file",
)
extract_cmd.parser.add_option(
'-n', dest='filename',
help='image filename to create for all matched albums',
"-n",
dest="filename",
help="image filename to create for all matched albums",
)
extract_cmd.parser.add_option(
'-a', dest='associate', action='store_true',
help='associate the extracted images with the album',
"-a",
dest="associate",
action="store_true",
help="associate the extracted images with the album",
)
def extract_func(lib, opts, args):
if opts.outpath:
art.extract_first(self._log, normpath(opts.outpath),
lib.items(decargs(args)))
art.extract_first(
self._log, normpath(opts.outpath), lib.items(decargs(args))
)
else:
filename = bytestring_path(opts.filename or
config['art_filename'].get())
if os.path.dirname(filename) != b'':
filename = bytestring_path(
opts.filename or config["art_filename"].get()
)
if os.path.dirname(filename) != b"":
self._log.error(
"Only specify a name rather than a path for -n")
"Only specify a name rather than a path for -n"
)
return
for album in lib.albums(decargs(args)):
artpath = normpath(os.path.join(album.path, filename))
artpath = art.extract_first(self._log, artpath,
album.items())
artpath = art.extract_first(
self._log, artpath, album.items()
)
if artpath and opts.associate:
album.set_art(artpath)
album.store()
extract_cmd.func = extract_func
# Clear command.
clear_cmd = ui.Subcommand(
'clearart',
help='remove images from file metadata',
"clearart",
help="remove images from file metadata",
)
clear_cmd.parser.add_option(
"-y", "--yes", action="store_true", help="skip confirmation"
@@ -175,27 +249,32 @@ class EmbedCoverArtPlugin(BeetsPlugin):
if not opts.yes and not _confirm(items, False):
return
art.clear(self._log, lib, decargs(args))
clear_cmd.func = clear_func
return [embed_cmd, extract_cmd, clear_cmd]
def process_album(self, album):
"""Automatically embed art after art has been set
"""
if self.config['auto'] and ui.should_write():
max_width = self.config['maxwidth'].get(int)
art.embed_album(self._log, album, max_width, True,
self.config['compare_threshold'].get(int),
self.config['ifempty'].get(bool))
"""Automatically embed art after art has been set"""
if self.config["auto"] and ui.should_write():
max_width = self.config["maxwidth"].get(int)
art.embed_album(
self._log,
album,
max_width,
True,
self.config["compare_threshold"].get(int),
self.config["ifempty"].get(bool),
)
self.remove_artfile(album)
def remove_artfile(self, album):
"""Possibly delete the album art file for an album (if the
appropriate configuration option is enabled).
"""
if self.config['remove_art_file'] and album.artpath:
if os.path.isfile(album.artpath):
self._log.debug('Removing album art file for {0}', album)
os.remove(album.artpath)
if self.config["remove_art_file"] and album.artpath:
if os.path.isfile(syspath(album.artpath)):
self._log.debug("Removing album art file for {0}", album)
os.remove(syspath(album.artpath))
album.artpath = None
album.store()
+62 -55
View File
@@ -9,9 +9,10 @@
"""
import hashlib
from urllib.parse import parse_qs, urlencode, urljoin, urlsplit, urlunsplit
import requests
from urllib.parse import urlencode, urljoin, parse_qs, urlsplit, urlunsplit
from beets import config
from beets.plugins import BeetsPlugin
@@ -32,24 +33,20 @@ def api_url(host, port, endpoint):
"""
# check if http or https is defined as host and create hostname
hostname_list = [host]
if host.startswith('http://') or host.startswith('https://'):
hostname = ''.join(hostname_list)
if host.startswith("http://") or host.startswith("https://"):
hostname = "".join(hostname_list)
else:
hostname_list.insert(0, 'http://')
hostname = ''.join(hostname_list)
hostname_list.insert(0, "http://")
hostname = "".join(hostname_list)
joined = urljoin(
'{hostname}:{port}'.format(
hostname=hostname,
port=port
),
endpoint
"{hostname}:{port}".format(hostname=hostname, port=port), endpoint
)
scheme, netloc, path, query_string, fragment = urlsplit(joined)
query_params = parse_qs(query_string)
query_params['format'] = ['json']
query_params["format"] = ["json"]
new_query_string = urlencode(query_params, doseq=True)
return urlunsplit((scheme, netloc, path, new_query_string, fragment))
@@ -66,9 +63,9 @@ def password_data(username, password):
:rtype: dict
"""
return {
'username': username,
'password': hashlib.sha1(password.encode('utf-8')).hexdigest(),
'passwordMd5': hashlib.md5(password.encode('utf-8')).hexdigest()
"username": username,
"password": hashlib.sha1(password.encode("utf-8")).hexdigest(),
"passwordMd5": hashlib.md5(password.encode("utf-8")).hexdigest(),
}
@@ -92,10 +89,10 @@ def create_headers(user_id, token=None):
'Version="0.0.0"'
).format(user_id=user_id)
headers['x-emby-authorization'] = authorization
headers["x-emby-authorization"] = authorization
if token:
headers['x-mediabrowser-token'] = token
headers["x-mediabrowser-token"] = token
return headers
@@ -114,10 +111,15 @@ def get_token(host, port, headers, auth_data):
:returns: Access Token
:rtype: str
"""
url = api_url(host, port, '/Users/AuthenticateByName')
r = requests.post(url, headers=headers, data=auth_data)
url = api_url(host, port, "/Users/AuthenticateByName")
r = requests.post(
url,
headers=headers,
data=auth_data,
timeout=10,
)
return r.json().get('AccessToken')
return r.json().get("AccessToken")
def get_user(host, port, username):
@@ -132,9 +134,9 @@ def get_user(host, port, username):
:returns: Matched Users
:rtype: list
"""
url = api_url(host, port, '/Users/Public')
r = requests.get(url)
user = [i for i in r.json() if i['Name'] == username]
url = api_url(host, port, "/Users/Public")
r = requests.get(url, timeout=10)
user = [i for i in r.json() if i["Name"] == username]
return user
@@ -144,62 +146,67 @@ class EmbyUpdate(BeetsPlugin):
super().__init__()
# Adding defaults.
config['emby'].add({
'host': 'http://localhost',
'port': 8096,
'apikey': None,
'password': None,
})
config["emby"].add(
{
"host": "http://localhost",
"port": 8096,
"apikey": None,
"password": None,
}
)
self.register_listener('database_change', self.listen_for_db_change)
self.register_listener("database_change", self.listen_for_db_change)
def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update for the end.
"""
self.register_listener('cli_exit', self.update)
"""Listens for beets db change and register the update for the end."""
self.register_listener("cli_exit", self.update)
def update(self, lib):
"""When the client exists try to send refresh request to Emby.
"""
self._log.info('Updating Emby library...')
"""When the client exists try to send refresh request to Emby."""
self._log.info("Updating Emby library...")
host = config['emby']['host'].get()
port = config['emby']['port'].get()
username = config['emby']['username'].get()
password = config['emby']['password'].get()
token = config['emby']['apikey'].get()
host = config["emby"]["host"].get()
port = config["emby"]["port"].get()
username = config["emby"]["username"].get()
password = config["emby"]["password"].get()
userid = config["emby"]["userid"].get()
token = config["emby"]["apikey"].get()
# Check if at least a apikey or password is given.
if not any([password, token]):
self._log.warning('Provide at least Emby password or apikey.')
self._log.warning("Provide at least Emby password or apikey.")
return
# Get user information from the Emby API.
user = get_user(host, port, username)
if not user:
self._log.warning(f'User {username} could not be found.')
return
if not userid:
# Get user information from the Emby API.
user = get_user(host, port, username)
if not user:
self._log.warning(f"User {username} could not be found.")
return
userid = user[0]["Id"]
if not token:
# Create Authentication data and headers.
auth_data = password_data(username, password)
headers = create_headers(user[0]['Id'])
headers = create_headers(userid)
# Get authentication token.
token = get_token(host, port, headers, auth_data)
if not token:
self._log.warning(
'Could not get token for user {0}', username
)
self._log.warning("Could not get token for user {0}", username)
return
# Recreate headers with a token.
headers = create_headers(user[0]['Id'], token=token)
headers = create_headers(userid, token=token)
# Trigger the Update.
url = api_url(host, port, '/Library/Refresh')
r = requests.post(url, headers=headers)
url = api_url(host, port, "/Library/Refresh")
r = requests.post(
url,
headers=headers,
timeout=10,
)
if r.status_code != 204:
self._log.warning('Update could not be triggered')
self._log.warning("Update could not be triggered")
else:
self._log.info('Update triggered.')
self._log.info("Update triggered.")
+98 -79
View File
@@ -15,22 +15,23 @@
"""
import sys
import codecs
import json
import csv
import json
import sys
from datetime import date, datetime
from xml.etree import ElementTree
from datetime import datetime, date
from beets.plugins import BeetsPlugin
from beets import ui
from beets import util
import mediafile
from beets import ui, util
from beets.plugins import BeetsPlugin
from beetsplug.info import library_data, tag_data
class ExportEncoder(json.JSONEncoder):
"""Deals with dates because JSON doesn't have a standard"""
def default(self, o):
if isinstance(o, (datetime, date)):
return o.isoformat()
@@ -38,89 +39,99 @@ class ExportEncoder(json.JSONEncoder):
class ExportPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'default_format': 'json',
'json': {
# JSON module formatting options.
'formatting': {
'ensure_ascii': False,
'indent': 4,
'separators': (',', ': '),
'sort_keys': True
}
},
'jsonlines': {
# JSON Lines formatting options.
'formatting': {
'ensure_ascii': False,
'separators': (',', ': '),
'sort_keys': True
}
},
'csv': {
# CSV module formatting options.
'formatting': {
# The delimiter used to seperate columns.
'delimiter': ',',
# The dialect to use when formating the file output.
'dialect': 'excel'
}
},
'xml': {
# XML module formatting options.
'formatting': {}
self.config.add(
{
"default_format": "json",
"json": {
# JSON module formatting options.
"formatting": {
"ensure_ascii": False,
"indent": 4,
"separators": (",", ": "),
"sort_keys": True,
}
},
"jsonlines": {
# JSON Lines formatting options.
"formatting": {
"ensure_ascii": False,
"separators": (",", ": "),
"sort_keys": True,
}
},
"csv": {
# CSV module formatting options.
"formatting": {
# The delimiter used to separate columns.
"delimiter": ",",
# The dialect to use when formatting the file output.
"dialect": "excel",
}
},
"xml": {
# XML module formatting options.
"formatting": {}
},
# TODO: Use something like the edit plugin
# 'item_fields': []
}
# TODO: Use something like the edit plugin
# 'item_fields': []
})
)
def commands(self):
cmd = ui.Subcommand('export', help='export data from beets')
cmd = ui.Subcommand("export", help="export data from beets")
cmd.func = self.run
cmd.parser.add_option(
'-l', '--library', action='store_true',
help='show library fields instead of tags',
"-l",
"--library",
action="store_true",
help="show library fields instead of tags",
)
cmd.parser.add_option(
'-a', '--album', action='store_true',
"-a",
"--album",
action="store_true",
help='show album fields instead of tracks (implies "--library")',
)
cmd.parser.add_option(
'--append', action='store_true', default=False,
help='if should append data to the file',
"--append",
action="store_true",
default=False,
help="if should append data to the file",
)
cmd.parser.add_option(
'-i', '--include-keys', default=[],
action='append', dest='included_keys',
help='comma separated list of keys to show',
"-i",
"--include-keys",
default=[],
action="append",
dest="included_keys",
help="comma separated list of keys to show",
)
cmd.parser.add_option(
'-o', '--output',
help='path for the output file. If not given, will print the data'
"-o",
"--output",
help="path for the output file. If not given, will print the data",
)
cmd.parser.add_option(
'-f', '--format', default='json',
help="the output format: json (default), jsonlines, csv, or xml"
"-f",
"--format",
default="json",
help="the output format: json (default), jsonlines, csv, or xml",
)
return [cmd]
def run(self, lib, opts, args):
file_path = opts.output
file_mode = 'a' if opts.append else 'w'
file_format = opts.format or self.config['default_format'].get(str)
file_format_is_line_based = (file_format == 'jsonlines')
format_options = self.config[file_format]['formatting'].get(dict)
file_mode = "a" if opts.append else "w"
file_format = opts.format or self.config["default_format"].get(str)
file_format_is_line_based = file_format == "jsonlines"
format_options = self.config[file_format]["formatting"].get(dict)
export_format = ExportFormat.factory(
file_type=file_format,
**{
'file_path': file_path,
'file_mode': file_mode
}
**{"file_path": file_path, "file_mode": file_mode},
)
if opts.library or opts.album:
@@ -130,17 +141,18 @@ class ExportPlugin(BeetsPlugin):
included_keys = []
for keys in opts.included_keys:
included_keys.extend(keys.split(','))
included_keys.extend(keys.split(","))
items = []
for data_emitter in data_collector(
lib, ui.decargs(args),
album=opts.album,
lib,
ui.decargs(args),
album=opts.album,
):
try:
data, item = data_emitter(included_keys or '*')
data, item = data_emitter(included_keys or "*")
except (mediafile.UnreadableFileError, OSError) as ex:
self._log.error('cannot read file: {0}', ex)
self._log.error("cannot read file: {0}", ex)
continue
for key, value in data.items():
@@ -158,13 +170,17 @@ class ExportPlugin(BeetsPlugin):
class ExportFormat:
"""The output format type"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
def __init__(self, file_path, file_mode="w", encoding="utf-8"):
self.path = file_path
self.mode = file_mode
self.encoding = encoding
# creates a file object to write/append or sets to stdout
self.out_stream = codecs.open(self.path, self.mode, self.encoding) \
if self.path else sys.stdout
self.out_stream = (
codecs.open(self.path, self.mode, self.encoding)
if self.path
else sys.stdout
)
@classmethod
def factory(cls, file_type, **kwargs):
@@ -183,17 +199,19 @@ class ExportFormat:
class JsonFormat(ExportFormat):
"""Saves in a json file"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
def __init__(self, file_path, file_mode="w", encoding="utf-8"):
super().__init__(file_path, file_mode, encoding)
def export(self, data, **kwargs):
json.dump(data, self.out_stream, cls=ExportEncoder, **kwargs)
self.out_stream.write('\n')
self.out_stream.write("\n")
class CSVFormat(ExportFormat):
"""Saves in a csv file"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
def __init__(self, file_path, file_mode="w", encoding="utf-8"):
super().__init__(file_path, file_mode, encoding)
def export(self, data, **kwargs):
@@ -205,23 +223,24 @@ class CSVFormat(ExportFormat):
class XMLFormat(ExportFormat):
"""Saves in a xml file"""
def __init__(self, file_path, file_mode='w', encoding='utf-8'):
def __init__(self, file_path, file_mode="w", encoding="utf-8"):
super().__init__(file_path, file_mode, encoding)
def export(self, data, **kwargs):
# Creates the XML file structure.
library = ElementTree.Element('library')
tracks = ElementTree.SubElement(library, 'tracks')
library = ElementTree.Element("library")
tracks = ElementTree.SubElement(library, "tracks")
if data and isinstance(data[0], dict):
for index, item in enumerate(data):
track = ElementTree.SubElement(tracks, 'track')
track = ElementTree.SubElement(tracks, "track")
for key, value in item.items():
track_details = ElementTree.SubElement(track, key)
track_details.text = value
# Depending on the version of python the encoding needs to change
try:
data = ElementTree.tostring(library, encoding='unicode', **kwargs)
data = ElementTree.tostring(library, encoding="unicode", **kwargs)
except LookupError:
data = ElementTree.tostring(library, encoding='utf-8', **kwargs)
data = ElementTree.tostring(library, encoding="utf-8", **kwargs)
self.out_stream.write(data)
+704 -409
View File
File diff suppressed because it is too large Load Diff
+20 -19
View File
@@ -17,38 +17,40 @@
import re
from beets import config
from beets.util import bytestring_path
from beets.plugins import BeetsPlugin
from beets.importer import SingletonImportTask
from beets.plugins import BeetsPlugin
from beets.util import bytestring_path
class FileFilterPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('import_task_created',
self.import_task_created_event)
self.config.add({
'path': '.*'
})
self.register_listener(
"import_task_created", self.import_task_created_event
)
self.config.add({"path": ".*"})
self.path_album_regex = \
self.path_singleton_regex = \
re.compile(bytestring_path(self.config['path'].get()))
self.path_album_regex = self.path_singleton_regex = re.compile(
bytestring_path(self.config["path"].get())
)
if 'album_path' in self.config:
if "album_path" in self.config:
self.path_album_regex = re.compile(
bytestring_path(self.config['album_path'].get()))
bytestring_path(self.config["album_path"].get())
)
if 'singleton_path' in self.config:
if "singleton_path" in self.config:
self.path_singleton_regex = re.compile(
bytestring_path(self.config['singleton_path'].get()))
bytestring_path(self.config["singleton_path"].get())
)
def import_task_created_event(self, session, task):
if task.items and len(task.items) > 0:
items_to_import = []
for item in task.items:
if self.file_filter(item['path']):
if self.file_filter(item["path"]):
items_to_import.append(item)
if len(items_to_import) > 0:
task.items = items_to_import
@@ -58,7 +60,7 @@ class FileFilterPlugin(BeetsPlugin):
return []
elif isinstance(task, SingletonImportTask):
if not self.file_filter(task.item['path']):
if not self.file_filter(task.item["path"]):
return []
# If not filtered, return the original task unchanged.
@@ -68,10 +70,9 @@ class FileFilterPlugin(BeetsPlugin):
"""Checks if the configured regular expressions allow the import
of the file given in full_path.
"""
import_config = dict(config['import'])
import_config = dict(config["import"])
full_path = bytestring_path(full_path)
if 'singletons' not in import_config or not import_config[
'singletons']:
if "singletons" not in import_config or not import_config["singletons"]:
# Album
return self.path_album_regex.match(full_path) is not None
else:
+157 -101
View File
@@ -23,17 +23,19 @@ by default but can be added via the `-e` / `--extravalues` flag. For example:
"""
from beets.plugins import BeetsPlugin
from beets import library, ui
from beets.ui import commands
from operator import attrgetter
import os
from operator import attrgetter
from beets import library, ui
from beets.plugins import BeetsPlugin
from beets.ui import commands
BL_NEED2 = """complete -c beet -n '__fish_beet_needs_command' {} {}\n"""
BL_USE3 = """complete -c beet -n '__fish_beet_using_command {}' {} {}\n"""
BL_SUBS = """complete -c beet -n '__fish_at_level {} ""' {} {}\n"""
BL_EXTRA3 = """complete -c beet -n '__fish_beet_use_extra {}' {} {}\n"""
HEAD = '''
HEAD = """
function __fish_beet_needs_command
set cmd (commandline -opc)
if test (count $cmd) -eq 1
@@ -62,25 +64,35 @@ function __fish_beet_use_extra
end
return 1
end
'''
"""
class FishPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('fish', help='generate Fish shell tab completions')
cmd = ui.Subcommand("fish", help="generate Fish shell tab completions")
cmd.func = self.run
cmd.parser.add_option('-f', '--noFields', action='store_true',
default=False,
help='omit album/track field completions')
cmd.parser.add_option(
'-e',
'--extravalues',
action='append',
type='choice',
choices=library.Item.all_keys() +
library.Album.all_keys(),
help='include specified field *values* in completions')
"-f",
"--noFields",
action="store_true",
default=False,
help="omit album/track field completions",
)
cmd.parser.add_option(
"-e",
"--extravalues",
action="append",
type="choice",
choices=library.Item.all_keys() + library.Album.all_keys(),
help="include specified field *values* in completions",
)
cmd.parser.add_option(
"-o",
"--output",
default="~/.config/fish/completions/beet.fish",
help="where to save the script. default: "
"~/.config/fish/completions",
)
return [cmd]
def run(self, lib, opts, args):
@@ -89,22 +101,20 @@ class FishPlugin(BeetsPlugin):
# If specified, also collect the values for these fields.
# Make a giant string of all the above, formatted in a way that
# allows Fish to do tab completion for the `beet` command.
home_dir = os.path.expanduser("~")
completion_dir = os.path.join(home_dir, '.config/fish/completions')
try:
os.makedirs(completion_dir)
except OSError:
if not os.path.isdir(completion_dir):
raise
completion_file_path = os.path.join(completion_dir, 'beet.fish')
completion_file_path = os.path.expanduser(opts.output)
completion_dir = os.path.dirname(completion_file_path)
if completion_dir != "":
os.makedirs(completion_dir, exist_ok=True)
nobasicfields = opts.noFields # Do not complete for album/track fields
extravalues = opts.extravalues # e.g., Also complete artists names
beetcmds = sorted(
(commands.default_commands +
commands.plugins.commands()),
key=attrgetter('name'))
fields = sorted(set(
library.Album.all_keys() + library.Item.all_keys()))
(commands.default_commands + commands.plugins.commands()),
key=attrgetter("name"),
)
fields = sorted(set(library.Album.all_keys() + library.Item.all_keys()))
# Collect commands, their aliases, and their help text
cmd_names_help = []
for cmd in beetcmds:
@@ -115,19 +125,26 @@ class FishPlugin(BeetsPlugin):
# Concatenate the string
totstring = HEAD + "\n"
totstring += get_cmds_list([name[0] for name in cmd_names_help])
totstring += '' if nobasicfields else get_standard_fields(fields)
totstring += get_extravalues(lib, extravalues) if extravalues else ''
totstring += "\n" + "# ====== {} =====".format(
"setup basic beet completion") + "\n" * 2
totstring += "" if nobasicfields else get_standard_fields(fields)
totstring += get_extravalues(lib, extravalues) if extravalues else ""
totstring += (
"\n"
+ "# ====== {} =====".format("setup basic beet completion")
+ "\n" * 2
)
totstring += get_basic_beet_options()
totstring += "\n" + "# ====== {} =====".format(
"setup field completion for subcommands") + "\n"
totstring += get_subcommands(
cmd_names_help, nobasicfields, extravalues)
totstring += (
"\n"
+ "# ====== {} =====".format(
"setup field completion for subcommands"
)
+ "\n"
)
totstring += get_subcommands(cmd_names_help, nobasicfields, extravalues)
# Set up completion for all the command options
totstring += get_all_commands(beetcmds)
with open(completion_file_path, 'w') as fish_file:
with open(completion_file_path, "w") as fish_file:
fish_file.write(totstring)
@@ -140,32 +157,31 @@ def _escape(name):
def get_cmds_list(cmds_names):
# Make a list of all Beets core & plugin commands
substr = ''
substr += (
"set CMDS " + " ".join(cmds_names) + ("\n" * 2)
)
substr = ""
substr += "set CMDS " + " ".join(cmds_names) + ("\n" * 2)
return substr
def get_standard_fields(fields):
# Make a list of album/track fields and append with ':'
fields = (field + ":" for field in fields)
substr = ''
substr += (
"set FIELDS " + " ".join(fields) + ("\n" * 2)
)
substr = ""
substr += "set FIELDS " + " ".join(fields) + ("\n" * 2)
return substr
def get_extravalues(lib, extravalues):
# Make a list of all values from an album/track field.
# 'beet ls albumartist: <TAB>' yields completions for ABBA, Beatles, etc.
word = ''
word = ""
values_set = get_set_of_values_for_field(lib, extravalues)
for fld in extravalues:
extraname = fld.upper() + 'S'
extraname = fld.upper() + "S"
word += (
"set " + extraname + " " + " ".join(sorted(values_set[fld]))
"set "
+ extraname
+ " "
+ " ".join(sorted(values_set[fld]))
+ ("\n" * 2)
)
return word
@@ -184,21 +200,24 @@ def get_set_of_values_for_field(lib, fields):
def get_basic_beet_options():
word = (
BL_NEED2.format("-l format-item",
"-f -d 'print with custom format'") +
BL_NEED2.format("-l format-album",
"-f -d 'print with custom format'") +
BL_NEED2.format("-s l -l library",
"-f -r -d 'library database file to use'") +
BL_NEED2.format("-s d -l directory",
"-f -r -d 'destination music directory'") +
BL_NEED2.format("-s v -l verbose",
"-f -d 'print debugging information'") +
BL_NEED2.format("-s c -l config",
"-f -r -d 'path to configuration file'") +
BL_NEED2.format("-s h -l help",
"-f -d 'print this help message and exit'"))
BL_NEED2.format("-l format-item", "-f -d 'print with custom format'")
+ BL_NEED2.format("-l format-album", "-f -d 'print with custom format'")
+ BL_NEED2.format(
"-s l -l library", "-f -r -d 'library database file to use'"
)
+ BL_NEED2.format(
"-s d -l directory", "-f -r -d 'destination music directory'"
)
+ BL_NEED2.format(
"-s v -l verbose", "-f -d 'print debugging information'"
)
+ BL_NEED2.format(
"-s c -l config", "-f -r -d 'path to configuration file'"
)
+ BL_NEED2.format(
"-s h -l help", "-f -d 'print this help message and exit'"
)
)
return word
@@ -208,27 +227,35 @@ def get_subcommands(cmd_name_and_help, nobasicfields, extravalues):
for cmdname, cmdhelp in cmd_name_and_help:
cmdname = _escape(cmdname)
word += "\n" + "# ------ {} -------".format(
"fieldsetups for " + cmdname) + "\n"
word += (
BL_NEED2.format(
("-a " + cmdname),
("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))))
"\n"
+ "# ------ {} -------".format("fieldsetups for " + cmdname)
+ "\n"
)
word += BL_NEED2.format(
("-a " + cmdname), ("-f " + "-d " + wrap(clean_whitespace(cmdhelp)))
)
if nobasicfields is False:
word += (
BL_USE3.format(
cmdname,
("-a " + wrap("$FIELDS")),
("-f " + "-d " + wrap("fieldname"))))
word += BL_USE3.format(
cmdname,
("-a " + wrap("$FIELDS")),
("-f " + "-d " + wrap("fieldname")),
)
if extravalues:
for f in extravalues:
setvar = wrap("$" + f.upper() + "S")
word += " ".join(BL_EXTRA3.format(
(cmdname + " " + f + ":"),
('-f ' + '-A ' + '-a ' + setvar),
('-d ' + wrap(f))).split()) + "\n"
word += (
" ".join(
BL_EXTRA3.format(
(cmdname + " " + f + ":"),
("-f " + "-A " + "-a " + setvar),
("-d " + wrap(f)),
).split()
)
+ "\n"
)
return word
@@ -242,30 +269,59 @@ def get_all_commands(beetcmds):
name = _escape(name)
word += "\n"
word += ("\n" * 2) + "# ====== {} =====".format(
"completions for " + name) + "\n"
word += (
("\n" * 2)
+ "# ====== {} =====".format("completions for " + name)
+ "\n"
)
for option in cmd.parser._get_all_options()[1:]:
cmd_l = (" -l " + option._long_opts[0].replace('--', '')
)if option._long_opts else ''
cmd_s = (" -s " + option._short_opts[0].replace('-', '')
) if option._short_opts else ''
cmd_need_arg = ' -r ' if option.nargs in [1] else ''
cmd_helpstr = (" -d " + wrap(' '.join(option.help.split()))
) if option.help else ''
cmd_arglist = (' -a ' + wrap(" ".join(option.choices))
) if option.choices else ''
cmd_l = (
(" -l " + option._long_opts[0].replace("--", ""))
if option._long_opts
else ""
)
cmd_s = (
(" -s " + option._short_opts[0].replace("-", ""))
if option._short_opts
else ""
)
cmd_need_arg = " -r " if option.nargs in [1] else ""
cmd_helpstr = (
(" -d " + wrap(" ".join(option.help.split())))
if option.help
else ""
)
cmd_arglist = (
(" -a " + wrap(" ".join(option.choices)))
if option.choices
else ""
)
word += " ".join(BL_USE3.format(
word += (
" ".join(
BL_USE3.format(
name,
(
cmd_need_arg
+ cmd_s
+ cmd_l
+ " -f "
+ cmd_arglist
),
cmd_helpstr,
).split()
)
+ "\n"
)
word = word + " ".join(
BL_USE3.format(
name,
(cmd_need_arg + cmd_s + cmd_l + " -f " + cmd_arglist),
cmd_helpstr).split()) + "\n"
word = (word + " ".join(BL_USE3.format(
name,
("-s " + "h " + "-l " + "help" + " -f "),
('-d ' + wrap("print help") + "\n")
).split()))
("-s " + "h " + "-l " + "help" + " -f "),
("-d " + wrap("print help") + "\n"),
).split()
)
return word
@@ -276,7 +332,7 @@ def clean_whitespace(word):
def wrap(word):
# Need " or ' around strings but watch out if they're in the string
sptoken = '\"'
sptoken = '"'
if ('"') in word and ("'") in word:
word.replace('"', sptoken)
return '"' + word + '"'
+11 -6
View File
@@ -16,20 +16,25 @@
"""
from beets.plugins import BeetsPlugin
from beets import ui
from beets.plugins import BeetsPlugin
class FreedesktopPlugin(BeetsPlugin):
def commands(self):
deprecated = ui.Subcommand(
"freedesktop",
help="Print a message to redirect to thumbnails --dolphin")
help="Print a message to redirect to thumbnails --dolphin",
)
deprecated.func = self.deprecation_message
return [deprecated]
def deprecation_message(self, lib, opts, args):
ui.print_("This plugin is deprecated. Its functionality is "
"superseded by the 'thumbnails' plugin")
ui.print_("'thumbnails --dolphin' replaces freedesktop. See doc & "
"changelog for more information")
ui.print_(
"This plugin is deprecated. Its functionality is "
"superseded by the 'thumbnails' plugin"
)
ui.print_(
"'thumbnails --dolphin' replaces freedesktop. See doc & "
"changelog for more information"
)
+55 -52
View File
@@ -16,35 +16,34 @@
filename.
"""
from beets import plugins
from beets.util import displayable_path
import os
import re
from beets import plugins
from beets.util import displayable_path
# Filename field extraction patterns.
PATTERNS = [
# Useful patterns.
r'^(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$',
r'^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$',
r'^(?P<artist>.+)[\-_](?P<title>.+)$',
r'^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)$',
r'^(?P<title>.+)$',
r'^(?P<track>\d+)[\s.\-_]+(?P<title>.+)$',
r'^(?P<track>\d+)\s+(?P<title>.+)$',
r'^(?P<title>.+) by (?P<artist>.+)$',
r'^(?P<track>\d+).*$',
# Useful patterns.
r"^(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$",
r"^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)[\-_](?P<tag>.*)$",
r"^(?P<artist>.+)[\-_](?P<title>.+)$",
r"^(?P<track>\d+)[\s.\-_]+(?P<artist>.+)[\-_](?P<title>.+)$",
r"^(?P<track>\d+)[\s.\-_]+(?P<title>.+)$",
r"^(?P<track>\d+)\s+(?P<title>.+)$",
r"^(?P<title>.+) by (?P<artist>.+)$",
r"^(?P<track>\d+).*$",
r"^(?P<title>.+)$",
]
# Titles considered "empty" and in need of replacement.
BAD_TITLE_PATTERNS = [
r'^$',
r"^$",
]
def equal(seq):
"""Determine whether a sequence holds identical elements.
"""
"""Determine whether a sequence holds identical elements."""
return len(set(seq)) <= 1
@@ -85,7 +84,7 @@ def bad_title(title):
return False
def apply_matches(d):
def apply_matches(d, log):
"""Given a mapping from items to field dicts, apply the fields to
the objects.
"""
@@ -93,19 +92,19 @@ def apply_matches(d):
keys = some_map.keys()
# Only proceed if the "tag" field is equal across all filenames.
if 'tag' in keys and not equal_fields(d, 'tag'):
if "tag" in keys and not equal_fields(d, "tag"):
return
# Given both an "artist" and "title" field, assume that one is
# *actually* the artist, which must be uniform, and use the other
# for the title. This, of course, won't work for VA albums.
if 'artist' in keys:
if equal_fields(d, 'artist'):
artist = some_map['artist']
title_field = 'title'
elif equal_fields(d, 'title'):
artist = some_map['title']
title_field = 'artist'
if "artist" in keys:
if equal_fields(d, "artist"):
artist = some_map["artist"]
title_field = "title"
elif equal_fields(d, "title"):
artist = some_map["title"]
title_field = "artist"
else:
# Both vary. Abort.
return
@@ -113,50 +112,54 @@ def apply_matches(d):
for item in d:
if not item.artist:
item.artist = artist
log.info("Artist replaced with: {}".format(item.artist))
# No artist field: remaining field is the title.
else:
title_field = 'title'
title_field = "title"
# Apply the title and track.
for item in d:
if bad_title(item.title):
item.title = str(d[item][title_field])
if 'track' in d[item] and item.track == 0:
item.track = int(d[item]['track'])
log.info("Title replaced with: {}".format(item.title))
if "track" in d[item] and item.track == 0:
item.track = int(d[item]["track"])
log.info("Track replaced with: {}".format(item.track))
# Plugin structure and hook into import process.
class FromFilenamePlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('import_task_start', filename_task)
self.register_listener("import_task_start", self.filename_task)
def filename_task(self, task, session):
"""Examine each item in the task to see if we can extract a title
from the filename. Try to match all filenames to a number of
regexps, starting with the most complex patterns and successively
trying less complex patterns. As soon as all filenames match the
same regex we can make an educated guess of which part of the
regex that contains the title.
"""
items = task.items if task.is_album else [task.item]
def filename_task(task, session):
"""Examine each item in the task to see if we can extract a title
from the filename. Try to match all filenames to a number of
regexps, starting with the most complex patterns and successively
trying less complex patterns. As soon as all filenames match the
same regex we can make an educated guess of which part of the
regex that contains the title.
"""
items = task.items if task.is_album else [task.item]
# Look for suspicious (empty or meaningless) titles.
missing_titles = sum(bad_title(i.title) for i in items)
# Look for suspicious (empty or meaningless) titles.
missing_titles = sum(bad_title(i.title) for i in items)
if missing_titles:
# Get the base filenames (no path or extension).
names = {}
for item in items:
path = displayable_path(item.path)
name, _ = os.path.splitext(os.path.basename(path))
names[item] = name
if missing_titles:
# Get the base filenames (no path or extension).
names = {}
for item in items:
path = displayable_path(item.path)
name, _ = os.path.splitext(os.path.basename(path))
names[item] = name
# Look for useful information in the filenames.
for pattern in PATTERNS:
d = all_matches(names, pattern)
if d:
apply_matches(d)
# Look for useful information in the filenames.
for pattern in PATTERNS:
d = all_matches(names, pattern)
if d:
apply_matches(d, self._log)
+28 -26
View File
@@ -17,8 +17,7 @@
import re
from beets import plugins
from beets import ui
from beets import plugins, ui
from beets.util import displayable_path
@@ -38,8 +37,7 @@ def split_on_feat(artist):
def contains_feat(title):
"""Determine whether the title contains a "featured" marker.
"""
"""Determine whether the title contains a "featured" marker."""
return bool(re.search(plugins.feat_tokens(), title, flags=re.IGNORECASE))
@@ -56,7 +54,7 @@ def find_feat_part(artist, albumartist):
# If the last element of the split (the right-hand side of the
# album artist) is nonempty, then it probably contains the
# featured artist.
elif albumartist_split[1] != '':
elif albumartist_split[1] != "":
# Extract the featured artist from the right-hand side.
_, feat_part = split_on_feat(albumartist_split[1])
return feat_part
@@ -75,29 +73,34 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'auto': True,
'drop': False,
'format': 'feat. {0}',
})
self.config.add(
{
"auto": True,
"drop": False,
"format": "feat. {0}",
}
)
self._command = ui.Subcommand(
'ftintitle',
help='move featured artists to the title field')
"ftintitle", help="move featured artists to the title field"
)
self._command.parser.add_option(
'-d', '--drop', dest='drop',
action='store_true', default=None,
help='drop featuring from artists and ignore title update')
"-d",
"--drop",
dest="drop",
action="store_true",
default=None,
help="drop featuring from artists and ignore title update",
)
if self.config['auto']:
if self.config["auto"]:
self.import_stages = [self.imported]
def commands(self):
def func(lib, opts, args):
self.config.set_args(opts)
drop_feat = self.config['drop'].get(bool)
drop_feat = self.config["drop"].get(bool)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
@@ -110,9 +113,8 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
return [self._command]
def imported(self, session, task):
"""Import hook for moving featuring artist automatically.
"""
drop_feat = self.config['drop'].get(bool)
"""Import hook for moving featuring artist automatically."""
drop_feat = self.config["drop"].get(bool)
for item in task.imported_items():
self.ft_in_title(item, drop_feat)
@@ -125,7 +127,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
remove it from the artist field.
"""
# In all cases, update the artist fields.
self._log.info('artist: {0} -> {1}', item.artist, item.albumartist)
self._log.info("artist: {0} -> {1}", item.artist, item.albumartist)
item.artist = item.albumartist
if item.artist_sort:
# Just strip the featured artist from the sort name.
@@ -134,10 +136,10 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# Only update the title if it does not already contain a featured
# artist and if we do not drop featuring information.
if not drop_feat and not contains_feat(item.title):
feat_format = self.config['format'].as_str()
feat_format = self.config["format"].as_str()
new_format = feat_format.format(feat_part)
new_title = f"{item.title} {new_format}"
self._log.info('title: {0} -> {1}', item.title, new_title)
self._log.info("title: {0} -> {1}", item.title, new_title)
item.title = new_title
def ft_in_title(self, item, drop_feat):
@@ -152,7 +154,7 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
# that case, we attempt to move the featured artist to the title.
_, featured = split_on_feat(artist)
if featured and albumartist != artist and albumartist:
self._log.info('{}', displayable_path(item.path))
self._log.info("{}", displayable_path(item.path))
feat_part = None
@@ -163,4 +165,4 @@ class FtInTitlePlugin(plugins.BeetsPlugin):
if feat_part:
self.update_metadata(item, feat_part, drop_feat)
else:
self._log.info('no featuring artists found')
self._log.info("no featuring artists found")
+14 -11
View File
@@ -16,31 +16,34 @@
"""
from beets.plugins import BeetsPlugin
from beets.dbcore.query import StringFieldQuery
from beets import config
import difflib
from beets import config
from beets.dbcore.query import StringFieldQuery
from beets.plugins import BeetsPlugin
class FuzzyQuery(StringFieldQuery):
class FuzzyQuery(StringFieldQuery[str]):
@classmethod
def string_match(cls, pattern, val):
def string_match(cls, pattern: str, val: str):
# smartcase
if pattern.islower():
val = val.lower()
query_matcher = difflib.SequenceMatcher(None, pattern, val)
threshold = config['fuzzy']['threshold'].as_number()
threshold = config["fuzzy"]["threshold"].as_number()
return query_matcher.quick_ratio() >= threshold
class FuzzyPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'prefix': '~',
'threshold': 0.7,
})
self.config.add(
{
"prefix": "~",
"threshold": 0.7,
}
)
def queries(self):
prefix = self.config['prefix'].as_str()
prefix = self.config["prefix"].as_str()
return {prefix: FuzzyQuery}
+5 -3
View File
@@ -20,6 +20,8 @@ class Gmusic(BeetsPlugin):
def __init__(self):
super().__init__()
self._log.warning("The 'gmusic' plugin has been removed following the"
" shutdown of Google Play Music. Remove the plugin"
" from your configuration to silence this warning.")
self._log.warning(
"The 'gmusic' plugin has been removed following the"
" shutdown of Google Play Music. Remove the plugin"
" from your configuration to silence this warning."
)
+24 -42
View File
@@ -14,9 +14,9 @@
"""Allows custom commands to be run when an event is emitted by beets"""
import shlex
import string
import subprocess
import shlex
from beets.plugins import BeetsPlugin
from beets.util import arg_encoding
@@ -26,39 +26,20 @@ class CodingFormatter(string.Formatter):
"""A variant of `string.Formatter` that converts everything to `unicode`
strings.
This is necessary on Python 2, where formatting otherwise occurs on
bytestrings. It intercepts two points in the formatting process to decode
the format string and all fields using the specified encoding. If decoding
fails, the values are used as-is.
This was necessary on Python 2, in needs to be kept for backwards
compatibility.
"""
def __init__(self, coding):
"""Creates a new coding formatter with the provided coding."""
self._coding = coding
def format(self, format_string, *args, **kwargs):
"""Formats the provided string using the provided arguments and keyword
arguments.
This method decodes the format string using the formatter's coding.
See str.format and string.Formatter.format.
"""
if isinstance(format_string, bytes):
format_string = format_string.decode(self._coding)
return super().format(format_string, *args,
**kwargs)
def convert_field(self, value, conversion):
"""Converts the provided value given a conversion type.
This method decodes the converted value using the formatter's coding.
See string.Formatter.convert_field.
"""
converted = super().convert_field(value,
conversion)
converted = super().convert_field(value, conversion)
if isinstance(converted, bytes):
return converted.decode(self._coding)
@@ -72,17 +53,15 @@ class HookPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'hooks': []
})
self.config.add({"hooks": []})
hooks = self.config['hooks'].get(list)
hooks = self.config["hooks"].get(list)
for hook_index in range(len(hooks)):
hook = self.config['hooks'][hook_index]
hook = self.config["hooks"][hook_index]
hook_event = hook['event'].as_str()
hook_command = hook['command'].as_str()
hook_event = hook["event"].as_str()
hook_command = hook["command"].as_str()
self.create_and_register_hook(hook_event, hook_command)
@@ -92,24 +71,27 @@ class HookPlugin(BeetsPlugin):
self._log.error('invalid command "{0}"', command)
return
# Use a string formatter that works on Unicode strings.
# For backwards compatibility, use a string formatter that decodes
# bytes (in particular, paths) to unicode strings.
formatter = CodingFormatter(arg_encoding())
command_pieces = [
formatter.format(piece, event=event, **kwargs)
for piece in shlex.split(command)
]
command_pieces = shlex.split(command)
for i, piece in enumerate(command_pieces):
command_pieces[i] = formatter.format(piece, event=event,
**kwargs)
self._log.debug('running command "{0}" for event {1}',
' '.join(command_pieces), event)
self._log.debug(
'running command "{0}" for event {1}',
" ".join(command_pieces),
event,
)
try:
subprocess.check_call(command_pieces)
except subprocess.CalledProcessError as exc:
self._log.error('hook for {0} exited with status {1}',
event, exc.returncode)
self._log.error(
"hook for {0} exited with status {1}", event, exc.returncode
)
except OSError as exc:
self._log.error('hook for {0} failed: {1}', event, exc)
self._log.error("hook for {0} failed: {1}", event, exc)
self.register_listener(event, hook_function)
+22 -22
View File
@@ -15,15 +15,12 @@
"""Warns you about things you hate (or even blocks import)."""
from beets.plugins import BeetsPlugin
from beets.importer import action
from beets.library import parse_query_string
from beets.library import Item
from beets.library import Album
from beets.library import Album, Item, parse_query_string
from beets.plugins import BeetsPlugin
__author__ = 'baobab@heresiarch.info'
__version__ = '2.0'
__author__ = "baobab@heresiarch.info"
__version__ = "2.0"
def summary(task):
@@ -31,20 +28,23 @@ def summary(task):
object.
"""
if task.is_album:
return f'{task.cur_artist} - {task.cur_album}'
return f"{task.cur_artist} - {task.cur_album}"
else:
return f'{task.item.artist} - {task.item.title}'
return f"{task.item.artist} - {task.item.title}"
class IHatePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('import_task_choice',
self.import_task_choice_event)
self.config.add({
'warn': [],
'skip': [],
})
self.register_listener(
"import_task_choice", self.import_task_choice_event
)
self.config.add(
{
"warn": [],
"skip": [],
}
)
@classmethod
def do_i_hate_this(cls, task, action_patterns):
@@ -62,19 +62,19 @@ class IHatePlugin(BeetsPlugin):
return False
def import_task_choice_event(self, session, task):
skip_queries = self.config['skip'].as_str_seq()
warn_queries = self.config['warn'].as_str_seq()
skip_queries = self.config["skip"].as_str_seq()
warn_queries = self.config["warn"].as_str_seq()
if task.choice_flag == action.APPLY:
if skip_queries or warn_queries:
self._log.debug('processing your hate')
self._log.debug("processing your hate")
if self.do_i_hate_this(task, skip_queries):
task.choice_flag = action.SKIP
self._log.info('skipped: {0}', summary(task))
self._log.info("skipped: {0}", summary(task))
return
if self.do_i_hate_this(task, warn_queries):
self._log.info('you may hate this: {0}', summary(task))
self._log.info("you may hate this: {0}", summary(task))
else:
self._log.debug('nothing to do')
self._log.debug("nothing to do")
else:
self._log.debug('user made a decision, nothing to do')
self._log.debug("user made a decision, nothing to do")
+75 -47
View File
@@ -6,18 +6,19 @@ Reimported albums and items are skipped.
import os
from beets import util
from beets import importer
from beets import importer, util
from beets.plugins import BeetsPlugin
class ImportAddedPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'preserve_mtimes': False,
'preserve_write_mtimes': False,
})
self.config.add(
{
"preserve_mtimes": False,
"preserve_write_mtimes": False,
}
)
# item.id for new items that were reimported
self.reimported_item_ids = None
@@ -27,19 +28,20 @@ class ImportAddedPlugin(BeetsPlugin):
self.item_mtime = {}
register = self.register_listener
register('import_task_created', self.check_config)
register('import_task_created', self.record_if_inplace)
register('import_task_files', self.record_reimported)
register('before_item_moved', self.record_import_mtime)
register('item_copied', self.record_import_mtime)
register('item_linked', self.record_import_mtime)
register('item_hardlinked', self.record_import_mtime)
register('album_imported', self.update_album_times)
register('item_imported', self.update_item_times)
register('after_write', self.update_after_write_time)
register("import_task_created", self.check_config)
register("import_task_created", self.record_if_inplace)
register("import_task_files", self.record_reimported)
register("before_item_moved", self.record_import_mtime)
register("item_copied", self.record_import_mtime)
register("item_linked", self.record_import_mtime)
register("item_hardlinked", self.record_import_mtime)
register("item_reflinked", self.record_import_mtime)
register("album_imported", self.update_album_times)
register("item_imported", self.update_item_times)
register("after_write", self.update_after_write_time)
def check_config(self, task, session):
self.config['preserve_mtimes'].get(bool)
self.config["preserve_mtimes"].get(bool)
def reimported_item(self, item):
return item.id in self.reimported_item_ids
@@ -48,25 +50,35 @@ class ImportAddedPlugin(BeetsPlugin):
return album.path in self.replaced_album_paths
def record_if_inplace(self, task, session):
if not (session.config['copy'] or session.config['move'] or
session.config['link'] or session.config['hardlink']):
self._log.debug("In place import detected, recording mtimes from "
"source paths")
items = [task.item] \
if isinstance(task, importer.SingletonImportTask) \
if not (
session.config["copy"]
or session.config["move"]
or session.config["link"]
or session.config["hardlink"]
or session.config["reflink"]
):
self._log.debug(
"In place import detected, recording mtimes from "
"source paths"
)
items = (
[task.item]
if isinstance(task, importer.SingletonImportTask)
else task.items
)
for item in items:
self.record_import_mtime(item, item.path, item.path)
def record_reimported(self, task, session):
self.reimported_item_ids = {item.id for item, replaced_items
in task.replaced_items.items()
if replaced_items}
self.reimported_item_ids = {
item.id
for item, replaced_items in task.replaced_items.items()
if replaced_items
}
self.replaced_album_paths = set(task.replaced_albums.keys())
def write_file_mtime(self, path, mtime):
"""Write the given mtime to the destination path.
"""
"""Write the given mtime to the destination path."""
stat = os.stat(util.syspath(path))
os.utime(util.syspath(path), (stat.st_atime, mtime))
@@ -79,19 +91,23 @@ class ImportAddedPlugin(BeetsPlugin):
item.mtime = mtime
def record_import_mtime(self, item, source, destination):
"""Record the file mtime of an item's path before its import.
"""
"""Record the file mtime of an item's path before its import."""
mtime = os.stat(util.syspath(source)).st_mtime
self.item_mtime[destination] = mtime
self._log.debug("Recorded mtime {0} for item '{1}' imported from "
"'{2}'", mtime, util.displayable_path(destination),
util.displayable_path(source))
self._log.debug(
"Recorded mtime {0} for item '{1}' imported from " "'{2}'",
mtime,
util.displayable_path(destination),
util.displayable_path(source),
)
def update_album_times(self, lib, album):
if self.reimported_album(album):
self._log.debug("Album '{0}' is reimported, skipping import of "
"added dates for the album and its items.",
util.displayable_path(album.path))
self._log.debug(
"Album '{0}' is reimported, skipping import of "
"added dates for the album and its items.",
util.displayable_path(album.path),
)
return
album_mtimes = []
@@ -99,26 +115,35 @@ class ImportAddedPlugin(BeetsPlugin):
mtime = self.item_mtime.pop(item.path, None)
if mtime:
album_mtimes.append(mtime)
if self.config['preserve_mtimes'].get(bool):
if self.config["preserve_mtimes"].get(bool):
self.write_item_mtime(item, mtime)
item.store()
album.added = min(album_mtimes)
self._log.debug("Import of album '{0}', selected album.added={1} "
"from item file mtimes.", album.album, album.added)
self._log.debug(
"Import of album '{0}', selected album.added={1} "
"from item file mtimes.",
album.album,
album.added,
)
album.store()
def update_item_times(self, lib, item):
if self.reimported_item(item):
self._log.debug("Item '{0}' is reimported, skipping import of "
"added date.", util.displayable_path(item.path))
self._log.debug(
"Item '{0}' is reimported, skipping import of " "added date.",
util.displayable_path(item.path),
)
return
mtime = self.item_mtime.pop(item.path, None)
if mtime:
item.added = mtime
if self.config['preserve_mtimes'].get(bool):
if self.config["preserve_mtimes"].get(bool):
self.write_item_mtime(item, mtime)
self._log.debug("Import of item '{0}', selected item.added={1}",
util.displayable_path(item.path), item.added)
self._log.debug(
"Import of item '{0}', selected item.added={1}",
util.displayable_path(item.path),
item.added,
)
item.store()
def update_after_write_time(self, item, path):
@@ -126,7 +151,10 @@ class ImportAddedPlugin(BeetsPlugin):
after each write of the item if `preserve_write_mtimes` is enabled.
"""
if item.added:
if self.config['preserve_write_mtimes'].get(bool):
if self.config["preserve_write_mtimes"].get(bool):
self.write_item_mtime(item, item.added)
self._log.debug("Write of item '{0}', selected item.added={1}",
util.displayable_path(item.path), item.added)
self._log.debug(
"Write of item '{0}', selected item.added={1}",
util.displayable_path(item.path),
item.added,
)
+63 -39
View File
@@ -21,74 +21,88 @@ import datetime
import os
import re
from beets.plugins import BeetsPlugin
from beets.util import mkdirall, normpath, syspath, bytestring_path, link
from beets import config
from beets.plugins import BeetsPlugin
from beets.util import bytestring_path, link, mkdirall, normpath, syspath
M3U_DEFAULT_NAME = 'imported.m3u'
M3U_DEFAULT_NAME = "imported.m3u"
def _build_m3u_session_filename(basename):
"""Builds unique m3u filename by putting current date between given
basename and file ending."""
date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M")
basename = re.sub(r"(\.m3u|\.M3U)", "", basename)
path = normpath(
os.path.join(
config["importfeeds"]["dir"].as_filename(), f"{basename}_{date}.m3u"
)
)
return path
def _build_m3u_filename(basename):
"""Builds unique m3u filename by appending given basename to current
date."""
basename = re.sub(r"[\s,/\\'\"]", '_', basename)
basename = re.sub(r"[\s,/\\'\"]", "_", basename)
date = datetime.datetime.now().strftime("%Y%m%d_%Hh%M")
path = normpath(os.path.join(
config['importfeeds']['dir'].as_filename(),
date + '_' + basename + '.m3u'
))
path = normpath(
os.path.join(
config["importfeeds"]["dir"].as_filename(),
date + "_" + basename + ".m3u",
)
)
return path
def _write_m3u(m3u_path, items_paths):
"""Append relative paths to items into m3u file.
"""
"""Append relative paths to items into m3u file."""
mkdirall(m3u_path)
with open(syspath(m3u_path), 'ab') as f:
with open(syspath(m3u_path), "ab") as f:
for path in items_paths:
f.write(path + b'\n')
f.write(path + b"\n")
class ImportFeedsPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'formats': [],
'm3u_name': 'imported.m3u',
'dir': None,
'relative_to': None,
'absolute_path': False,
})
self.config.add(
{
"formats": [],
"m3u_name": "imported.m3u",
"dir": None,
"relative_to": None,
"absolute_path": False,
}
)
relative_to = self.config['relative_to'].get()
relative_to = self.config["relative_to"].get()
if relative_to:
self.config['relative_to'] = normpath(relative_to)
self.config["relative_to"] = normpath(relative_to)
else:
self.config['relative_to'] = self.get_feeds_dir()
self.config["relative_to"] = self.get_feeds_dir()
self.register_listener('album_imported', self.album_imported)
self.register_listener('item_imported', self.item_imported)
self.register_listener("album_imported", self.album_imported)
self.register_listener("item_imported", self.item_imported)
self.register_listener("import_begin", self.import_begin)
def get_feeds_dir(self):
feeds_dir = self.config['dir'].get()
feeds_dir = self.config["dir"].get()
if feeds_dir:
return os.path.expanduser(bytestring_path(feeds_dir))
return config['directory'].as_filename()
return config["directory"].as_filename()
def _record_items(self, lib, basename, items):
"""Records relative paths to the given items for each feed format
"""
"""Records relative paths to the given items for each feed format"""
feedsdir = bytestring_path(self.get_feeds_dir())
formats = self.config['formats'].as_str_seq()
relative_to = self.config['relative_to'].get() \
or self.get_feeds_dir()
formats = self.config["formats"].as_str_seq()
relative_to = self.config["relative_to"].get() or self.get_feeds_dir()
relative_to = bytestring_path(relative_to)
paths = []
for item in items:
if self.config['absolute_path']:
if self.config["absolute_path"]:
paths.append(item.path)
else:
try:
@@ -99,23 +113,26 @@ class ImportFeedsPlugin(BeetsPlugin):
relpath = item.path
paths.append(relpath)
if 'm3u' in formats:
m3u_basename = bytestring_path(
self.config['m3u_name'].as_str())
if "m3u" in formats:
m3u_basename = bytestring_path(self.config["m3u_name"].as_str())
m3u_path = os.path.join(feedsdir, m3u_basename)
_write_m3u(m3u_path, paths)
if 'm3u_multi' in formats:
if "m3u_session" in formats:
m3u_path = os.path.join(feedsdir, self.m3u_session)
_write_m3u(m3u_path, paths)
if "m3u_multi" in formats:
m3u_path = _build_m3u_filename(basename)
_write_m3u(m3u_path, paths)
if 'link' in formats:
if "link" in formats:
for path in paths:
dest = os.path.join(feedsdir, os.path.basename(path))
if not os.path.exists(syspath(dest)):
link(path, dest)
if 'echo' in formats:
if "echo" in formats:
self._log.info("Location of imported music:")
for path in paths:
self._log.info(" {0}", path)
@@ -125,3 +142,10 @@ class ImportFeedsPlugin(BeetsPlugin):
def item_imported(self, lib, item):
self._record_items(lib, item.title, [item])
def import_begin(self, session):
formats = self.config["formats"].as_str_seq()
if "m3u_session" in formats:
self.m3u_session = _build_m3u_session_filename(
self.config["m3u_name"].as_str()
)
+46 -33
View File
@@ -18,10 +18,11 @@
import os
from beets.plugins import BeetsPlugin
from beets import ui
import mediafile
from beets import ui
from beets.library import Item
from beets.plugins import BeetsPlugin
from beets.util import displayable_path, normpath, syspath
@@ -41,23 +42,23 @@ def tag_data(lib, args, album=False):
def tag_fields():
fields = set(mediafile.MediaFile.readable_fields())
fields.add('art')
fields.add("art")
return fields
def tag_data_emitter(path):
def emitter(included_keys):
if included_keys == '*':
if included_keys == "*":
fields = tag_fields()
else:
fields = included_keys
if 'images' in fields:
if "images" in fields:
# We can't serialize the image data.
fields.remove('images')
fields.remove("images")
mf = mediafile.MediaFile(syspath(path))
tags = {}
for field in fields:
if field == 'art':
if field == "art":
tags[field] = mf.art is not None
else:
tags[field] = getattr(mf, field, None)
@@ -66,6 +67,7 @@ def tag_data_emitter(path):
item = Item.from_path(syspath(path))
return tags, item
return emitter
@@ -79,6 +81,7 @@ def library_data_emitter(item):
data = dict(item.formatted(included_keys=included_keys))
return data, item
return emitter
@@ -87,7 +90,7 @@ def update_summary(summary, tags):
if key not in summary:
summary[key] = value
elif summary[key] != value:
summary[key] = '[various]'
summary[key] = "[various]"
return summary
@@ -108,7 +111,7 @@ def print_data(data, item=None, fmt=None):
formatted = {}
for key, value in data.items():
if isinstance(value, list):
formatted[key] = '; '.join(value)
formatted[key] = "; ".join(value)
if value is not None:
formatted[key] = value
@@ -116,7 +119,7 @@ def print_data(data, item=None, fmt=None):
return
maxwidth = max(len(key) for key in formatted)
lineformat = f'{{0:>{maxwidth}}}: {{1}}'
lineformat = f"{{0:>{maxwidth}}}: {{1}}"
if path:
ui.print_(displayable_path(path))
@@ -124,13 +127,12 @@ def print_data(data, item=None, fmt=None):
for field in sorted(formatted):
value = formatted[field]
if isinstance(value, list):
value = '; '.join(value)
value = "; ".join(value)
ui.print_(lineformat.format(field, value))
def print_data_keys(data, item=None):
"""Print only the keys (field names) for an item.
"""
"""Print only the keys (field names) for an item."""
path = displayable_path(item.path) if item else None
formatted = []
for key, value in data.items():
@@ -139,7 +141,7 @@ def print_data_keys(data, item=None):
if len(formatted) == 0:
return
line_format = '{0}{{0}}'.format(' ' * 4)
line_format = "{0}{{0}}".format(" " * 4)
if path:
ui.print_(displayable_path(path))
@@ -148,32 +150,42 @@ def print_data_keys(data, item=None):
class InfoPlugin(BeetsPlugin):
def commands(self):
cmd = ui.Subcommand('info', help='show file metadata')
cmd = ui.Subcommand("info", help="show file metadata")
cmd.func = self.run
cmd.parser.add_option(
'-l', '--library', action='store_true',
help='show library fields instead of tags',
"-l",
"--library",
action="store_true",
help="show library fields instead of tags",
)
cmd.parser.add_option(
'-a', '--album', action='store_true',
"-a",
"--album",
action="store_true",
help='show album fields instead of tracks (implies "--library")',
)
cmd.parser.add_option(
'-s', '--summarize', action='store_true',
help='summarize the tags of all files',
"-s",
"--summarize",
action="store_true",
help="summarize the tags of all files",
)
cmd.parser.add_option(
'-i', '--include-keys', default=[],
action='append', dest='included_keys',
help='comma separated list of keys to show',
"-i",
"--include-keys",
default=[],
action="append",
dest="included_keys",
help="comma separated list of keys to show",
)
cmd.parser.add_option(
'-k', '--keys-only', action='store_true',
help='show only the keys',
"-k",
"--keys-only",
action="store_true",
help="show only the keys",
)
cmd.parser.add_format_option(target='item')
cmd.parser.add_format_option(target="item")
return [cmd]
def run(self, lib, opts, args):
@@ -197,20 +209,21 @@ class InfoPlugin(BeetsPlugin):
included_keys = []
for keys in opts.included_keys:
included_keys.extend(keys.split(','))
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']
included_keys = [k for k in included_keys if k != "path"]
first = True
summary = {}
for data_emitter in data_collector(
lib, ui.decargs(args),
album=opts.album,
lib,
ui.decargs(args),
album=opts.album,
):
try:
data, item = data_emitter(included_keys or '*')
data, item = data_emitter(included_keys or "*")
except (mediafile.UnreadableFileError, OSError) as ex:
self._log.error('cannot read file: {0}', ex)
self._log.error("cannot read file: {0}", ex)
continue
if opts.summarize:
+30 -26
View File
@@ -15,22 +15,22 @@
"""Allows inline path template customization code in the config file.
"""
import traceback
import itertools
import traceback
from beets.plugins import BeetsPlugin
from beets import config
from beets.plugins import BeetsPlugin
FUNC_NAME = '__INLINE_FUNC__'
FUNC_NAME = "__INLINE_FUNC__"
class InlineError(Exception):
"""Raised when a runtime error occurs in an inline expression.
"""
"""Raised when a runtime error occurs in an inline expression."""
def __init__(self, code, exc):
super().__init__(
("error in inline path field code:\n"
"%s\n%s: %s") % (code, type(exc).__name__, str(exc))
("error in inline path field code:\n" "%s\n%s: %s")
% (code, type(exc).__name__, str(exc))
)
@@ -38,11 +38,8 @@ def _compile_func(body):
"""Given Python code for a function body, return a compiled
callable that invokes that code.
"""
body = 'def {}():\n {}'.format(
FUNC_NAME,
body.replace('\n', '\n ')
)
code = compile(body, 'inline', 'exec')
body = "def {}():\n {}".format(FUNC_NAME, body.replace("\n", "\n "))
code = compile(body, "inline", "exec")
env = {}
eval(code, env)
return env[FUNC_NAME]
@@ -52,23 +49,26 @@ class InlinePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
config.add({
'pathfields': {}, # Legacy name.
'item_fields': {},
'album_fields': {},
})
config.add(
{
"pathfields": {}, # Legacy name.
"item_fields": {},
"album_fields": {},
}
)
# Item fields.
for key, view in itertools.chain(config['item_fields'].items(),
config['pathfields'].items()):
self._log.debug('adding item field {0}', key)
for key, view in itertools.chain(
config["item_fields"].items(), config["pathfields"].items()
):
self._log.debug("adding item field {0}", key)
func = self.compile_inline(view.as_str(), False)
if func is not None:
self.template_fields[key] = func
# Album fields.
for key, view in config['album_fields'].items():
self._log.debug('adding album field {0}', key)
for key, view in config["album_fields"].items():
self._log.debug("adding album field {0}", key)
func = self.compile_inline(view.as_str(), True)
if func is not None:
self.album_template_fields[key] = func
@@ -81,14 +81,16 @@ class InlinePlugin(BeetsPlugin):
"""
# First, try compiling as a single function.
try:
code = compile(f'({python_code})', 'inline', 'eval')
code = compile(f"({python_code})", "inline", "eval")
except SyntaxError:
# Fall back to a function body.
try:
func = _compile_func(python_code)
except SyntaxError:
self._log.error('syntax error in inline field definition:\n'
'{0}', traceback.format_exc())
self._log.error(
"syntax error in inline field definition:\n" "{0}",
traceback.format_exc(),
)
return
else:
is_expr = False
@@ -98,7 +100,7 @@ class InlinePlugin(BeetsPlugin):
def _dict_for(obj):
out = dict(obj)
if album:
out['items'] = list(obj.items())
out["items"] = list(obj.items())
return out
if is_expr:
@@ -109,6 +111,7 @@ class InlinePlugin(BeetsPlugin):
return eval(code, values)
except Exception as exc:
raise InlineError(python_code, exc)
return _expr_func
else:
# For function bodies, invoke the function with values as global
@@ -123,4 +126,5 @@ class InlinePlugin(BeetsPlugin):
finally:
func.__globals__.clear()
func.__globals__.update(old_globals)
return _func_func
+73 -51
View File
@@ -15,55 +15,73 @@
"""
from beets import ui, util, library, config
from beets.plugins import BeetsPlugin
import subprocess
import shutil
import os
import shutil
import subprocess
import tempfile
from beets import config, library, ui, util
from beets.plugins import BeetsPlugin
from beets.util import syspath
class IPFSPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'auto': True,
'nocopy': False,
})
self.config.add(
{
"auto": True,
"nocopy": False,
}
)
if self.config['auto']:
if self.config["auto"]:
self.import_stages = [self.auto_add]
def commands(self):
cmd = ui.Subcommand('ipfs',
help='interact with ipfs')
cmd.parser.add_option('-a', '--add', dest='add',
action='store_true',
help='Add to ipfs')
cmd.parser.add_option('-g', '--get', dest='get',
action='store_true',
help='Get from ipfs')
cmd.parser.add_option('-p', '--publish', dest='publish',
action='store_true',
help='Publish local library to ipfs')
cmd.parser.add_option('-i', '--import', dest='_import',
action='store_true',
help='Import remote library from ipfs')
cmd.parser.add_option('-l', '--list', dest='_list',
action='store_true',
help='Query imported libraries')
cmd.parser.add_option('-m', '--play', dest='play',
action='store_true',
help='Play music from remote libraries')
cmd = ui.Subcommand("ipfs", help="interact with ipfs")
cmd.parser.add_option(
"-a", "--add", dest="add", action="store_true", help="Add to ipfs"
)
cmd.parser.add_option(
"-g", "--get", dest="get", action="store_true", help="Get from ipfs"
)
cmd.parser.add_option(
"-p",
"--publish",
dest="publish",
action="store_true",
help="Publish local library to ipfs",
)
cmd.parser.add_option(
"-i",
"--import",
dest="_import",
action="store_true",
help="Import remote library from ipfs",
)
cmd.parser.add_option(
"-l",
"--list",
dest="_list",
action="store_true",
help="Query imported libraries",
)
cmd.parser.add_option(
"-m",
"--play",
dest="play",
action="store_true",
help="Play music from remote libraries",
)
def func(lib, opts, args):
if opts.add:
for album in lib.albums(ui.decargs(args)):
if len(album.items()) == 0:
self._log.info('{0} does not contain items, aborting',
album)
self._log.info(
"{0} does not contain items, aborting", album
)
self.ipfs_add(album)
album.store()
@@ -96,7 +114,7 @@ class IPFSPlugin(BeetsPlugin):
jlib = self.get_remote_lib(lib)
player = PlayPlugin()
config['play']['relative_to'] = None
config["play"]["relative_to"] = None
player.album = True
player.play_music(jlib, player, args)
@@ -107,15 +125,15 @@ class IPFSPlugin(BeetsPlugin):
return False
try:
if album.ipfs:
self._log.debug('{0} already added', album_dir)
self._log.debug("{0} already added", album_dir)
# Already added to ipfs
return False
except AttributeError:
pass
self._log.info('Adding {0} to ipfs', album_dir)
self._log.info("Adding {0} to ipfs", album_dir)
if self.config['nocopy']:
if self.config["nocopy"]:
cmd = "ipfs add --nocopy -q -r".split()
else:
cmd = "ipfs add -q -r".split()
@@ -123,7 +141,7 @@ class IPFSPlugin(BeetsPlugin):
try:
output = util.command_output(cmd).stdout.split()
except (OSError, subprocess.CalledProcessError) as exc:
self._log.error('Failed to add {0}, error: {1}', album_dir, exc)
self._log.error("Failed to add {0}, error: {1}", album_dir, exc)
return False
length = len(output)
@@ -164,21 +182,26 @@ class IPFSPlugin(BeetsPlugin):
cmd.append(_hash)
util.command_output(cmd)
except (OSError, subprocess.CalledProcessError) as err:
self._log.error('Failed to get {0} from ipfs.\n{1}',
_hash, err.output)
self._log.error(
"Failed to get {0} from ipfs.\n{1}", _hash, err.output
)
return False
self._log.info('Getting {0} from ipfs', _hash)
imp = ui.commands.TerminalImportSession(lib, loghandler=None,
query=None, paths=[_hash])
self._log.info("Getting {0} from ipfs", _hash)
imp = ui.commands.TerminalImportSession(
lib, loghandler=None, query=None, paths=[_hash]
)
imp.run()
shutil.rmtree(_hash)
# This uses a relative path, hence we cannot use util.syspath(_hash,
# prefix=True). However, that should be fine since the hash will not
# exceed MAX_PATH.
shutil.rmtree(syspath(_hash, prefix=False))
def ipfs_publish(self, lib):
with tempfile.NamedTemporaryFile() as tmp:
self.ipfs_added_albums(lib, tmp.name)
try:
if self.config['nocopy']:
if self.config["nocopy"]:
cmd = "ipfs add --nocopy -q ".split()
else:
cmd = "ipfs add -q ".split()
@@ -236,7 +259,7 @@ class IPFSPlugin(BeetsPlugin):
return False
def ipfs_list(self, lib, args):
fmt = config['format_album'].get()
fmt = config["format_album"].get()
try:
albums = self.query(lib, args)
except OSError:
@@ -260,8 +283,7 @@ class IPFSPlugin(BeetsPlugin):
return library.Library(path)
def ipfs_added_albums(self, rlib, tmpname):
""" Returns a new library with only albums/items added to ipfs
"""
"""Returns a new library with only albums/items added to ipfs"""
tmplib = library.Library(tmpname)
for album in rlib.albums():
try:
@@ -280,10 +302,10 @@ class IPFSPlugin(BeetsPlugin):
except AttributeError:
pass
item_path = os.path.basename(item.path).decode(
util._fsencoding(), 'ignore'
util._fsencoding(), "ignore"
)
# Clear current path from item
item.path = f'/ipfs/{album.ipfs}/{item_path}'
item.path = f"/ipfs/{album.ipfs}/{item_path}"
item.id = None
items.append(item)
@@ -292,4 +314,4 @@ class IPFSPlugin(BeetsPlugin):
self._log.info("Adding '{0}' to temporary library", album)
new_album = tmplib.add_album(items)
new_album.ipfs = album.ipfs
new_album.store()
new_album.store(inherit=False)
+30 -31
View File
@@ -19,27 +19,28 @@
import os.path
import subprocess
from beets import ui
from beets import util
from beets import ui, util
from beets.plugins import BeetsPlugin
class KeyFinderPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'bin': 'KeyFinder',
'auto': True,
'overwrite': False,
})
self.config.add(
{
"bin": "KeyFinder",
"auto": True,
"overwrite": False,
}
)
if self.config['auto'].get(bool):
if self.config["auto"].get(bool):
self.import_stages = [self.imported]
def commands(self):
cmd = ui.Subcommand('keyfinder',
help='detect and add initial key from audio')
cmd = ui.Subcommand(
"keyfinder", help="detect and add initial key from audio"
)
cmd.func = self.command
return [cmd]
@@ -50,28 +51,23 @@ class KeyFinderPlugin(BeetsPlugin):
self.find_key(task.imported_items())
def find_key(self, items, write=False):
overwrite = self.config['overwrite'].get(bool)
command = [self.config['bin'].as_str()]
overwrite = self.config["overwrite"].get(bool)
command = [self.config["bin"].as_str()]
# The KeyFinder GUI program needs the -f flag before the path.
# keyfinder-cli is similar, but just wants the path with no flag.
if 'keyfinder-cli' not in os.path.basename(command[0]).lower():
command.append('-f')
if "keyfinder-cli" not in os.path.basename(command[0]).lower():
command.append("-f")
for item in items:
if item['initial_key'] and not overwrite:
if item["initial_key"] and not overwrite:
continue
try:
output = util.command_output(command + [util.syspath(
item.path)]).stdout
output = util.command_output(
command + [util.syspath(item.path)]
).stdout
except (subprocess.CalledProcessError, OSError) as exc:
self._log.error('execution failed: {0}', exc)
continue
except UnicodeEncodeError:
# Workaround for Python 2 Windows bug.
# https://bugs.python.org/issue1759845
self._log.error('execution failed for Unicode path: {0!r}',
item.path)
self._log.error("execution failed: {0}", exc)
continue
try:
@@ -79,18 +75,21 @@ class KeyFinderPlugin(BeetsPlugin):
except IndexError:
# Sometimes keyfinder-cli returns 0 but with no key, usually
# when the file is silent or corrupt, so we log and skip.
self._log.error('no key returned for path: {0}', item.path)
self._log.error("no key returned for path: {0}", item.path)
continue
try:
key = util.text_string(key_raw)
key = key_raw.decode("utf-8")
except UnicodeDecodeError:
self._log.error('output is invalid UTF-8')
self._log.error("output is invalid UTF-8")
continue
item['initial_key'] = key
self._log.info('added computed initial key {0} for {1}',
key, util.displayable_path(item.path))
item["initial_key"] = key
self._log.info(
"added computed initial key {0} for {1}",
key,
util.displayable_path(item.path),
)
if write:
item.try_write()
+44 -34
View File
@@ -24,27 +24,29 @@ Put something like the following in your config.yaml to configure:
"""
import requests
from beets import config
from beets.plugins import BeetsPlugin
def update_kodi(host, port, user, password):
"""Sends request to the Kodi api to start a library refresh.
"""
"""Sends request to the Kodi api to start a library refresh."""
url = f"http://{host}:{port}/jsonrpc"
"""Content-Type: application/json is mandatory
according to the kodi jsonrpc documentation"""
headers = {'Content-Type': 'application/json'}
headers = {"Content-Type": "application/json"}
# Create the payload. Id seems to be mandatory.
payload = {'jsonrpc': '2.0', 'method': 'AudioLibrary.Scan', 'id': 1}
payload = {"jsonrpc": "2.0", "method": "AudioLibrary.Scan", "id": 1}
r = requests.post(
url,
auth=(user, password),
json=payload,
headers=headers)
headers=headers,
timeout=10,
)
return r
@@ -54,42 +56,50 @@ class KodiUpdate(BeetsPlugin):
super().__init__()
# Adding defaults.
config['kodi'].add({
'host': 'localhost',
'port': 8080,
'user': 'kodi',
'pwd': 'kodi'})
config["kodi"].add(
[{"host": "localhost", "port": 8080, "user": "kodi", "pwd": "kodi"}]
)
config['kodi']['pwd'].redact = True
self.register_listener('database_change', self.listen_for_db_change)
config["kodi"]["pwd"].redact = True
self.register_listener("database_change", self.listen_for_db_change)
def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update"""
self.register_listener('cli_exit', self.update)
self.register_listener("cli_exit", self.update)
def update(self, lib):
"""When the client exists try to send refresh request to Kodi server.
"""
self._log.info('Requesting a Kodi library update...')
"""When the client exists try to send refresh request to Kodi server."""
self._log.info("Requesting a Kodi library update...")
# Try to send update request.
try:
r = update_kodi(
config['kodi']['host'].get(),
config['kodi']['port'].get(),
config['kodi']['user'].get(),
config['kodi']['pwd'].get())
r.raise_for_status()
kodi = config["kodi"].get()
except requests.exceptions.RequestException as e:
self._log.warning('Kodi update failed: {0}',
str(e))
return
# Backwards compatibility in case not configured as an array
if not isinstance(kodi, list):
kodi = [kodi]
json = r.json()
if json.get('result') != 'OK':
self._log.warning('Kodi update failed: JSON response was {0!r}',
json)
return
for instance in kodi:
# Try to send update request.
try:
r = update_kodi(
instance["host"],
instance["port"],
instance["user"],
instance["pwd"],
)
r.raise_for_status()
self._log.info('Kodi update triggered')
json = r.json()
if json.get("result") != "OK":
self._log.warning(
"Kodi update failed: JSON response was {0!r}", json
)
continue
self._log.info(
"Kodi update triggered for {0}:{1}",
instance["host"],
instance["port"],
)
except requests.exceptions.RequestException as e:
self._log.warning("Kodi update failed: {0}", str(e))
continue
+131 -116
View File
@@ -21,18 +21,15 @@ and has been edited to remove some questionable entries.
The scraper script used is available here:
https://gist.github.com/1241307
"""
import pylast
import codecs
import os
import yaml
import traceback
from beets import plugins
from beets import ui
from beets import config
from beets.util import normpath, plurality
from beets import library
import pylast
import yaml
from beets import config, library, plugins, ui
from beets.util import normpath, plurality
LASTFM = pylast.LastFMNetwork(api_key=plugins.LASTFM_KEY)
@@ -43,19 +40,19 @@ PYLAST_EXCEPTIONS = (
)
REPLACE = {
'\u2010': '-',
"\u2010": "-",
}
def deduplicate(seq):
"""Remove duplicates from sequence wile preserving order.
"""
"""Remove duplicates from sequence while preserving order."""
seen = set()
return [x for x in seq if x not in seen and not seen.add(x)]
# Canonicalization tree processing.
def flatten_tree(elem, path, branches):
"""Flatten nested lists/dictionaries into lists of strings
(branches).
@@ -64,7 +61,7 @@ def flatten_tree(elem, path, branches):
path = []
if isinstance(elem, dict):
for (k, v) in elem.items():
for k, v in elem.items():
flatten_tree(v, path + [k], branches)
elif isinstance(elem, list):
for sub in elem:
@@ -80,7 +77,7 @@ def find_parents(candidate, branches):
for branch in branches:
try:
idx = branch.index(candidate.lower())
return list(reversed(branch[:idx + 1]))
return list(reversed(branch[: idx + 1]))
except ValueError:
continue
return [candidate]
@@ -88,68 +85,69 @@ def find_parents(candidate, branches):
# Main plugin logic.
WHITELIST = os.path.join(os.path.dirname(__file__), 'genres.txt')
C14N_TREE = os.path.join(os.path.dirname(__file__), 'genres-tree.yaml')
WHITELIST = os.path.join(os.path.dirname(__file__), "genres.txt")
C14N_TREE = os.path.join(os.path.dirname(__file__), "genres-tree.yaml")
class LastGenrePlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'whitelist': True,
'min_weight': 10,
'count': 1,
'fallback': None,
'canonical': False,
'source': 'album',
'force': True,
'auto': True,
'separator': ', ',
'prefer_specific': False,
'title_case': True,
})
self.config.add(
{
"whitelist": True,
"min_weight": 10,
"count": 1,
"fallback": None,
"canonical": False,
"source": "album",
"force": True,
"auto": True,
"separator": ", ",
"prefer_specific": False,
"title_case": True,
}
)
self.setup()
def setup(self):
"""Setup plugin from config options
"""
if self.config['auto']:
"""Setup plugin from config options"""
if self.config["auto"]:
self.import_stages = [self.imported]
self._genre_cache = {}
# Read the whitelist file if enabled.
self.whitelist = set()
wl_filename = self.config['whitelist'].get()
if wl_filename in (True, ''): # Indicates the default whitelist.
wl_filename = self.config["whitelist"].get()
if wl_filename in (True, ""): # Indicates the default whitelist.
wl_filename = WHITELIST
if wl_filename:
wl_filename = normpath(wl_filename)
with open(wl_filename, 'rb') as f:
with open(wl_filename, "rb") as f:
for line in f:
line = line.decode('utf-8').strip().lower()
if line and not line.startswith('#'):
line = line.decode("utf-8").strip().lower()
if line and not line.startswith("#"):
self.whitelist.add(line)
# Read the genres tree for canonicalization if enabled.
self.c14n_branches = []
c14n_filename = self.config['canonical'].get()
c14n_filename = self.config["canonical"].get()
self.canonicalize = c14n_filename is not False
# Default tree
if c14n_filename in (True, ''):
if c14n_filename in (True, ""):
c14n_filename = C14N_TREE
elif not self.canonicalize and self.config['prefer_specific'].get():
elif not self.canonicalize and self.config["prefer_specific"].get():
# prefer_specific requires a tree, load default tree
c14n_filename = C14N_TREE
# Read the tree
if c14n_filename:
self._log.debug('Loading canonicalization tree {0}', c14n_filename)
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:
with codecs.open(c14n_filename, "r", encoding="utf-8") as f:
genres_tree = yaml.safe_load(f)
flatten_tree(genres_tree, [], self.c14n_branches)
@@ -158,17 +156,16 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"""A tuple of allowed genre sources. May contain 'track',
'album', or 'artist.'
"""
source = self.config['source'].as_choice(('track', 'album', 'artist'))
if source == 'track':
return 'track', 'album', 'artist'
elif source == 'album':
return 'album', 'artist'
elif source == 'artist':
return 'artist',
source = self.config["source"].as_choice(("track", "album", "artist"))
if source == "track":
return "track", "album", "artist"
elif source == "album":
return "album", "artist"
elif source == "artist":
return ("artist",)
def _get_depth(self, tag):
"""Find the depth of a tag in the genres tree.
"""
"""Find the depth of a tag in the genres tree."""
depth = None
for key, value in enumerate(self.c14n_branches):
if tag in value:
@@ -192,7 +189,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if not tags:
return None
count = self.config['count'].get(int)
count = self.config["count"].get(int)
if self.canonicalize:
# Extend the list to consider tags parents in the c14n tree
tags_all = []
@@ -200,31 +197,38 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# Add parents that are in the whitelist, or add the oldest
# ancestor if no whitelist
if self.whitelist:
parents = [x for x in find_parents(tag, self.c14n_branches)
if self._is_allowed(x)]
parents = [
x
for x in find_parents(tag, self.c14n_branches)
if self._is_allowed(x)
]
else:
parents = [find_parents(tag, self.c14n_branches)[-1]]
tags_all += parents
# Stop if we have enough tags already, unless we need to find
# the most specific tag (instead of the most popular).
if (not self.config['prefer_specific'] and
len(tags_all) >= count):
if (
not self.config["prefer_specific"]
and len(tags_all) >= count
):
break
tags = tags_all
tags = deduplicate(tags)
# Sort the tags by specificity.
if self.config['prefer_specific']:
if self.config["prefer_specific"]:
tags = self._sort_by_depth(tags)
# c14n only adds allowed genres but we may have had forbidden genres in
# the original tags list
tags = [self._format_tag(x) for x in tags if self._is_allowed(x)]
return self.config['separator'].as_str().join(
tags[:self.config['count'].get(int)]
return (
self.config["separator"]
.as_str()
.join(tags[: self.config["count"].get(int)])
)
def _format_tag(self, tag):
@@ -236,7 +240,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"""Return the genre for a pylast entity or None if no suitable genre
can be found. Ex. 'Electronic, House, Dance'
"""
min_weight = self.config['min_weight'].get(int)
min_weight = self.config["min_weight"].get(int)
return self._resolve_genres(self._tags_for(lastfm_obj, min_weight))
def _is_allowed(self, genre):
@@ -263,8 +267,7 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if any(not s for s in args):
return None
key = '{}.{}'.format(entity,
'-'.join(str(a) for a in args))
key = "{}.{}".format(entity, "-".join(str(a) for a in args))
if key in self._genre_cache:
return self._genre_cache[key]
else:
@@ -279,31 +282,23 @@ class LastGenrePlugin(plugins.BeetsPlugin):
return genre
def fetch_album_genre(self, obj):
"""Return the album genre for this Item or Album.
"""
"""Return the album genre for this Item or Album."""
return self._last_lookup(
'album', LASTFM.get_album, obj.albumartist, obj.album
"album", LASTFM.get_album, obj.albumartist, obj.album
)
def fetch_album_artist_genre(self, obj):
"""Return the album artist genre for this Item or Album.
"""
return self._last_lookup(
'artist', LASTFM.get_artist, obj.albumartist
)
"""Return the album artist genre for this Item or Album."""
return self._last_lookup("artist", LASTFM.get_artist, obj.albumartist)
def fetch_artist_genre(self, item):
"""Returns the track artist genre for this Item.
"""
return self._last_lookup(
'artist', LASTFM.get_artist, item.artist
)
"""Returns the track artist genre for this Item."""
return self._last_lookup("artist", LASTFM.get_artist, item.artist)
def fetch_track_genre(self, obj):
"""Returns the track genre for this Item.
"""
"""Returns the track genre for this Item."""
return self._last_lookup(
'track', LASTFM.get_track, obj.artist, obj.title
"track", LASTFM.get_track, obj.artist, obj.title
)
def _get_genre(self, obj):
@@ -319,35 +314,35 @@ class LastGenrePlugin(plugins.BeetsPlugin):
"""
# Shortcut to existing genre if not forcing.
if not self.config['force'] and self._is_allowed(obj.genre):
return obj.genre, 'keep'
if not self.config["force"] and self._is_allowed(obj.genre):
return obj.genre, "keep"
# Track genre (for Items only).
if isinstance(obj, library.Item):
if 'track' in self.sources:
if "track" in self.sources:
result = self.fetch_track_genre(obj)
if result:
return result, 'track'
return result, "track"
# Album genre.
if 'album' in self.sources:
if "album" in self.sources:
result = self.fetch_album_genre(obj)
if result:
return result, 'album'
return result, "album"
# Artist (or album artist) genre.
if 'artist' in self.sources:
if "artist" in self.sources:
result = None
if isinstance(obj, library.Item):
result = self.fetch_artist_genre(obj)
elif obj.albumartist != config['va_name'].as_str():
elif obj.albumartist != config["va_name"].as_str():
result = self.fetch_album_artist_genre(obj)
else:
# For "Various Artists", pick the most popular track genre.
item_genres = []
for item in obj.items():
item_genre = None
if 'track' in self.sources:
if "track" in self.sources:
item_genre = self.fetch_track_genre(item)
if not item_genre:
item_genre = self.fetch_artist_genre(item)
@@ -357,38 +352,51 @@ class LastGenrePlugin(plugins.BeetsPlugin):
result, _ = plurality(item_genres)
if result:
return result, 'artist'
return result, "artist"
# Filter the existing genre.
if obj.genre:
result = self._resolve_genres([obj.genre])
if result:
return result, 'original'
return result, "original"
# Fallback string.
fallback = self.config['fallback'].get()
fallback = self.config["fallback"].get()
if fallback:
return fallback, 'fallback'
return fallback, "fallback"
return None, None
def commands(self):
lastgenre_cmd = ui.Subcommand('lastgenre', help='fetch genres')
lastgenre_cmd = ui.Subcommand("lastgenre", help="fetch genres")
lastgenre_cmd.parser.add_option(
'-f', '--force', dest='force',
action='store_true',
help='re-download genre when already present'
"-f",
"--force",
dest="force",
action="store_true",
help="re-download genre when already present",
)
lastgenre_cmd.parser.add_option(
'-s', '--source', dest='source', type='string',
help='genre source: artist, album, or track'
"-s",
"--source",
dest="source",
type="string",
help="genre source: artist, album, or track",
)
lastgenre_cmd.parser.add_option(
'-A', '--items', action='store_false', dest='album',
help='match items instead of albums')
"-A",
"--items",
action="store_false",
dest="album",
help="match items instead of albums",
)
lastgenre_cmd.parser.add_option(
'-a', '--albums', action='store_true', dest='album',
help='match albums instead of items')
"-a",
"--albums",
action="store_true",
dest="album",
help="match albums instead of items",
)
lastgenre_cmd.parser.set_defaults(album=True)
def lastgenre_func(lib, opts, args):
@@ -399,19 +407,22 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# Fetch genres for whole albums
for album in lib.albums(ui.decargs(args)):
album.genre, src = self._get_genre(album)
self._log.info('genre for album {0} ({1}): {0.genre}',
album, src)
self._log.info(
"genre for album {0} ({1}): {0.genre}", album, src
)
album.store()
for item in album.items():
# If we're using track-level sources, also look up each
# track on the album.
if 'track' in self.sources:
if "track" in self.sources:
item.genre, src = self._get_genre(item)
item.store()
self._log.info(
'genre for track {0} ({1}): {0.genre}',
item, src)
"genre for track {0} ({1}): {0.genre}",
item,
src,
)
if write:
item.try_write()
@@ -420,8 +431,9 @@ class LastGenrePlugin(plugins.BeetsPlugin):
# an album
for item in lib.items(ui.decargs(args)):
item.genre, src = self._get_genre(item)
self._log.debug('added last.fm item genre ({0}): {1}',
src, item.genre)
self._log.debug(
"added last.fm item genre ({0}): {1}", src, item.genre
)
item.store()
lastgenre_cmd.func = lastgenre_func
@@ -432,22 +444,25 @@ class LastGenrePlugin(plugins.BeetsPlugin):
if task.is_album:
album = task.album
album.genre, src = self._get_genre(album)
self._log.debug('added last.fm album genre ({0}): {1}',
src, album.genre)
self._log.debug(
"added last.fm album genre ({0}): {1}", src, album.genre
)
album.store()
if 'track' in self.sources:
if "track" in self.sources:
for item in album.items():
item.genre, src = self._get_genre(item)
self._log.debug('added last.fm item genre ({0}): {1}',
src, item.genre)
self._log.debug(
"added last.fm item genre ({0}): {1}", src, item.genre
)
item.store()
else:
item = task.item
item.genre, src = self._get_genre(item)
self._log.debug('added last.fm item genre ({0}): {1}',
src, item.genre)
self._log.debug(
"added last.fm item genre ({0}): {1}", src, item.genre
)
item.store()
def _tags_for(self, obj, min_weight=None):
@@ -468,12 +483,12 @@ class LastGenrePlugin(plugins.BeetsPlugin):
try:
res = obj.get_top_tags()
except PYLAST_EXCEPTIONS as exc:
self._log.debug('last.fm error: {0}', exc)
self._log.debug("last.fm error: {0}", exc)
return []
except Exception as exc:
# Isolate bugs in pylast.
self._log.debug('{}', traceback.format_exc())
self._log.error('error in pylast library: {0}', exc)
self._log.debug("{}", traceback.format_exc())
self._log.error("error in pylast library: {0}", exc)
return []
# Filter by weight (optionally).
-1
View File
@@ -1459,7 +1459,6 @@ tribal house
trikitixa
trip hop
trip rock
trip-hop
tropicalia
tropicalismo
tropipop
+141 -96
View File
@@ -15,33 +15,35 @@
import pylast
from pylast import TopItem, _extract, _number
from beets import ui
from beets import dbcore
from beets import config
from beets import plugins
from beets import config, dbcore, plugins, ui
from beets.dbcore import types
API_URL = 'https://ws.audioscrobbler.com/2.0/'
API_URL = "https://ws.audioscrobbler.com/2.0/"
class LastImportPlugin(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
config['lastfm'].add({
'user': '',
'api_key': plugins.LASTFM_KEY,
})
config['lastfm']['api_key'].redact = True
self.config.add({
'per_page': 500,
'retry_limit': 3,
})
config["lastfm"].add(
{
"user": "",
"api_key": plugins.LASTFM_KEY,
}
)
config["lastfm"]["api_key"].redact = True
self.config.add(
{
"per_page": 500,
"retry_limit": 3,
}
)
self.item_types = {
'play_count': types.INTEGER,
"play_count": types.INTEGER,
}
def commands(self):
cmd = ui.Subcommand('lastimport', help='import last.fm play-count')
cmd = ui.Subcommand("lastimport", help="import last.fm play-count")
def func(lib, opts, args):
import_lastfm(lib, self._log)
@@ -51,25 +53,26 @@ class LastImportPlugin(plugins.BeetsPlugin):
class CustomUser(pylast.User):
""" Custom user class derived from pylast.User, and overriding the
"""Custom user class derived from pylast.User, and overriding the
_get_things method to return MBID and album. Also introduces new
get_top_tracks_by_page method to allow access to more than one page of top
tracks.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _get_things(self, method, thing, thing_type, params=None,
cacheable=True):
def _get_things(
self, method, thing, thing_type, params=None, cacheable=True
):
"""Returns a list of the most played thing_types by this thing, in a
tuple with the total number of pages of results. Includes an MBID, if
found.
"""
doc = self._request(
self.ws_prefix + "." + method, cacheable, params)
doc = self._request(self.ws_prefix + "." + method, cacheable, params)
toptracks_node = doc.getElementsByTagName('toptracks')[0]
total_pages = int(toptracks_node.getAttribute('totalPages'))
toptracks_node = doc.getElementsByTagName("toptracks")[0]
total_pages = int(toptracks_node.getAttribute("totalPages"))
seq = []
for node in doc.getElementsByTagName(thing):
@@ -84,8 +87,9 @@ class CustomUser(pylast.User):
return seq, total_pages
def get_top_tracks_by_page(self, period=pylast.PERIOD_OVERALL, limit=None,
page=1, cacheable=True):
def get_top_tracks_by_page(
self, period=pylast.PERIOD_OVERALL, limit=None, page=1, cacheable=True
):
"""Returns the top tracks played by a user, in a tuple with the total
number of pages of results.
* period: The period of time. Possible values:
@@ -98,40 +102,43 @@ class CustomUser(pylast.User):
"""
params = self._get_params()
params['period'] = period
params['page'] = page
params["period"] = period
params["page"] = page
if limit:
params['limit'] = limit
params["limit"] = limit
return self._get_things(
"getTopTracks", "track", pylast.Track, params, cacheable)
"getTopTracks", "track", pylast.Track, params, cacheable
)
def import_lastfm(lib, log):
user = config['lastfm']['user'].as_str()
per_page = config['lastimport']['per_page'].get(int)
user = config["lastfm"]["user"].as_str()
per_page = config["lastimport"]["per_page"].get(int)
if not user:
raise ui.UserError('You must specify a user name for lastimport')
raise ui.UserError("You must specify a user name for lastimport")
log.info('Fetching last.fm library for @{0}', user)
log.info("Fetching last.fm library for @{0}", user)
page_total = 1
page_current = 0
found_total = 0
unknown_total = 0
retry_limit = config['lastimport']['retry_limit'].get(int)
retry_limit = config["lastimport"]["retry_limit"].get(int)
# Iterate through a yet to be known page total count
while page_current < page_total:
log.info('Querying page #{0}{1}...',
page_current + 1,
f'/{page_total}' if page_total > 1 else '')
log.info(
"Querying page #{0}{1}...",
page_current + 1,
f"/{page_total}" if page_total > 1 else "",
)
for retry in range(0, retry_limit):
tracks, page_total = fetch_tracks(user, page_current + 1, per_page)
if page_total < 1:
# It means nothing to us!
raise ui.UserError('Last.fm reported no data.')
raise ui.UserError("Last.fm reported no data.")
if tracks:
found, unknown = process_tracks(lib, tracks, log)
@@ -139,48 +146,53 @@ def import_lastfm(lib, log):
unknown_total += unknown
break
else:
log.error('ERROR: unable to read page #{0}',
page_current + 1)
log.error("ERROR: unable to read page #{0}", page_current + 1)
if retry < retry_limit:
log.info(
'Retrying page #{0}... ({1}/{2} retry)',
page_current + 1, retry + 1, retry_limit
"Retrying page #{0}... ({1}/{2} retry)",
page_current + 1,
retry + 1,
retry_limit,
)
else:
log.error('FAIL: unable to fetch page #{0}, ',
'tried {1} times', page_current, retry + 1)
log.error(
"FAIL: unable to fetch page #{0}, ",
"tried {1} times",
page_current,
retry + 1,
)
page_current += 1
log.info('... done!')
log.info('finished processing {0} song pages', page_total)
log.info('{0} unknown play-counts', unknown_total)
log.info('{0} play-counts imported', found_total)
log.info("... done!")
log.info("finished processing {0} song pages", page_total)
log.info("{0} unknown play-counts", unknown_total)
log.info("{0} play-counts imported", found_total)
def fetch_tracks(user, page, limit):
""" JSON format:
[
{
"mbid": "...",
"artist": "...",
"title": "...",
"playcount": "..."
}
]
"""JSON format:
[
{
"mbid": "...",
"artist": "...",
"title": "...",
"playcount": "..."
}
]
"""
network = pylast.LastFMNetwork(api_key=config['lastfm']['api_key'])
network = pylast.LastFMNetwork(api_key=config["lastfm"]["api_key"])
user_obj = CustomUser(user, network)
results, total_pages =\
user_obj.get_top_tracks_by_page(limit=limit, page=page)
results, total_pages = user_obj.get_top_tracks_by_page(
limit=limit, page=page
)
return [
{
"mbid": track.item.mbid if track.item.mbid else '',
"artist": {
"name": track.item.artist.name
},
"mbid": track.item.mbid if track.item.mbid else "",
"artist": {"name": track.item.artist.name},
"name": track.item.title,
"playcount": track.weight
} for track in results
"playcount": track.weight,
}
for track in results
], total_pages
@@ -188,60 +200,93 @@ def process_tracks(lib, tracks, log):
total = len(tracks)
total_found = 0
total_fails = 0
log.info('Received {0} tracks in this page, processing...', total)
log.info("Received {0} tracks in this page, processing...", total)
for num in range(0, total):
song = None
trackid = tracks[num]['mbid'].strip()
artist = tracks[num]['artist'].get('name', '').strip()
title = tracks[num]['name'].strip()
album = ''
if 'album' in tracks[num]:
album = tracks[num]['album'].get('name', '').strip()
trackid = tracks[num]["mbid"].strip() if tracks[num]["mbid"] else None
artist = (
tracks[num]["artist"].get("name", "").strip()
if tracks[num]["artist"].get("name", "")
else None
)
title = tracks[num]["name"].strip() if tracks[num]["name"] else None
album = ""
if "album" in tracks[num]:
album = (
tracks[num]["album"].get("name", "").strip()
if tracks[num]["album"]
else None
)
log.debug('query: {0} - {1} ({2})', artist, title, album)
log.debug("query: {0} - {1} ({2})", artist, title, album)
# First try to query by musicbrainz's trackid
if trackid:
song = lib.items(
dbcore.query.MatchQuery('mb_trackid', trackid)
dbcore.query.MatchQuery("mb_trackid", trackid)
).get()
# If not, try just album/title
if song is None:
log.debug(
"no album match, trying by album/title: {0} - {1}", album, title
)
query = dbcore.AndQuery(
[
dbcore.query.SubstringQuery("album", album),
dbcore.query.SubstringQuery("title", title),
]
)
song = lib.items(query).get()
# If not, try just artist/title
if song is None:
log.debug('no album match, trying by artist/title')
query = dbcore.AndQuery([
dbcore.query.SubstringQuery('artist', artist),
dbcore.query.SubstringQuery('title', title)
])
log.debug("no album match, trying by artist/title")
query = dbcore.AndQuery(
[
dbcore.query.SubstringQuery("artist", artist),
dbcore.query.SubstringQuery("title", title),
]
)
song = lib.items(query).get()
# Last resort, try just replacing to utf-8 quote
if song is None:
title = title.replace("'", '\u2019')
log.debug('no title match, trying utf-8 single quote')
query = dbcore.AndQuery([
dbcore.query.SubstringQuery('artist', artist),
dbcore.query.SubstringQuery('title', title)
])
title = title.replace("'", "\u2019")
log.debug("no title match, trying utf-8 single quote")
query = dbcore.AndQuery(
[
dbcore.query.SubstringQuery("artist", artist),
dbcore.query.SubstringQuery("title", title),
]
)
song = lib.items(query).get()
if song is not None:
count = int(song.get('play_count', 0))
new_count = int(tracks[num]['playcount'])
log.debug('match: {0} - {1} ({2}) '
'updating: play_count {3} => {4}',
song.artist, song.title, song.album, count, new_count)
song['play_count'] = new_count
count = int(song.get("play_count", 0))
new_count = int(tracks[num].get("playcount", 1))
log.debug(
"match: {0} - {1} ({2}) " "updating: play_count {3} => {4}",
song.artist,
song.title,
song.album,
count,
new_count,
)
song["play_count"] = new_count
song.store()
total_found += 1
else:
total_fails += 1
log.info(' - No match: {0} - {1} ({2})',
artist, title, album)
log.info(" - No match: {0} - {1} ({2})", artist, title, album)
if total_fails > 0:
log.info('Acquired {0}/{1} play-counts ({2} unknown)',
total_found, total, total_fails)
log.info(
"Acquired {0}/{1} play-counts ({2} unknown)",
total_found,
total,
total_fails,
)
return total_found, total_fails
+96
View File
@@ -0,0 +1,96 @@
# This file is part of beets.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
"""Adds head/tail functionality to list/ls.
1. Implemented as `lslimit` command with `--head` and `--tail` options. This is
the idiomatic way to use this plugin.
2. Implemented as query prefix `<` for head functionality only. This is the
composable way to use the plugin (plays nicely with anything that uses the
query language).
"""
from collections import deque
from itertools import islice
from beets.dbcore import FieldQuery
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, decargs, print_
def lslimit(lib, opts, args):
"""Query command with head/tail."""
if (opts.head is not None) and (opts.tail is not None):
raise ValueError("Only use one of --head and --tail")
if (opts.head or opts.tail or 0) < 0:
raise ValueError("Limit value must be non-negative")
query = decargs(args)
if opts.album:
objs = lib.albums(query)
else:
objs = lib.items(query)
if opts.head is not None:
objs = islice(objs, opts.head)
elif opts.tail is not None:
objs = deque(objs, opts.tail)
for obj in objs:
print_(format(obj))
lslimit_cmd = Subcommand("lslimit", help="query with optional head or tail")
lslimit_cmd.parser.add_option(
"--head", action="store", type="int", default=None
)
lslimit_cmd.parser.add_option(
"--tail", action="store", type="int", default=None
)
lslimit_cmd.parser.add_all_common_options()
lslimit_cmd.func = lslimit
class LimitPlugin(BeetsPlugin):
"""Query limit functionality via command and query prefix."""
def commands(self):
"""Expose `lslimit` subcommand."""
return [lslimit_cmd]
def queries(self):
class HeadQuery(FieldQuery):
"""This inner class pattern allows the query to track state."""
n = 0
N = None
def __init__(self, *args, **kwargs) -> None:
"""Force the query to be slow so that 'value_match' is called."""
super().__init__(*args, **kwargs)
self.fast = False
@classmethod
def value_match(cls, pattern, value):
if cls.N is None:
cls.N = int(pattern)
if cls.N < 0:
raise ValueError("Limit value must be non-negative")
cls.n += 1
return cls.n <= cls.N
return {"<": HeadQuery}
+266
View File
@@ -0,0 +1,266 @@
"""Adds Listenbrainz support to Beets."""
import datetime
import musicbrainzngs
import requests
from beets import config, ui
from beets.plugins import BeetsPlugin
from beetsplug.lastimport import process_tracks
class ListenBrainzPlugin(BeetsPlugin):
"""A Beets plugin for interacting with ListenBrainz."""
data_source = "ListenBrainz"
ROOT = "http://api.listenbrainz.org/1/"
def __init__(self):
"""Initialize the plugin."""
super().__init__()
self.token = self.config["token"].get()
self.username = self.config["username"].get()
self.AUTH_HEADER = {"Authorization": f"Token {self.token}"}
config["listenbrainz"]["token"].redact = True
def commands(self):
"""Add beet UI commands to interact with ListenBrainz."""
lbupdate_cmd = ui.Subcommand(
"lbimport", help=f"Import {self.data_source} history"
)
def func(lib, opts, args):
self._lbupdate(lib, self._log)
lbupdate_cmd.func = func
return [lbupdate_cmd]
def _lbupdate(self, lib, log):
"""Obtain view count from Listenbrainz."""
found_total = 0
unknown_total = 0
ls = self.get_listens()
tracks = self.get_tracks_from_listens(ls)
log.info(f"Found {len(ls)} listens")
if tracks:
found, unknown = process_tracks(lib, tracks, log)
found_total += found
unknown_total += unknown
log.info("... done!")
log.info("{0} unknown play-counts", unknown_total)
log.info("{0} play-counts imported", found_total)
def _make_request(self, url, params=None):
"""Makes a request to the ListenBrainz API."""
try:
response = requests.get(
url=url,
headers=self.AUTH_HEADER,
timeout=10,
params=params,
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
self._log.debug(f"Invalid Search Error: {e}")
return None
def get_listens(self, min_ts=None, max_ts=None, count=None):
"""Gets the listen history of a given user.
Args:
username: User to get listen history of.
min_ts: History before this timestamp will not be returned.
DO NOT USE WITH max_ts.
max_ts: History after this timestamp will not be returned.
DO NOT USE WITH min_ts.
count: How many listens to return. If not specified,
uses a default from the server.
Returns:
A list of listen info dictionaries if there's an OK status.
Raises:
An HTTPError if there's a failure.
A ValueError if the JSON in the response is invalid.
An IndexError if the JSON is not structured as expected.
"""
url = f"{self.ROOT}/user/{self.username}/listens"
params = {
k: v
for k, v in {
"min_ts": min_ts,
"max_ts": max_ts,
"count": count,
}.items()
if v is not None
}
response = self._make_request(url, params)
if response is not None:
return response["payload"]["listens"]
else:
return None
def get_tracks_from_listens(self, listens):
"""Returns a list of tracks from a list of listens."""
tracks = []
for track in listens:
if track["track_metadata"].get("release_name") is None:
continue
mbid_mapping = track["track_metadata"].get("mbid_mapping", {})
# print(json.dumps(track, indent=4, sort_keys=True))
if mbid_mapping.get("recording_mbid") is None:
# search for the track using title and release
mbid = self.get_mb_recording_id(track)
tracks.append(
{
"album": {
"name": track["track_metadata"].get("release_name")
},
"name": track["track_metadata"].get("track_name"),
"artist": {
"name": track["track_metadata"].get("artist_name")
},
"mbid": mbid,
"release_mbid": mbid_mapping.get("release_mbid"),
"listened_at": track.get("listened_at"),
}
)
return tracks
def get_mb_recording_id(self, track):
"""Returns the MusicBrainz recording ID for a track."""
resp = musicbrainzngs.search_recordings(
query=track["track_metadata"].get("track_name"),
release=track["track_metadata"].get("release_name"),
strict=True,
)
if resp.get("recording-count") == "1":
return resp.get("recording-list")[0].get("id")
else:
return None
def get_playlists_createdfor(self, username):
"""Returns a list of playlists created by a user."""
url = f"{self.ROOT}/user/{username}/playlists/createdfor"
return self._make_request(url)
def get_listenbrainz_playlists(self):
"""Returns a list of playlists created by ListenBrainz."""
import re
resp = self.get_playlists_createdfor(self.username)
playlists = resp.get("playlists")
listenbrainz_playlists = []
for playlist in playlists:
playlist_info = playlist.get("playlist")
if playlist_info.get("creator") == "listenbrainz":
title = playlist_info.get("title")
match = re.search(
r"(Missed Recordings of \d{4}|Discoveries of \d{4})", title
)
if "Exploration" in title:
playlist_type = "Exploration"
elif "Jams" in title:
playlist_type = "Jams"
elif match:
playlist_type = match.group(1)
else:
playlist_type = None
if "week of " in title:
date_str = title.split("week of ")[1].split(" ")[0]
date = datetime.datetime.strptime(
date_str, "%Y-%m-%d"
).date()
else:
date = None
identifier = playlist_info.get("identifier")
id = identifier.split("/")[-1]
if playlist_type in ["Jams", "Exploration"]:
listenbrainz_playlists.append(
{
"type": playlist_type,
"date": date,
"identifier": id,
"title": title,
}
)
return listenbrainz_playlists
def get_playlist(self, identifier):
"""Returns a playlist."""
url = f"{self.ROOT}/playlist/{identifier}"
return self._make_request(url)
def get_tracks_from_playlist(self, playlist):
"""This function returns a list of tracks in the playlist."""
tracks = []
for track in playlist.get("playlist").get("track"):
tracks.append(
{
"artist": track.get("creator"),
"identifier": track.get("identifier").split("/")[-1],
"title": track.get("title"),
}
)
return self.get_track_info(tracks)
def get_track_info(self, tracks):
"""Returns a list of track info."""
track_info = []
for track in tracks:
identifier = track.get("identifier")
resp = musicbrainzngs.get_recording_by_id(
identifier, includes=["releases", "artist-credits"]
)
recording = resp.get("recording")
title = recording.get("title")
artist_credit = recording.get("artist-credit", [])
if artist_credit:
artist = artist_credit[0].get("artist", {}).get("name")
else:
artist = None
releases = recording.get("release-list", [])
if releases:
album = releases[0].get("title")
date = releases[0].get("date")
year = date.split("-")[0] if date else None
else:
album = None
year = None
track_info.append(
{
"identifier": identifier,
"title": title,
"artist": artist,
"album": album,
"year": year,
}
)
return track_info
def get_weekly_playlist(self, index):
"""Returns a list of weekly playlists based on the index."""
playlists = self.get_listenbrainz_playlists()
playlist = self.get_playlist(playlists[index].get("identifier"))
self._log.info(f"Getting {playlist.get('playlist').get('title')}")
return self.get_tracks_from_playlist(playlist)
def get_weekly_exploration(self):
"""Returns a list of weekly exploration."""
return self.get_weekly_playlist(0)
def get_weekly_jams(self):
"""Returns a list of weekly jams."""
return self.get_weekly_playlist(1)
def get_last_weekly_exploration(self):
"""Returns a list of weekly exploration."""
return self.get_weekly_playlist(3)
def get_last_weekly_jams(self):
"""Returns a list of weekly jams."""
return self.get_weekly_playlist(3)
+9 -6
View File
@@ -16,9 +16,10 @@
"""
import sqlite3
from beets.dbcore import Database
from beets.plugins import BeetsPlugin
import sqlite3
class LoadExtPlugin(BeetsPlugin):
@@ -26,19 +27,21 @@ class LoadExtPlugin(BeetsPlugin):
super().__init__()
if not Database.supports_extensions:
self._log.warn('loadext is enabled but the current SQLite '
'installation does not support 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)
self.register_listener("library_opened", self.library_opened)
def library_opened(self, lib):
for v in self.config:
ext = v.as_filename()
self._log.debug('loading extension {}', ext)
self._log.debug("loading extension {}", ext)
try:
lib.load_extension(ext)
except sqlite3.OperationalError as e:
self._log.error('failed to load extension {}: {}', ext, e)
self._log.error("failed to load extension {}: {}", ext, e)
+465 -313
View File
File diff suppressed because it is too large Load Diff
+52 -54
View File
@@ -13,30 +13,29 @@
# included in all copies or substantial portions of the Software.
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets import ui
from beets import config
import re
import musicbrainzngs
import re
from beets import config, ui
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
SUBMISSION_CHUNK_SIZE = 200
FETCH_CHUNK_SIZE = 100
UUID_REGEX = r'^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$'
UUID_REGEX = r"^[a-f0-9]{8}(-[a-f0-9]{4}){3}-[a-f0-9]{12}$"
def mb_call(func, *args, **kwargs):
"""Call a MusicBrainz API function and catch exceptions.
"""
"""Call a MusicBrainz API function and catch exceptions."""
try:
return func(*args, **kwargs)
except musicbrainzngs.AuthenticationError:
raise ui.UserError('authentication with MusicBrainz failed')
raise ui.UserError("authentication with MusicBrainz failed")
except (musicbrainzngs.ResponseError, musicbrainzngs.NetworkError) as exc:
raise ui.UserError(f'MusicBrainz API error: {exc}')
raise ui.UserError(f"MusicBrainz API error: {exc}")
except musicbrainzngs.UsageError:
raise ui.UserError('MusicBrainz credentials missing')
raise ui.UserError("MusicBrainz credentials missing")
def submit_albums(collection_id, release_ids):
@@ -44,45 +43,45 @@ def submit_albums(collection_id, release_ids):
requests are made if there are many release IDs to submit.
"""
for i in range(0, len(release_ids), SUBMISSION_CHUNK_SIZE):
chunk = release_ids[i:i + SUBMISSION_CHUNK_SIZE]
mb_call(
musicbrainzngs.add_releases_to_collection,
collection_id, chunk
)
chunk = release_ids[i : i + SUBMISSION_CHUNK_SIZE]
mb_call(musicbrainzngs.add_releases_to_collection, collection_id, chunk)
class MusicBrainzCollectionPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
config['musicbrainz']['pass'].redact = True
config["musicbrainz"]["pass"].redact = True
musicbrainzngs.auth(
config['musicbrainz']['user'].as_str(),
config['musicbrainz']['pass'].as_str(),
config["musicbrainz"]["user"].as_str(),
config["musicbrainz"]["pass"].as_str(),
)
self.config.add({
'auto': False,
'collection': '',
'remove': False,
})
if self.config['auto']:
self.config.add(
{
"auto": False,
"collection": "",
"remove": False,
}
)
if self.config["auto"]:
self.import_stages = [self.imported]
def _get_collection(self):
collections = mb_call(musicbrainzngs.get_collections)
if not collections['collection-list']:
raise ui.UserError('no collections exist for user')
if not collections["collection-list"]:
raise ui.UserError("no collections exist for user")
# Get all collection IDs, avoiding event collections
collection_ids = [x['id'] for x in collections['collection-list']]
collection_ids = [x["id"] for x in collections["collection-list"]]
if not collection_ids:
raise ui.UserError('No collection found.')
raise ui.UserError("No collection found.")
# Check that the collection exists so we can present a nice error
collection = self.config['collection'].as_str()
collection = self.config["collection"].as_str()
if collection:
if collection not in collection_ids:
raise ui.UserError('invalid collection ID: {}'
.format(collection))
raise ui.UserError(
"invalid collection ID: {}".format(collection)
)
return collection
# No specified collection. Just return the first collection ID
@@ -94,9 +93,9 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
musicbrainzngs.get_releases_in_collection,
id,
limit=FETCH_CHUNK_SIZE,
offset=offset
)['collection']
return [x['id'] for x in res['release-list']], res['release-count']
offset=offset,
)["collection"]
return [x["id"] for x in res["release-list"]], res["release-count"]
offset = 0
albums_in_collection, release_count = _fetch(offset)
@@ -107,13 +106,15 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
return albums_in_collection
def commands(self):
mbupdate = Subcommand('mbupdate',
help='Update MusicBrainz collection')
mbupdate.parser.add_option('-r', '--remove',
action='store_true',
default=None,
dest='remove',
help='Remove albums not in beets library')
mbupdate = Subcommand("mbupdate", help="Update MusicBrainz collection")
mbupdate.parser.add_option(
"-r",
"--remove",
action="store_true",
default=None,
dest="remove",
help="Remove albums not in beets library",
)
mbupdate.func = self.update_collection
return [mbupdate]
@@ -122,26 +123,25 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
albums_in_collection = self._get_albums_in_collection(collection_id)
remove_me = list(set(albums_in_collection) - lib_ids)
for i in range(0, len(remove_me), FETCH_CHUNK_SIZE):
chunk = remove_me[i:i + FETCH_CHUNK_SIZE]
chunk = remove_me[i : i + FETCH_CHUNK_SIZE]
mb_call(
musicbrainzngs.remove_releases_from_collection,
collection_id, chunk
collection_id,
chunk,
)
def update_collection(self, lib, opts, args):
self.config.set_args(opts)
remove_missing = self.config['remove'].get(bool)
remove_missing = self.config["remove"].get(bool)
self.update_album_list(lib, lib.albums(), remove_missing)
def imported(self, session, task):
"""Add each imported album to the collection.
"""
"""Add each imported album to the collection."""
if task.is_album:
self.update_album_list(session.lib, [task.album])
def update_album_list(self, lib, album_list, remove_missing=False):
"""Update the MusicBrainz collection from a list of Beets albums
"""
"""Update the MusicBrainz collection from a list of Beets albums"""
collection_id = self._get_collection()
# Get a list of all the album IDs.
@@ -152,13 +152,11 @@ class MusicBrainzCollectionPlugin(BeetsPlugin):
if re.match(UUID_REGEX, aid):
album_ids.append(aid)
else:
self._log.info('skipping invalid MBID: {0}', aid)
self._log.info("skipping invalid MBID: {0}", aid)
# Submit to MusicBrainz.
self._log.info(
'Updating MusicBrainz collection {0}...', collection_id
)
self._log.info("Updating MusicBrainz collection {0}...", collection_id)
submit_albums(collection_id, album_ids)
if remove_missing:
self.remove_missing(collection_id, lib.albums())
self._log.info('...MusicBrainz collection updated.')
self._log.info("...MusicBrainz collection updated.")
+56 -14
View File
@@ -21,10 +21,13 @@ implemented by MusicBrainz yet.
[1] https://wiki.musicbrainz.org/History:How_To_Parse_Track_Listings
"""
import subprocess
from beets import ui
from beets.autotag import Recommendation
from beets.plugins import BeetsPlugin
from beets.ui.commands import PromptChoice
from beets.util import displayable_path
from beetsplug.info import print_data
@@ -32,26 +35,65 @@ class MBSubmitPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'format': '$track. $title - $artist ($length)',
'threshold': 'medium',
})
self.config.add(
{
"format": "$track. $title - $artist ($length)",
"threshold": "medium",
"picard_path": "picard",
}
)
# Validate and store threshold.
self.threshold = self.config['threshold'].as_choice({
'none': Recommendation.none,
'low': Recommendation.low,
'medium': Recommendation.medium,
'strong': Recommendation.strong
})
self.threshold = self.config["threshold"].as_choice(
{
"none": Recommendation.none,
"low": Recommendation.low,
"medium": Recommendation.medium,
"strong": Recommendation.strong,
}
)
self.register_listener('before_choose_candidate',
self.before_choose_candidate_event)
self.register_listener(
"before_choose_candidate", self.before_choose_candidate_event
)
def before_choose_candidate_event(self, session, task):
if task.rec <= self.threshold:
return [PromptChoice('p', 'Print tracks', self.print_tracks)]
return [
PromptChoice("p", "Print tracks", self.print_tracks),
PromptChoice("o", "Open files with Picard", self.picard),
]
def picard(self, session, task):
paths = []
for p in task.paths:
paths.append(displayable_path(p))
try:
picard_path = self.config["picard_path"].as_str()
subprocess.Popen([picard_path] + paths)
self._log.info("launched picard from\n{}", picard_path)
except OSError as exc:
self._log.error(f"Could not open picard, got error:\n{exc}")
def print_tracks(self, session, task):
for i in sorted(task.items, key=lambda i: i.track):
print_data(None, i, self.config['format'].as_str())
print_data(None, i, self.config["format"].as_str())
def commands(self):
"""Add beet UI commands for mbsubmit."""
mbsubmit_cmd = ui.Subcommand(
"mbsubmit", help="Submit Tracks to MusicBrainz"
)
def func(lib, opts, args):
items = lib.items(ui.decargs(args))
self._mbsubmit(items)
mbsubmit_cmd.func = func
return [mbsubmit_cmd]
def _mbsubmit(self, items):
"""Print track information to be submitted to MusicBrainz."""
for i in sorted(items, key=lambda i: i.track):
print_data(None, i, self.config["format"].as_str())
+65 -38
View File
@@ -15,12 +15,12 @@
"""Update library's tags using MusicBrainz.
"""
from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from beets.autotag import hooks
import re
from collections import defaultdict
import re
from beets import autotag, library, ui, util
from beets.autotag import hooks
from beets.plugins import BeetsPlugin, apply_item_changes
MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}"
@@ -30,28 +30,41 @@ class MBSyncPlugin(BeetsPlugin):
super().__init__()
def commands(self):
cmd = ui.Subcommand('mbsync',
help='update metadata from musicbrainz')
cmd = ui.Subcommand("mbsync", help="update metadata from musicbrainz")
cmd.parser.add_option(
'-p', '--pretend', action='store_true',
help='show all changes but do nothing')
"-p",
"--pretend",
action="store_true",
help="show all changes but do nothing",
)
cmd.parser.add_option(
'-m', '--move', action='store_true', dest='move',
help="move files in the library directory")
"-m",
"--move",
action="store_true",
dest="move",
help="move files in the library directory",
)
cmd.parser.add_option(
'-M', '--nomove', action='store_false', dest='move',
help="don't move files in library")
"-M",
"--nomove",
action="store_false",
dest="move",
help="don't move files in library",
)
cmd.parser.add_option(
'-W', '--nowrite', action='store_false',
default=None, dest='write',
help="don't write updated metadata to files")
"-W",
"--nowrite",
action="store_false",
default=None,
dest="write",
help="don't write updated metadata to files",
)
cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
def func(self, lib, opts, args):
"""Command handler for the mbsync function.
"""
"""Command handler for the mbsync function."""
move = ui.should_move(opts.move)
pretend = opts.pretend
write = ui.should_write(opts.write)
@@ -64,25 +77,30 @@ class MBSyncPlugin(BeetsPlugin):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + ['singleton:true']):
for item in lib.items(query + ["singleton:true"]):
item_formatted = format(item)
if not item.mb_trackid:
self._log.info('Skipping singleton with no mb_trackid: {0}',
item_formatted)
self._log.info(
"Skipping singleton with no mb_trackid: {0}", item_formatted
)
continue
# Do we have a valid MusicBrainz track ID?
if not re.match(MBID_REGEX, item.mb_trackid):
self._log.info('Skipping singleton with invalid mb_trackid:' +
' {0}', item_formatted)
self._log.info(
"Skipping singleton with invalid mb_trackid:" + " {0}",
item_formatted,
)
continue
# Get the MusicBrainz recording info.
track_info = hooks.track_for_mbid(item.mb_trackid)
if not track_info:
self._log.info('Recording ID not found: {0} for track {0}',
item.mb_trackid,
item_formatted)
self._log.info(
"Recording ID not found: {0} for track {0}",
item.mb_trackid,
item_formatted,
)
continue
# Apply.
@@ -98,24 +116,29 @@ class MBSyncPlugin(BeetsPlugin):
for a in lib.albums(query):
album_formatted = format(a)
if not a.mb_albumid:
self._log.info('Skipping album with no mb_albumid: {0}',
album_formatted)
self._log.info(
"Skipping album with no mb_albumid: {0}", album_formatted
)
continue
items = list(a.items())
# Do we have a valid MusicBrainz album ID?
if not re.match(MBID_REGEX, a.mb_albumid):
self._log.info('Skipping album with invalid mb_albumid: {0}',
album_formatted)
self._log.info(
"Skipping album with invalid mb_albumid: {0}",
album_formatted,
)
continue
# Get the MusicBrainz album information.
album_info = hooks.album_for_mbid(a.mb_albumid)
if not album_info:
self._log.info('Release ID {0} not found for album {1}',
a.mb_albumid,
album_formatted)
self._log.info(
"Release ID {0} not found for album {1}",
a.mb_albumid,
album_formatted,
)
continue
# Map release track and recording MBIDs to their information.
@@ -132,8 +155,10 @@ class MBSyncPlugin(BeetsPlugin):
# work for albums that have missing or extra tracks.
mapping = {}
for item in items:
if item.mb_releasetrackid and \
item.mb_releasetrackid in releasetrack_index:
if (
item.mb_releasetrackid
and item.mb_releasetrackid in releasetrack_index
):
mapping[item] = releasetrack_index[item.mb_releasetrackid]
else:
candidates = track_index[item.mb_trackid]
@@ -143,13 +168,15 @@ class MBSyncPlugin(BeetsPlugin):
# If there are multiple copies of a recording, they are
# disambiguated using their disc and track number.
for c in candidates:
if (c.medium_index == item.track and
c.medium == item.disc):
if (
c.medium_index == item.track
and c.medium == item.disc
):
mapping[item] = c
break
# Apply.
self._log.debug('applying changes to {}', album_formatted)
self._log.debug("applying changes to {}", album_formatted)
with lib.transaction():
autotag.apply_metadata(album_info, mapping)
changed = False
@@ -174,5 +201,5 @@ class MBSyncPlugin(BeetsPlugin):
# Move album art (and any inconsistent items).
if move and lib.directory in util.ancestry(items[0].path):
self._log.debug('moving album {0}', album_formatted)
self._log.debug("moving album {0}", album_formatted)
a.move()
+35 -27
View File
@@ -16,20 +16,20 @@
"""
from abc import abstractmethod, ABCMeta
from abc import ABCMeta, abstractmethod
from importlib import import_module
from confuse import ConfigValueError
from beets import ui
from beets.plugins import BeetsPlugin
METASYNC_MODULE = 'beetsplug.metasync'
METASYNC_MODULE = "beetsplug.metasync"
# Dictionary to map the MODULE and the CLASS NAME of meta sources
SOURCES = {
'amarok': 'Amarok',
'itunes': 'Itunes',
"amarok": "Amarok",
"itunes": "Itunes",
}
@@ -45,13 +45,13 @@ class MetaSource(metaclass=ABCMeta):
def load_meta_sources():
""" Returns a dictionary of all the MetaSources
"""Returns a dictionary of all the MetaSources
E.g., {'itunes': Itunes} with isinstance(Itunes, MetaSource) true
"""
meta_sources = {}
for module_path, class_name in SOURCES.items():
module = import_module(METASYNC_MODULE + '.' + module_path)
module = import_module(METASYNC_MODULE + "." + module_path)
meta_sources[class_name.lower()] = getattr(module, class_name)
return meta_sources
@@ -61,8 +61,7 @@ META_SOURCES = load_meta_sources()
def load_item_types():
""" Returns a dictionary containing the item_types of all the MetaSources
"""
"""Returns a dictionary containing the item_types of all the MetaSources"""
item_types = {}
for meta_source in META_SOURCES.values():
item_types.update(meta_source.item_types)
@@ -70,42 +69,50 @@ def load_item_types():
class MetaSyncPlugin(BeetsPlugin):
item_types = load_item_types()
def __init__(self):
super().__init__()
def commands(self):
cmd = ui.Subcommand('metasync',
help='update metadata from music player libraries')
cmd.parser.add_option('-p', '--pretend', action='store_true',
help='show all changes but do nothing')
cmd.parser.add_option('-s', '--source', default=[],
action='append', dest='sources',
help='comma-separated list of sources to sync')
cmd = ui.Subcommand(
"metasync", help="update metadata from music player libraries"
)
cmd.parser.add_option(
"-p",
"--pretend",
action="store_true",
help="show all changes but do nothing",
)
cmd.parser.add_option(
"-s",
"--source",
default=[],
action="append",
dest="sources",
help="comma-separated list of sources to sync",
)
cmd.parser.add_format_option()
cmd.func = self.func
return [cmd]
def func(self, lib, opts, args):
"""Command handler for the metasync function.
"""
"""Command handler for the metasync function."""
pretend = opts.pretend
query = ui.decargs(args)
sources = []
for source in opts.sources:
sources.extend(source.split(','))
sources.extend(source.split(","))
sources = sources or self.config['source'].as_str_seq()
sources = sources or self.config["source"].as_str_seq()
meta_source_instances = {}
items = lib.items(query)
# Avoid needlessly instantiating meta sources (can be expensive)
if not items:
self._log.info('No items found matching query')
self._log.info("No items found matching query")
return
# Instantiate the meta sources
@@ -113,18 +120,19 @@ class MetaSyncPlugin(BeetsPlugin):
try:
cls = META_SOURCES[player]
except KeyError:
self._log.error('Unknown metadata source \'{}\''.format(
player))
self._log.error("Unknown metadata source '{}'".format(player))
try:
meta_source_instances[player] = cls(self.config, self._log)
except (ImportError, ConfigValueError) as e:
self._log.error('Failed to instantiate metadata source '
'\'{}\': {}'.format(player, e))
self._log.error(
"Failed to instantiate metadata source "
"'{}': {}".format(player, e)
)
# Avoid needlessly iterating over items
if not meta_source_instances:
self._log.error('No valid metadata sources found')
self._log.error("No valid metadata sources found")
return
# Sync the items with all of the meta sources
+35 -33
View File
@@ -16,35 +16,35 @@
"""
from os.path import basename
from datetime import datetime
from os.path import basename
from time import mktime
from xml.sax.saxutils import quoteattr
from beets.util import displayable_path
from beets.dbcore import types
from beets.library import DateType
from beets.util import displayable_path
from beetsplug.metasync import MetaSource
def import_dbus():
try:
return __import__('dbus')
return __import__("dbus")
except ImportError:
return None
dbus = import_dbus()
class Amarok(MetaSource):
item_types = {
'amarok_rating': types.INTEGER,
'amarok_score': types.FLOAT,
'amarok_uid': types.STRING,
'amarok_playcount': types.INTEGER,
'amarok_firstplayed': DateType(),
'amarok_lastplayed': DateType(),
"amarok_rating": types.INTEGER,
"amarok_score": types.FLOAT,
"amarok_uid": types.STRING,
"amarok_playcount": types.INTEGER,
"amarok_firstplayed": DateType(),
"amarok_lastplayed": DateType(),
}
query_xml = '<query version="1.0"> \
@@ -57,10 +57,11 @@ class Amarok(MetaSource):
super().__init__(config, log)
if not dbus:
raise ImportError('failed to import dbus')
raise ImportError("failed to import dbus")
self.collection = \
dbus.SessionBus().get_object('org.kde.amarok', '/Collection')
self.collection = dbus.SessionBus().get_object(
"org.kde.amarok", "/Collection"
)
def sync_from_source(self, item):
path = displayable_path(item.path)
@@ -73,35 +74,36 @@ class Amarok(MetaSource):
self.query_xml % quoteattr(basename(path))
)
for result in results:
if result['xesam:url'] != path:
if result["xesam:url"] != path:
continue
item.amarok_rating = result['xesam:userRating']
item.amarok_score = result['xesam:autoRating']
item.amarok_playcount = result['xesam:useCount']
item.amarok_uid = \
result['xesam:id'].replace('amarok-sqltrackuid://', '')
item.amarok_rating = result["xesam:userRating"]
item.amarok_score = result["xesam:autoRating"]
item.amarok_playcount = result["xesam:useCount"]
item.amarok_uid = result["xesam:id"].replace(
"amarok-sqltrackuid://", ""
)
if result['xesam:firstUsed'][0][0] != 0:
if result["xesam:firstUsed"][0][0] != 0:
# These dates are stored as timestamps in amarok's db, but
# exposed over dbus as fixed integers in the current timezone.
first_played = datetime(
result['xesam:firstUsed'][0][0],
result['xesam:firstUsed'][0][1],
result['xesam:firstUsed'][0][2],
result['xesam:firstUsed'][1][0],
result['xesam:firstUsed'][1][1],
result['xesam:firstUsed'][1][2]
result["xesam:firstUsed"][0][0],
result["xesam:firstUsed"][0][1],
result["xesam:firstUsed"][0][2],
result["xesam:firstUsed"][1][0],
result["xesam:firstUsed"][1][1],
result["xesam:firstUsed"][1][2],
)
if result['xesam:lastUsed'][0][0] != 0:
if result["xesam:lastUsed"][0][0] != 0:
last_played = datetime(
result['xesam:lastUsed'][0][0],
result['xesam:lastUsed'][0][1],
result['xesam:lastUsed'][0][2],
result['xesam:lastUsed'][1][0],
result['xesam:lastUsed'][1][1],
result['xesam:lastUsed'][1][2]
result["xesam:lastUsed"][0][0],
result["xesam:lastUsed"][0][1],
result["xesam:lastUsed"][0][2],
result["xesam:lastUsed"][1][0],
result["xesam:lastUsed"][1][1],
result["xesam:lastUsed"][1][2],
)
else:
last_played = first_played
+48 -45
View File
@@ -16,31 +16,32 @@
"""
from contextlib import contextmanager
import os
import plistlib
import shutil
import tempfile
import plistlib
from urllib.parse import urlparse, unquote
from contextlib import contextmanager
from time import mktime
from urllib.parse import unquote, urlparse
from confuse import ConfigValueError
from beets import util
from beets.dbcore import types
from beets.library import DateType
from confuse import ConfigValueError
from beets.util import bytestring_path, syspath
from beetsplug.metasync import MetaSource
@contextmanager
def create_temporary_copy(path):
temp_dir = tempfile.mkdtemp()
temp_path = os.path.join(temp_dir, 'temp_itunes_lib')
shutil.copyfile(path, temp_path)
temp_dir = bytestring_path(tempfile.mkdtemp())
temp_path = os.path.join(temp_dir, b"temp_itunes_lib")
shutil.copyfile(syspath(path), syspath(temp_path))
try:
yield temp_path
finally:
shutil.rmtree(temp_dir)
shutil.rmtree(syspath(temp_dir))
def _norm_itunes_path(path):
@@ -54,72 +55,74 @@ def _norm_itunes_path(path):
# which is unwanted in the case of Windows systems.
# E.g., '\\G:\\Music\\bar' needs to be stripped to 'G:\\Music\\bar'
return util.bytestring_path(os.path.normpath(
unquote(urlparse(path).path)).lstrip('\\')).lower()
return util.bytestring_path(
os.path.normpath(unquote(urlparse(path).path)).lstrip("\\")
).lower()
class Itunes(MetaSource):
item_types = {
'itunes_rating': types.INTEGER, # 0..100 scale
'itunes_playcount': types.INTEGER,
'itunes_skipcount': types.INTEGER,
'itunes_lastplayed': DateType(),
'itunes_lastskipped': DateType(),
'itunes_dateadded': DateType(),
"itunes_rating": types.INTEGER, # 0..100 scale
"itunes_playcount": types.INTEGER,
"itunes_skipcount": types.INTEGER,
"itunes_lastplayed": DateType(),
"itunes_lastskipped": DateType(),
"itunes_dateadded": DateType(),
}
def __init__(self, config, log):
super().__init__(config, log)
config.add({'itunes': {
'library': '~/Music/iTunes/iTunes Library.xml'
}})
config.add({"itunes": {"library": "~/Music/iTunes/iTunes Library.xml"}})
# Load the iTunes library, which has to be the .xml one (not the .itl)
library_path = config['itunes']['library'].as_filename()
library_path = config["itunes"]["library"].as_filename()
try:
self._log.debug(
f'loading iTunes library from {library_path}')
self._log.debug(f"loading iTunes library from {library_path}")
with create_temporary_copy(library_path) as library_copy:
with open(library_copy, 'rb') as library_copy_f:
with open(library_copy, "rb") as library_copy_f:
raw_library = plistlib.load(library_copy_f)
except OSError as e:
raise ConfigValueError('invalid iTunes library: ' + e.strerror)
raise ConfigValueError("invalid iTunes library: " + e.strerror)
except Exception:
# It's likely the user configured their '.itl' library (<> xml)
if os.path.splitext(library_path)[1].lower() != '.xml':
hint = ': please ensure that the configured path' \
' points to the .XML library'
if os.path.splitext(library_path)[1].lower() != ".xml":
hint = (
": please ensure that the configured path"
" points to the .XML library"
)
else:
hint = ''
raise ConfigValueError('invalid iTunes library' + hint)
hint = ""
raise ConfigValueError("invalid iTunes library" + hint)
# Make the iTunes library queryable using the path
self.collection = {_norm_itunes_path(track['Location']): track
for track in raw_library['Tracks'].values()
if 'Location' in track}
self.collection = {
_norm_itunes_path(track["Location"]): track
for track in raw_library["Tracks"].values()
if "Location" in track
}
def sync_from_source(self, item):
result = self.collection.get(util.bytestring_path(item.path).lower())
if not result:
self._log.warning(f'no iTunes match found for {item}')
self._log.warning(f"no iTunes match found for {item}")
return
item.itunes_rating = result.get('Rating')
item.itunes_playcount = result.get('Play Count')
item.itunes_skipcount = result.get('Skip Count')
item.itunes_rating = result.get("Rating")
item.itunes_playcount = result.get("Play Count")
item.itunes_skipcount = result.get("Skip Count")
if result.get('Play Date UTC'):
if result.get("Play Date UTC"):
item.itunes_lastplayed = mktime(
result.get('Play Date UTC').timetuple())
result.get("Play Date UTC").timetuple()
)
if result.get('Skip Date'):
if result.get("Skip Date"):
item.itunes_lastskipped = mktime(
result.get('Skip Date').timetuple())
result.get("Skip Date").timetuple()
)
if result.get('Date Added'):
item.itunes_dateadded = mktime(
result.get('Date Added').timetuple())
if result.get("Date Added"):
item.itunes_dateadded = mktime(result.get("Date Added").timetuple())
+98 -79
View File
@@ -16,21 +16,21 @@
"""List missing tracks.
"""
import musicbrainzngs
from musicbrainzngs.musicbrainz import MusicBrainzError
from collections import defaultdict
import musicbrainzngs
from musicbrainzngs.musicbrainz import MusicBrainzError
from beets import config
from beets.autotag import hooks
from beets.dbcore import types
from beets.library import Item
from beets.plugins import BeetsPlugin
from beets.ui import decargs, print_, Subcommand
from beets import config
from beets.dbcore import types
from beets.ui import Subcommand, decargs, print_
def _missing_count(album):
"""Return number of missing items in `album`.
"""
"""Return number of missing items in `album`."""
return (album.albumtotal or 0) - len(album.items())
@@ -45,80 +45,93 @@ def _item(track_info, album_info, album_id):
t = track_info
a = album_info
return Item(**{
'album_id': album_id,
'album': a.album,
'albumartist': a.artist,
'albumartist_credit': a.artist_credit,
'albumartist_sort': a.artist_sort,
'albumdisambig': a.albumdisambig,
'albumstatus': a.albumstatus,
'albumtype': a.albumtype,
'artist': t.artist,
'artist_credit': t.artist_credit,
'artist_sort': t.artist_sort,
'asin': a.asin,
'catalognum': a.catalognum,
'comp': a.va,
'country': a.country,
'day': a.day,
'disc': t.medium,
'disctitle': t.disctitle,
'disctotal': a.mediums,
'label': a.label,
'language': a.language,
'length': t.length,
'mb_albumid': a.album_id,
'mb_artistid': t.artist_id,
'mb_releasegroupid': a.releasegroup_id,
'mb_trackid': t.track_id,
'media': t.media,
'month': a.month,
'script': a.script,
'title': t.title,
'track': t.index,
'tracktotal': len(a.tracks),
'year': a.year,
})
return Item(
**{
"album_id": album_id,
"album": a.album,
"albumartist": a.artist,
"albumartist_credit": a.artist_credit,
"albumartist_sort": a.artist_sort,
"albumdisambig": a.albumdisambig,
"albumstatus": a.albumstatus,
"albumtype": a.albumtype,
"artist": t.artist,
"artist_credit": t.artist_credit,
"artist_sort": t.artist_sort,
"asin": a.asin,
"catalognum": a.catalognum,
"comp": a.va,
"country": a.country,
"day": a.day,
"disc": t.medium,
"disctitle": t.disctitle,
"disctotal": a.mediums,
"label": a.label,
"language": a.language,
"length": t.length,
"mb_albumid": a.album_id,
"mb_artistid": t.artist_id,
"mb_releasegroupid": a.releasegroup_id,
"mb_trackid": t.track_id,
"media": t.media,
"month": a.month,
"script": a.script,
"title": t.title,
"track": t.index,
"tracktotal": len(a.tracks),
"year": a.year,
}
)
class MissingPlugin(BeetsPlugin):
"""List missing tracks
"""
"""List missing tracks"""
album_types = {
'missing': types.INTEGER,
"missing": types.INTEGER,
}
def __init__(self):
super().__init__()
self.config.add({
'count': False,
'total': False,
'album': False,
})
self.config.add(
{
"count": False,
"total": False,
"album": False,
}
)
self.album_template_fields['missing'] = _missing_count
self.album_template_fields["missing"] = _missing_count
self._command = Subcommand('missing',
help=__doc__,
aliases=['miss'])
self._command = Subcommand("missing", help=__doc__, aliases=["miss"])
self._command.parser.add_option(
'-c', '--count', dest='count', action='store_true',
help='count missing tracks per album')
"-c",
"--count",
dest="count",
action="store_true",
help="count missing tracks per album",
)
self._command.parser.add_option(
'-t', '--total', dest='total', action='store_true',
help='count total of missing tracks')
"-t",
"--total",
dest="total",
action="store_true",
help="count total of missing tracks",
)
self._command.parser.add_option(
'-a', '--album', dest='album', action='store_true',
help='show missing albums for artist instead of tracks')
"-a",
"--album",
dest="album",
action="store_true",
help="show missing albums for artist instead of tracks",
)
self._command.parser.add_format_option()
def commands(self):
def _miss(lib, opts, args):
self.config.set_args(opts)
albms = self.config['album'].get()
albms = self.config["album"].get()
helper = self._missing_albums if albms else self._missing_tracks
helper(lib, decargs(args))
@@ -132,9 +145,9 @@ class MissingPlugin(BeetsPlugin):
"""
albums = lib.albums(query)
count = self.config['count'].get()
total = self.config['total'].get()
fmt = config['format_album' if count else 'format_item'].get()
count = self.config["count"].get()
total = self.config["total"].get()
fmt = config["format_album" if count else "format_item"].get()
if total:
print(sum([_missing_count(a) for a in albums]))
@@ -142,7 +155,7 @@ class MissingPlugin(BeetsPlugin):
# Default format string for count mode.
if count:
fmt += ': $missing'
fmt += ": $missing"
for album in albums:
if count:
@@ -157,13 +170,13 @@ class MissingPlugin(BeetsPlugin):
"""Print a listing of albums missing from each artist in the library
matching query.
"""
total = self.config['total'].get()
total = self.config["total"].get()
albums = lib.albums(query)
# build dict mapping artist to list of their albums in library
albums_by_artist = defaultdict(list)
for alb in albums:
artist = (alb['albumartist'], alb['mb_albumartistid'])
artist = (alb["albumartist"], alb["mb_albumartistid"])
albums_by_artist[artist].append(alb)
total_missing = 0
@@ -171,20 +184,24 @@ class MissingPlugin(BeetsPlugin):
# build dict mapping artist to list of all albums
for artist, albums in albums_by_artist.items():
if artist[1] is None or artist[1] == "":
albs_no_mbid = ["'" + a['album'] + "'" for a in albums]
albs_no_mbid = ["'" + a["album"] + "'" for a in albums]
self._log.info(
"No musicbrainz ID for artist '{}' found in album(s) {}; "
"skipping", artist[0], ", ".join(albs_no_mbid)
"skipping",
artist[0],
", ".join(albs_no_mbid),
)
continue
try:
resp = musicbrainzngs.browse_release_groups(artist=artist[1])
release_groups = resp['release-group-list']
release_groups = resp["release-group-list"]
except MusicBrainzError as err:
self._log.info(
"Couldn't fetch info for artist '{}' ({}) - '{}'",
artist[0], artist[1], err
artist[0],
artist[1],
err,
)
continue
@@ -193,7 +210,7 @@ class MissingPlugin(BeetsPlugin):
for rg in release_groups:
missing.append(rg)
for alb in albums:
if alb['mb_releasegroupid'] == rg['id']:
if alb["mb_releasegroupid"] == rg["id"]:
missing.remove(rg)
present.append(rg)
break
@@ -202,7 +219,7 @@ class MissingPlugin(BeetsPlugin):
if total:
continue
missing_titles = {rg['title'] for rg in missing}
missing_titles = {rg["title"] for rg in missing}
for release_title in missing_titles:
print_("{} - {}".format(artist[0], release_title))
@@ -211,16 +228,18 @@ class MissingPlugin(BeetsPlugin):
print(total_missing)
def _missing(self, album):
"""Query MusicBrainz to determine items missing from `album`.
"""
"""Query MusicBrainz to determine items missing from `album`."""
item_mbids = [x.mb_trackid for x in album.items()]
if len(list(album.items())) < album.albumtotal:
# fetch missing items
# TODO: Implement caching that without breaking other stuff
album_info = hooks.album_for_mbid(album.mb_albumid)
for track_info in getattr(album_info, 'tracks', []):
for track_info in getattr(album_info, "tracks", []):
if track_info.track_id not in item_mbids:
item = _item(track_info, album_info, album.id)
self._log.debug('track {0} in album {1}',
track_info.track_id, album_info.album_id)
self._log.debug(
"track {0} in album {1}",
track_info.track_id,
album_info.album_id,
)
yield item
+122 -119
View File
@@ -13,16 +13,14 @@
# included in all copies or substantial portions of the Software.
import mpd
import time
import os
import time
from beets import ui
from beets import config
from beets import plugins
from beets import library
from beets.util import displayable_path
import mpd
from beets import config, library, plugins, ui
from beets.dbcore import types
from beets.util import displayable_path
# If we lose the connection, how many times do we want to retry and how
# much time should we wait between retries?
@@ -30,60 +28,55 @@ RETRIES = 10
RETRY_INTERVAL = 5
mpd_config = config['mpd']
mpd_config = config["mpd"]
def is_url(path):
"""Try to determine if the path is an URL.
"""
"""Try to determine if the path is an URL."""
if isinstance(path, bytes): # if it's bytes, then it's a path
return False
return path.split('://', 1)[0] in ['http', 'https']
return path.split("://", 1)[0] in ["http", "https"]
class MPDClientWrapper:
def __init__(self, log):
self._log = log
self.music_directory = mpd_config['music_directory'].as_str()
self.strip_path = mpd_config['strip_path'].as_str()
self.music_directory = mpd_config["music_directory"].as_str()
self.strip_path = mpd_config["strip_path"].as_str()
# Ensure strip_path end with '/'
if not self.strip_path.endswith('/'):
self.strip_path += '/'
if not self.strip_path.endswith("/"):
self.strip_path += "/"
self._log.debug('music_directory: {0}', self.music_directory)
self._log.debug('strip_path: {0}', self.strip_path)
self._log.debug("music_directory: {0}", self.music_directory)
self._log.debug("strip_path: {0}", self.strip_path)
self.client = mpd.MPDClient()
def connect(self):
"""Connect to the MPD.
"""
host = mpd_config['host'].as_str()
port = mpd_config['port'].get(int)
"""Connect to the MPD."""
host = mpd_config["host"].as_str()
port = mpd_config["port"].get(int)
if host[0] in ['/', '~']:
if host[0] in ["/", "~"]:
host = os.path.expanduser(host)
self._log.info('connecting to {0}:{1}', host, port)
self._log.info("connecting to {0}:{1}", host, port)
try:
self.client.connect(host, port)
except OSError as e:
raise ui.UserError(f'could not connect to MPD: {e}')
raise ui.UserError(f"could not connect to MPD: {e}")
password = mpd_config['password'].as_str()
password = mpd_config["password"].as_str()
if password:
try:
self.client.password(password)
except mpd.CommandError as e:
raise ui.UserError(
f'could not authenticate to MPD: {e}'
)
raise ui.UserError(f"could not authenticate to MPD: {e}")
def disconnect(self):
"""Disconnect from the MPD.
"""
"""Disconnect from the MPD."""
self.client.close()
self.client.disconnect()
@@ -94,11 +87,11 @@ class MPDClientWrapper:
try:
return getattr(self.client, command)()
except (OSError, mpd.ConnectionError) as err:
self._log.error('{0}', err)
self._log.error("{0}", err)
if retries <= 0:
# if we exited without breaking, we couldn't reconnect in time :(
raise ui.UserError('communication with MPD server failed')
raise ui.UserError("communication with MPD server failed")
time.sleep(RETRY_INTERVAL)
@@ -119,28 +112,27 @@ class MPDClientWrapper:
`strip_path` defaults to ''.
"""
result = None
entry = self.get('currentsong')
if 'file' in entry:
if not is_url(entry['file']):
file = entry['file']
entry = self.get("currentsong")
if "file" in entry:
if not is_url(entry["file"]):
file = entry["file"]
if file.startswith(self.strip_path):
file = file[len(self.strip_path):]
file = file[len(self.strip_path) :]
result = os.path.join(self.music_directory, file)
else:
result = entry['file']
self._log.debug('returning: {0}', result)
return result, entry.get('id')
result = entry["file"]
self._log.debug("returning: {0}", result)
return result, entry.get("id")
def status(self):
"""Return the current status of the MPD.
"""
return self.get('status')
"""Return the current status of the MPD."""
return self.get("status")
def events(self):
"""Return list of events. This may block a long time while waiting for
an answer from MPD.
"""
return self.get('idle')
return self.get("idle")
class MPDStats:
@@ -148,8 +140,8 @@ class MPDStats:
self.lib = lib
self._log = log
self.do_rating = mpd_config['rating'].get(bool)
self.rating_mix = mpd_config['rating_mix'].get(float)
self.do_rating = mpd_config["rating"].get(bool)
self.rating_mix = mpd_config["rating_mix"].get(float)
self.time_threshold = 10.0 # TODO: maybe add config option?
self.now_playing = None
@@ -160,22 +152,20 @@ class MPDStats:
old rating and the fact if it was skipped or not.
"""
if skipped:
rolling = (rating - rating / 2.0)
rolling = rating - rating / 2.0
else:
rolling = (rating + (1.0 - rating) / 2.0)
rolling = rating + (1.0 - rating) / 2.0
stable = (play_count + 1.0) / (play_count + skip_count + 2.0)
return (self.rating_mix * stable +
(1.0 - self.rating_mix) * rolling)
return self.rating_mix * stable + (1.0 - self.rating_mix) * rolling
def get_item(self, path):
"""Return the beets item related to path.
"""
query = library.PathQuery('path', path)
"""Return the beets item related to path."""
query = library.PathQuery("path", path)
item = self.lib.items(query).get()
if item:
return item
else:
self._log.info('item not found: {0}', displayable_path(path))
self._log.info("item not found: {0}", displayable_path(path))
def update_item(self, item, attribute, value=None, increment=None):
"""Update the beets item. Set attribute to value or increment the value
@@ -193,10 +183,12 @@ class MPDStats:
item[attribute] = value
item.store()
self._log.debug('updated: {0} = {1} [{2}]',
attribute,
item[attribute],
displayable_path(item.path))
self._log.debug(
"updated: {0} = {1} [{2}]",
attribute,
item[attribute],
displayable_path(item.path),
)
def update_rating(self, item, skipped):
"""Update the rating for a beets item. The `item` can either be a
@@ -207,12 +199,13 @@ class MPDStats:
item.load()
rating = self.rating(
int(item.get('play_count', 0)),
int(item.get('skip_count', 0)),
float(item.get('rating', 0.5)),
skipped)
int(item.get("play_count", 0)),
int(item.get("skip_count", 0)),
float(item.get("rating", 0.5)),
skipped,
)
self.update_item(item, 'rating', rating)
self.update_item(item, "rating", rating)
def handle_song_change(self, song):
"""Determine if a song was skipped or not and update its attributes.
@@ -222,7 +215,7 @@ class MPDStats:
Returns whether the change was manual (skipped previous song or not)
"""
diff = abs(song['remaining'] - (time.time() - song['started']))
diff = abs(song["remaining"] - (time.time() - song["started"]))
skipped = diff >= self.time_threshold
@@ -232,89 +225,89 @@ class MPDStats:
self.handle_played(song)
if self.do_rating:
self.update_rating(song['beets_item'], skipped)
self.update_rating(song["beets_item"], skipped)
return skipped
def handle_played(self, song):
"""Updates the play count of a song.
"""
self.update_item(song['beets_item'], 'play_count', increment=1)
self._log.info('played {0}', displayable_path(song['path']))
"""Updates the play count of a song."""
self.update_item(song["beets_item"], "play_count", increment=1)
self._log.info("played {0}", displayable_path(song["path"]))
def handle_skipped(self, song):
"""Updates the skip count of a song.
"""
self.update_item(song['beets_item'], 'skip_count', increment=1)
self._log.info('skipped {0}', displayable_path(song['path']))
"""Updates the skip count of a song."""
self.update_item(song["beets_item"], "skip_count", increment=1)
self._log.info("skipped {0}", displayable_path(song["path"]))
def on_stop(self, status):
self._log.info('stop')
self._log.info("stop")
# if the current song stays the same it means that we stopped on the
# current track and should not record a skip.
if self.now_playing and self.now_playing['id'] != status.get('songid'):
if self.now_playing and self.now_playing["id"] != status.get("songid"):
self.handle_song_change(self.now_playing)
self.now_playing = None
def on_pause(self, status):
self._log.info('pause')
self._log.info("pause")
self.now_playing = None
def on_play(self, status):
path, songid = self.mpd.currentsong()
if not path:
return
played, duration = map(int, status['time'].split(':', 1))
played, duration = map(int, status["time"].split(":", 1))
remaining = duration - played
if self.now_playing:
if self.now_playing['path'] != path:
if self.now_playing["path"] != path:
self.handle_song_change(self.now_playing)
else:
# In case we got mpd play event with same song playing
# multiple times,
# assume low diff means redundant second play event
# after natural song start.
diff = abs(time.time() - self.now_playing['started'])
diff = abs(time.time() - self.now_playing["started"])
if diff <= self.time_threshold:
return
if self.now_playing['path'] == path and played == 0:
if self.now_playing["path"] == path and played == 0:
self.handle_song_change(self.now_playing)
if is_url(path):
self._log.info('playing stream {0}', displayable_path(path))
self._log.info("playing stream {0}", displayable_path(path))
self.now_playing = None
return
self._log.info('playing {0}', displayable_path(path))
self._log.info("playing {0}", displayable_path(path))
self.now_playing = {
'started': time.time(),
'remaining': remaining,
'path': path,
'id': songid,
'beets_item': self.get_item(path),
"started": time.time(),
"remaining": remaining,
"path": path,
"id": songid,
"beets_item": self.get_item(path),
}
self.update_item(self.now_playing['beets_item'],
'last_played', value=int(time.time()))
self.update_item(
self.now_playing["beets_item"],
"last_played",
value=int(time.time()),
)
def run(self):
self.mpd.connect()
events = ['player']
events = ["player"]
while True:
if 'player' in events:
if "player" in events:
status = self.mpd.status()
handler = getattr(self, 'on_' + status['state'], None)
handler = getattr(self, "on_" + status["state"], None)
if handler:
handler(status)
@@ -325,51 +318,61 @@ class MPDStats:
class MPDStatsPlugin(plugins.BeetsPlugin):
item_types = {
'play_count': types.INTEGER,
'skip_count': types.INTEGER,
'last_played': library.DateType(),
'rating': types.FLOAT,
"play_count": types.INTEGER,
"skip_count": types.INTEGER,
"last_played": library.DateType(),
"rating": types.FLOAT,
}
def __init__(self):
super().__init__()
mpd_config.add({
'music_directory': config['directory'].as_filename(),
'strip_path': '',
'rating': True,
'rating_mix': 0.75,
'host': os.environ.get('MPD_HOST', 'localhost'),
'port': int(os.environ.get('MPD_PORT', 6600)),
'password': '',
})
mpd_config['password'].redact = True
mpd_config.add(
{
"music_directory": config["directory"].as_filename(),
"strip_path": "",
"rating": True,
"rating_mix": 0.75,
"host": os.environ.get("MPD_HOST", "localhost"),
"port": int(os.environ.get("MPD_PORT", 6600)),
"password": "",
}
)
mpd_config["password"].redact = True
def commands(self):
cmd = ui.Subcommand(
'mpdstats',
help='run a MPD client to gather play statistics')
"mpdstats", help="run a MPD client to gather play statistics"
)
cmd.parser.add_option(
'--host', dest='host', type='string',
help='set the hostname of the server to connect to')
"--host",
dest="host",
type="string",
help="set the hostname of the server to connect to",
)
cmd.parser.add_option(
'--port', dest='port', type='int',
help='set the port of the MPD server to connect to')
"--port",
dest="port",
type="int",
help="set the port of the MPD server to connect to",
)
cmd.parser.add_option(
'--password', dest='password', type='string',
help='set the password of the MPD server to connect to')
"--password",
dest="password",
type="string",
help="set the password of the MPD server to connect to",
)
def func(lib, opts, args):
mpd_config.set_args(opts)
# Overrides for MPD settings.
if opts.host:
mpd_config['host'] = opts.host.decode('utf-8')
mpd_config["host"] = opts.host.decode("utf-8")
if opts.port:
mpd_config['host'] = int(opts.port)
mpd_config["host"] = int(opts.port)
if opts.password:
mpd_config['password'] = opts.password.decode('utf-8')
mpd_config["password"] = opts.password.decode("utf-8")
try:
MPDStats(lib, self._log).run()
+36 -33
View File
@@ -21,10 +21,11 @@ Put something like the following in your config.yaml to configure:
password: seekrit
"""
from beets.plugins import BeetsPlugin
import os
import socket
from beets import config
from beets.plugins import BeetsPlugin
# No need to introduce a dependency on an MPD library for such a
@@ -32,14 +33,15 @@ from beets import config
# easier.
class BufferedSocket:
"""Socket abstraction that allows reading by line."""
def __init__(self, host, port, sep=b'\n'):
if host[0] in ['/', '~']:
def __init__(self, host, port, sep=b"\n"):
if host[0] in ["/", "~"]:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.connect(os.path.expanduser(host))
else:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((host, port))
self.buf = b''
self.buf = b""
self.sep = sep
def readline(self):
@@ -52,7 +54,7 @@ class BufferedSocket:
res, self.buf = self.buf.split(self.sep, 1)
return res + self.sep
else:
return b''
return b""
def send(self, data):
self.sock.send(data)
@@ -64,63 +66,64 @@ class BufferedSocket:
class MPDUpdatePlugin(BeetsPlugin):
def __init__(self):
super().__init__()
config['mpd'].add({
'host': os.environ.get('MPD_HOST', 'localhost'),
'port': int(os.environ.get('MPD_PORT', 6600)),
'password': '',
})
config['mpd']['password'].redact = True
config["mpd"].add(
{
"host": os.environ.get("MPD_HOST", "localhost"),
"port": int(os.environ.get("MPD_PORT", 6600)),
"password": "",
}
)
config["mpd"]["password"].redact = True
# For backwards compatibility, use any values from the
# plugin-specific "mpdupdate" section.
for key in config['mpd'].keys():
for key in config["mpd"].keys():
if self.config[key].exists():
config['mpd'][key] = self.config[key].get()
config["mpd"][key] = self.config[key].get()
self.register_listener('database_change', self.db_change)
self.register_listener("database_change", self.db_change)
def db_change(self, lib, model):
self.register_listener('cli_exit', self.update)
self.register_listener("cli_exit", self.update)
def update(self, lib):
self.update_mpd(
config['mpd']['host'].as_str(),
config['mpd']['port'].get(int),
config['mpd']['password'].as_str(),
config["mpd"]["host"].as_str(),
config["mpd"]["port"].get(int),
config["mpd"]["password"].as_str(),
)
def update_mpd(self, host='localhost', port=6600, password=None):
def update_mpd(self, host="localhost", port=6600, password=None):
"""Sends the "update" command to the MPD server indicated,
possibly authenticating with a password first.
"""
self._log.info('Updating MPD database...')
self._log.info("Updating MPD database...")
try:
s = BufferedSocket(host, port)
except OSError as e:
self._log.warning('MPD connection failed: {0}',
str(e.strerror))
self._log.warning("MPD connection failed: {0}", str(e.strerror))
return
resp = s.readline()
if b'OK MPD' not in resp:
self._log.warning('MPD connection failed: {0!r}', resp)
if b"OK MPD" not in resp:
self._log.warning("MPD connection failed: {0!r}", resp)
return
if password:
s.send(b'password "%s"\n' % password.encode('utf8'))
s.send(b'password "%s"\n' % password.encode("utf8"))
resp = s.readline()
if b'OK' not in resp:
self._log.warning('Authentication failed: {0!r}', resp)
s.send(b'close\n')
if b"OK" not in resp:
self._log.warning("Authentication failed: {0!r}", resp)
s.send(b"close\n")
s.close()
return
s.send(b'update\n')
s.send(b"update\n")
resp = s.readline()
if b'updating_db' not in resp:
self._log.warning('Update failed: {0!r}', resp)
if b"updating_db" not in resp:
self._log.warning("Update failed: {0!r}", resp)
s.send(b'close\n')
s.send(b"close\n")
s.close()
self._log.info('Database updated.')
self._log.info("Database updated.")
+95 -69
View File
@@ -17,37 +17,38 @@ and work composition date
"""
import musicbrainzngs
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']
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']
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.
"""
"""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)
@@ -62,8 +63,9 @@ def find_parentwork_info(mb_workid):
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"])
work_info = musicbrainzngs.get_work_by_id(
parent_id, includes=["artist-rels"]
)
return work_info, work_date
@@ -71,19 +73,20 @@ class ParentWorkPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
self.config.add({
'auto': False,
'force': False,
})
self.config.add(
{
"auto": False,
"force": False,
}
)
if self.config['auto']:
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)
force_parent = self.config["force"].get(bool)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
@@ -92,22 +95,26 @@ class ParentWorkPlugin(BeetsPlugin):
item.store()
if write:
item.try_write()
command = ui.Subcommand(
'parentwork',
help='fetch parent works, composers and dates')
"parentwork", help="fetch parent works, composers and dates"
)
command.parser.add_option(
'-f', '--force', dest='force',
action='store_true', default=None,
help='re-fetch when parent work is already present')
"-f",
"--force",
dest="force",
action="store_true",
default=None,
help="re-fetch when parent work is already present",
)
command.func = func
return [command]
def imported(self, session, task):
"""Import hook for fetching parent works automatically.
"""
force_parent = self.config['force'].get(bool)
"""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)
@@ -124,35 +131,38 @@ class ParentWorkPlugin(BeetsPlugin):
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':
if "artist-relation-list" in work_info["work"]:
for artist in work_info["work"]["artist-relation-list"]:
if artist["type"] == "composer":
composer_exists = True
parent_composer.append(artist['artist']['name'])
parent_composer_sort.append(artist['artist']['sort-name'])
if 'end' in artist.keys():
parentwork_info["parentwork_date"] = artist['end']
parent_composer.append(artist["artist"]["name"])
parent_composer_sort.append(artist["artist"]["sort-name"])
if "end" in artist.keys():
parentwork_info["parentwork_date"] = artist["end"]
parentwork_info['parent_composer'] = ', '.join(parent_composer)
parentwork_info['parent_composer_sort'] = ', '.join(
parent_composer_sort)
parentwork_info["parent_composer"] = ", ".join(parent_composer)
parentwork_info["parent_composer_sort"] = ", ".join(
parent_composer_sort
)
if not composer_exists:
self._log.debug(
'no composer for {}; add one at '
'https://musicbrainz.org/work/{}',
item, work_info['work']['id'],
"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']
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']
if "disambiguation" in work_info["work"]:
parentwork_info["parentwork_disambig"] = work_info["work"][
"disambiguation"
]
else:
parentwork_info['parentwork_disambig'] = None
parentwork_info["parentwork_disambig"] = None
return parentwork_info
@@ -169,13 +179,17 @@ class ParentWorkPlugin(BeetsPlugin):
"""
if not item.mb_workid:
self._log.info('No work for {}, \
add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
self._log.info(
"No work for {}, \
add one at https://musicbrainz.org/recording/{}",
item,
item.mb_trackid,
)
return
hasparent = hasattr(item, 'parentwork')
hasparent = hasattr(item, "parentwork")
work_changed = True
if hasattr(item, 'parentwork_workid_current'):
if hasattr(item, "parentwork_workid_current"):
work_changed = item.parentwork_workid_current != item.mb_workid
if force or not hasparent or work_changed:
try:
@@ -184,14 +198,18 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
self._log.debug("error fetching work: {}", e)
return
parent_info = self.get_info(item, work_info)
parent_info['parentwork_workid_current'] = item.mb_workid
if 'parent_composer' in parent_info:
self._log.debug("Work fetched: {} - {}",
parent_info['parentwork'],
parent_info['parent_composer'])
parent_info["parentwork_workid_current"] = item.mb_workid
if "parent_composer" in parent_info:
self._log.debug(
"Work fetched: {} - {}",
parent_info["parentwork"],
parent_info["parent_composer"],
)
else:
self._log.debug("Work fetched: {} - no parent composer",
parent_info['parentwork'])
self._log.debug(
"Work fetched: {} - no parent composer",
parent_info["parentwork"],
)
elif hasparent:
self._log.debug("{}: Work present, skipping", item)
@@ -203,9 +221,17 @@ add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
item[key] = value
if work_date:
item['work_date'] = work_date
item["work_date"] = work_date
return ui.show_model_changes(
item, fields=['parentwork', 'parentwork_disambig',
'mb_parentworkid', 'parent_composer',
'parent_composer_sort', 'work_date',
'parentwork_workid_current', 'parentwork_date'])
item,
fields=[
"parentwork",
"parentwork_disambig",
"mb_parentworkid",
"parent_composer",
"parent_composer_sort",
"work_date",
"parentwork_workid_current",
"parentwork_date",
],
)
+35 -34
View File
@@ -5,10 +5,13 @@ like the following in your config.yaml to configure:
file: 644
dir: 755
"""
import os
from beets import config, util
import stat
from beets import config
from beets.plugins import BeetsPlugin
from beets.util import ancestry
from beets.util import ancestry, displayable_path, syspath
def convert_perm(perm):
@@ -25,7 +28,7 @@ def check_permissions(path, permission):
"""Check whether the file's permissions equal the given vector.
Return a boolean.
"""
return oct(os.stat(path).st_mode & 0o777) == oct(permission)
return oct(stat.S_IMODE(os.stat(syspath(path)).st_mode)) == oct(permission)
def assert_permissions(path, permission, log):
@@ -33,24 +36,20 @@ def assert_permissions(path, permission, log):
log a warning message. Return a boolean indicating the match, like
`check_permissions`.
"""
if not check_permissions(util.syspath(path), permission):
log.warning(
'could not set permissions on {}',
util.displayable_path(path),
)
if not check_permissions(path, permission):
log.warning("could not set permissions on {}", displayable_path(path))
log.debug(
'set permissions to {}, but permissions are now {}',
"set permissions to {}, but permissions are now {}",
permission,
os.stat(util.syspath(path)).st_mode & 0o777,
os.stat(syspath(path)).st_mode & 0o777,
)
def dirs_in_library(library, item):
"""Creates a list of ancestor directories in the beets library path.
"""
return [ancestor
for ancestor in ancestry(item)
if ancestor.startswith(library)][1:]
"""Creates a list of ancestor directories in the beets library path."""
return [
ancestor for ancestor in ancestry(item) if ancestor.startswith(library)
][1:]
class Permissions(BeetsPlugin):
@@ -58,18 +57,19 @@ class Permissions(BeetsPlugin):
super().__init__()
# Adding defaults.
self.config.add({
'file': '644',
'dir': '755',
})
self.config.add(
{
"file": "644",
"dir": "755",
}
)
self.register_listener('item_imported', self.fix)
self.register_listener('album_imported', self.fix)
self.register_listener('art_set', self.fix_art)
self.register_listener("item_imported", self.fix)
self.register_listener("album_imported", self.fix)
self.register_listener("art_set", self.fix_art)
def fix(self, lib, item=None, album=None):
"""Fix the permissions for an imported Item or Album.
"""
"""Fix the permissions for an imported Item or Album."""
files = []
dirs = set()
if item:
@@ -82,8 +82,7 @@ class Permissions(BeetsPlugin):
self.set_permissions(files=files, dirs=dirs)
def fix_art(self, album):
"""Fix the permission for Album art file.
"""
"""Fix the permission for Album art file."""
if album.artpath:
self.set_permissions(files=[album.artpath])
@@ -92,18 +91,19 @@ class Permissions(BeetsPlugin):
# string (in YAML quotes) or, for convenience, as an integer so the
# quotes can be omitted. In the latter case, we need to reinterpret the
# integer as octal, not decimal.
file_perm = config['permissions']['file'].get()
dir_perm = config['permissions']['dir'].get()
file_perm = config["permissions"]["file"].get()
dir_perm = config["permissions"]["dir"].get()
file_perm = convert_perm(file_perm)
dir_perm = convert_perm(dir_perm)
for path in files:
# Changing permissions on the destination file.
self._log.debug(
'setting file permissions on {}',
util.displayable_path(path),
"setting file permissions on {}",
displayable_path(path),
)
os.chmod(util.syspath(path), file_perm)
if not check_permissions(path, file_perm):
os.chmod(syspath(path), file_perm)
# Checks if the destination path has the permissions configured.
assert_permissions(path, file_perm, self._log)
@@ -112,10 +112,11 @@ class Permissions(BeetsPlugin):
for path in dirs:
# Changing permissions on the destination directory.
self._log.debug(
'setting directory permissions on {}',
util.displayable_path(path),
"setting directory permissions on {}",
displayable_path(path),
)
os.chmod(util.syspath(path), dir_perm)
if not check_permissions(path, dir_perm):
os.chmod(syspath(path), dir_perm)
# Checks if the destination path has the permissions configured.
assert_permissions(path, dir_perm, self._log)
+88 -76
View File
@@ -15,31 +15,37 @@
"""Send the results of a query to the configured music player as a playlist.
"""
import shlex
import subprocess
from os.path import relpath
from beets import config, ui, util
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand
from beets.ui.commands import PromptChoice
from beets import config
from beets import ui
from beets import util
from os.path import relpath
from tempfile import NamedTemporaryFile
import subprocess
import shlex
from beets.util import get_temp_filename
# Indicate where arguments should be inserted into the command string.
# If this is missing, they're placed at the end.
ARGS_MARKER = '$args'
ARGS_MARKER = "$args"
def play(command_str, selection, paths, open_args, log, item_type='track',
keep_open=False):
def play(
command_str,
selection,
paths,
open_args,
log,
item_type="track",
keep_open=False,
):
"""Play items in paths with command_str and optional arguments. If
keep_open, return to beets, otherwise exit once command runs.
"""
# Print number of tracks or albums to be played, log command to be run.
item_type += 's' if len(selection) > 1 else ''
ui.print_('Playing {} {}.'.format(len(selection), item_type))
log.debug('executing command: {} {!r}', command_str, open_args)
item_type += "s" if len(selection) > 1 else ""
ui.print_("Playing {} {}.".format(len(selection), item_type))
log.debug("executing command: {} {!r}", command_str, open_args)
try:
if keep_open:
@@ -49,42 +55,44 @@ def play(command_str, selection, paths, open_args, log, item_type='track',
else:
util.interactive_open(open_args, command_str)
except OSError as exc:
raise ui.UserError(
f"Could not play the query: {exc}")
raise ui.UserError(f"Could not play the query: {exc}")
class PlayPlugin(BeetsPlugin):
def __init__(self):
super().__init__()
config['play'].add({
'command': None,
'use_folders': False,
'relative_to': None,
'raw': False,
'warning_threshold': 100,
'bom': False,
})
config["play"].add(
{
"command": None,
"use_folders": False,
"relative_to": None,
"raw": False,
"warning_threshold": 100,
"bom": False,
}
)
self.register_listener('before_choose_candidate',
self.before_choose_candidate_listener)
self.register_listener(
"before_choose_candidate", self.before_choose_candidate_listener
)
def commands(self):
play_command = Subcommand(
'play',
help='send music to a player as a playlist'
"play", help="send music to a player as a playlist"
)
play_command.parser.add_album_option()
play_command.parser.add_option(
'-A', '--args',
action='store',
help='add additional arguments to the command',
"-A",
"--args",
action="store",
help="add additional arguments to the command",
)
play_command.parser.add_option(
'-y', '--yes',
"-y",
"--yes",
action="store_true",
help='skip the warning threshold',
help="skip the warning threshold",
)
play_command.func = self._play_command
return [play_command]
@@ -93,8 +101,8 @@ class PlayPlugin(BeetsPlugin):
"""The CLI command function for `beet play`. Create a list of paths
from query, determine if tracks or albums are to be played.
"""
use_folders = config['play']['use_folders'].get(bool)
relative_to = config['play']['relative_to'].get()
use_folders = config["play"]["use_folders"].get(bool)
relative_to = config["play"]["relative_to"].get()
if relative_to:
relative_to = util.normpath(relative_to)
# Perform search by album and add folders rather than tracks to
@@ -108,22 +116,20 @@ class PlayPlugin(BeetsPlugin):
if use_folders:
paths.append(album.item_dir())
else:
paths.extend(item.path
for item in sort.sort(album.items()))
item_type = 'album'
paths.extend(item.path for item in sort.sort(album.items()))
item_type = "album"
# Perform item query and add tracks to playlist.
else:
selection = lib.items(ui.decargs(args))
paths = [item.path for item in selection]
item_type = 'track'
item_type = "track"
if relative_to:
paths = [relpath(path, relative_to) for path in paths]
if not selection:
ui.print_(ui.colorize('text_warning',
f'No {item_type} to play.'))
ui.print_(ui.colorize("text_warning", f"No {item_type} to play."))
return
open_args = self._playlist_or_paths(paths)
@@ -132,14 +138,13 @@ class PlayPlugin(BeetsPlugin):
# Check if the selection exceeds configured threshold. If True,
# cancel, otherwise proceed with play command.
if opts.yes or not self._exceeds_threshold(
selection, command_str, open_args, item_type):
play(command_str, selection, paths, open_args, self._log,
item_type)
selection, command_str, open_args, item_type
):
play(command_str, selection, paths, open_args, self._log, item_type)
def _command_str(self, args=None):
"""Create a command string from the config command and optional args.
"""
command_str = config['play']['command'].get()
"""Create a command string from the config command and optional args."""
command_str = config["play"]["command"].get()
if not command_str:
return util.open_anything()
# Add optional arguments to the player command.
@@ -153,57 +158,58 @@ class PlayPlugin(BeetsPlugin):
return command_str.replace(" " + ARGS_MARKER, "")
def _playlist_or_paths(self, paths):
"""Return either the raw paths of items or a playlist of the items.
"""
if config['play']['raw']:
"""Return either the raw paths of items or a playlist of the items."""
if config["play"]["raw"]:
return paths
else:
return [self._create_tmp_playlist(paths)]
def _exceeds_threshold(self, selection, command_str, open_args,
item_type='track'):
def _exceeds_threshold(
self, selection, command_str, open_args, item_type="track"
):
"""Prompt user whether to abort if playlist exceeds threshold. If
True, cancel playback. If False, execute play command.
"""
warning_threshold = config['play']['warning_threshold'].get(int)
warning_threshold = config["play"]["warning_threshold"].get(int)
# Warn user before playing any huge playlists.
if warning_threshold and len(selection) > warning_threshold:
if len(selection) > 1:
item_type += 's'
item_type += "s"
ui.print_(ui.colorize(
'text_warning',
'You are about to queue {} {}.'.format(
len(selection), item_type)))
ui.print_(
ui.colorize(
"text_warning",
"You are about to queue {} {}.".format(
len(selection), item_type
),
)
)
if ui.input_options(('Continue', 'Abort')) == 'a':
if ui.input_options(("Continue", "Abort")) == "a":
return True
return False
def _create_tmp_playlist(self, paths_list):
"""Create a temporary .m3u file. Return the filename.
"""
utf8_bom = config['play']['bom'].get(bool)
m3u = NamedTemporaryFile('wb', suffix='.m3u', delete=False)
"""Create a temporary .m3u file. Return the filename."""
utf8_bom = config["play"]["bom"].get(bool)
filename = get_temp_filename(__name__, suffix=".m3u")
with open(filename, "wb") as m3u:
if utf8_bom:
m3u.write(b"\xEF\xBB\xBF")
if utf8_bom:
m3u.write(b'\xEF\xBB\xBF')
for item in paths_list:
m3u.write(item + b"\n")
for item in paths_list:
m3u.write(item + b'\n')
m3u.close()
return m3u.name
return filename
def before_choose_candidate_listener(self, session, task):
"""Append a "Play" choice to the interactive importer prompt.
"""
return [PromptChoice('y', 'plaY', self.importer_play)]
"""Append a "Play" choice to the interactive importer prompt."""
return [PromptChoice("y", "plaY", self.importer_play)]
def importer_play(self, session, task):
"""Get items from current import task and send to play function.
"""
"""Get items from current import task and send to play function."""
selection = task.items
paths = [item.path for item in selection]
@@ -211,5 +217,11 @@ class PlayPlugin(BeetsPlugin):
command_str = self._command_str()
if not self._exceeds_threshold(selection, command_str, open_args):
play(command_str, selection, paths, open_args, self._log,
keep_open=True)
play(
command_str,
selection,
paths,
open_args,
self._log,
keep_open=True,
)
+77 -62
View File
@@ -12,98 +12,104 @@
# included in all copies or substantial portions of the Software.
import os
import fnmatch
import os
import tempfile
from typing import Sequence
import beets
from beets.dbcore.query import InQuery
from beets.library import BLOB_TYPE
from beets.util import path_as_posix
class PlaylistQuery(beets.dbcore.Query):
"""Matches files listed by a playlist file.
"""
def __init__(self, pattern):
self.pattern = pattern
config = beets.config['playlist']
class PlaylistQuery(InQuery[bytes]):
"""Matches files listed by a playlist file."""
@property
def subvals(self) -> Sequence[BLOB_TYPE]:
return [BLOB_TYPE(p) for p in self.pattern]
def __init__(self, _, pattern: str, __):
config = beets.config["playlist"]
# Get the full path to the playlist
playlist_paths = (
pattern,
os.path.abspath(os.path.join(
config['playlist_dir'].as_filename(),
f'{pattern}.m3u',
)),
os.path.abspath(
os.path.join(
config["playlist_dir"].as_filename(),
f"{pattern}.m3u",
)
),
)
self.paths = []
paths = []
for playlist_path in playlist_paths:
if not fnmatch.fnmatch(playlist_path, '*.[mM]3[uU]'):
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')
f = open(beets.util.syspath(playlist_path), mode="rb")
except OSError:
continue
if config['relative_to'].get() == 'library':
relative_to = beets.config['directory'].as_filename()
elif config['relative_to'].get() == 'playlist':
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 = config["relative_to"].as_filename()
relative_to = beets.util.bytestring_path(relative_to)
for line in f:
if line[0] == '#':
if line[0] == "#":
# ignore comments, and extm3u extension
continue
self.paths.append(beets.util.normpath(
os.path.join(relative_to, line.rstrip())
))
paths.append(
beets.util.normpath(
os.path.join(relative_to, line.rstrip())
)
)
f.close()
break
def col_clause(self):
if not self.paths:
# Playlist is empty
return '0', ()
clause = 'path IN ({})'.format(', '.join('?' for path in self.paths))
return clause, (beets.library.BLOB_TYPE(p) for p in self.paths)
def match(self, item):
return item.path in self.paths
super().__init__("path", paths)
class PlaylistPlugin(beets.plugins.BeetsPlugin):
item_queries = {'playlist': PlaylistQuery}
item_queries = {"playlist": PlaylistQuery}
def __init__(self):
super().__init__()
self.config.add({
'auto': False,
'playlist_dir': '.',
'relative_to': 'library',
'forward_slash': False,
})
self.config.add(
{
"auto": False,
"playlist_dir": ".",
"relative_to": "library",
"forward_slash": False,
}
)
self.playlist_dir = self.config['playlist_dir'].as_filename()
self.playlist_dir = self.config["playlist_dir"].as_filename()
self.changes = {}
if self.config['relative_to'].get() == 'library':
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':
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())
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)
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
@@ -114,29 +120,36 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
def cli_exit(self, lib):
for playlist in self.find_playlists():
self._log.info(f'Updating playlist: {playlist}')
self._log.info(f"Updating playlist: {playlist}")
base_dir = beets.util.bytestring_path(
self.relative_to if self.relative_to
self.relative_to
if self.relative_to
else os.path.dirname(playlist)
)
try:
self.update_playlist(playlist, base_dir)
except beets.util.FilesystemError:
self._log.error('Failed to update playlist: {}'.format(
beets.util.displayable_path(playlist)))
self._log.error(
"Failed to update playlist: {}".format(
beets.util.displayable_path(playlist)
)
)
def find_playlists(self):
"""Find M3U playlists in the playlist directory."""
try:
dir_contents = os.listdir(beets.util.syspath(self.playlist_dir))
except OSError:
self._log.warning('Unable to open playlist directory {}'.format(
beets.util.displayable_path(self.playlist_dir)))
self._log.warning(
"Unable to open playlist directory {}".format(
beets.util.displayable_path(self.playlist_dir)
)
)
return
for filename in dir_contents:
if fnmatch.fnmatch(filename, '*.[mM]3[uU]'):
if fnmatch.fnmatch(filename, "*.[mM]3[uU]"):
yield os.path.join(self.playlist_dir, filename)
def update_playlist(self, filename, base_dir):
@@ -144,11 +157,11 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
changes = 0
deletions = 0
with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as tempfp:
with tempfile.NamedTemporaryFile(mode="w+b", delete=False) as tempfp:
new_playlist = tempfp.name
with open(filename, mode='rb') as fp:
with open(filename, mode="rb") as fp:
for line in fp:
original_path = line.rstrip(b'\r\n')
original_path = line.rstrip(b"\r\n")
# Ensure that path from playlist is absolute
is_relative = not os.path.isabs(line)
@@ -160,7 +173,7 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
try:
new_path = self.changes[beets.util.normpath(lookup)]
except KeyError:
if self.config['forward_slash']:
if self.config["forward_slash"]:
line = path_as_posix(line)
tempfp.write(line)
else:
@@ -173,13 +186,15 @@ class PlaylistPlugin(beets.plugins.BeetsPlugin):
if is_relative:
new_path = os.path.relpath(new_path, base_dir)
line = line.replace(original_path, new_path)
if self.config['forward_slash']:
if self.config["forward_slash"]:
line = path_as_posix(line)
tempfp.write(line)
if changes or deletions:
self._log.info(
'Updated playlist {} ({} changes, {} deletions)'.format(
filename, changes, deletions))
"Updated playlist {} ({} changes, {} deletions)".format(
filename, changes, deletions
)
)
beets.util.copy(new_playlist, filename, replace=True)
beets.util.remove(new_playlist)
+63 -49
View File
@@ -8,66 +8,77 @@ Put something like the following in your config.yaml to configure:
token: token
"""
import requests
from urllib.parse import urlencode, urljoin
from xml.etree import ElementTree
from urllib.parse import urljoin, urlencode
import requests
from beets import config
from beets.plugins import BeetsPlugin
def get_music_section(host, port, token, library_name, secure,
ignore_cert_errors):
"""Getting the section key for the music library in Plex.
"""
api_endpoint = append_token('library/sections', token)
url = urljoin('{}://{}:{}'.format(get_protocol(secure), host,
port), api_endpoint)
def get_music_section(
host, port, token, library_name, secure, ignore_cert_errors
):
"""Getting the section key for the music library in Plex."""
api_endpoint = append_token("library/sections", token)
url = urljoin(
"{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint
)
# Sends request.
r = requests.get(url, verify=not ignore_cert_errors)
r = requests.get(
url,
verify=not ignore_cert_errors,
timeout=10,
)
# Parse xml tree and extract music section key.
tree = ElementTree.fromstring(r.content)
for child in tree.findall('Directory'):
if child.get('title') == library_name:
return child.get('key')
for child in tree.findall("Directory"):
if child.get("title") == library_name:
return child.get("key")
def update_plex(host, port, token, library_name, secure,
ignore_cert_errors):
"""Ignore certificate errors if configured to.
"""
def update_plex(host, port, token, library_name, secure, ignore_cert_errors):
"""Ignore certificate errors if configured to."""
if ignore_cert_errors:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
"""Sends request to the Plex api to start a library refresh.
"""
# Getting section key and build url.
section_key = get_music_section(host, port, token, library_name,
secure, ignore_cert_errors)
api_endpoint = f'library/sections/{section_key}/refresh'
section_key = get_music_section(
host, port, token, library_name, secure, ignore_cert_errors
)
api_endpoint = f"library/sections/{section_key}/refresh"
api_endpoint = append_token(api_endpoint, token)
url = urljoin('{}://{}:{}'.format(get_protocol(secure), host,
port), api_endpoint)
url = urljoin(
"{}://{}:{}".format(get_protocol(secure), host, port), api_endpoint
)
# Sends request and returns requests object.
r = requests.get(url, verify=not ignore_cert_errors)
r = requests.get(
url,
verify=not ignore_cert_errors,
timeout=10,
)
return r
def append_token(url, token):
"""Appends the Plex Home token to the api call if required.
"""
"""Appends the Plex Home token to the api call if required."""
if token:
url += '?' + urlencode({'X-Plex-Token': token})
url += "?" + urlencode({"X-Plex-Token": token})
return url
def get_protocol(secure):
if secure:
return 'https'
return "https"
else:
return 'http'
return "http"
class PlexUpdate(BeetsPlugin):
@@ -75,36 +86,39 @@ class PlexUpdate(BeetsPlugin):
super().__init__()
# Adding defaults.
config['plex'].add({
'host': 'localhost',
'port': 32400,
'token': '',
'library_name': 'Music',
'secure': False,
'ignore_cert_errors': False})
config["plex"].add(
{
"host": "localhost",
"port": 32400,
"token": "",
"library_name": "Music",
"secure": False,
"ignore_cert_errors": False,
}
)
config['plex']['token'].redact = True
self.register_listener('database_change', self.listen_for_db_change)
config["plex"]["token"].redact = True
self.register_listener("database_change", self.listen_for_db_change)
def listen_for_db_change(self, lib, model):
"""Listens for beets db change and register the update for the end"""
self.register_listener('cli_exit', self.update)
self.register_listener("cli_exit", self.update)
def update(self, lib):
"""When the client exists try to send refresh request to Plex server.
"""
self._log.info('Updating Plex library...')
"""When the client exists try to send refresh request to Plex server."""
self._log.info("Updating Plex library...")
# Try to send update request.
try:
update_plex(
config['plex']['host'].get(),
config['plex']['port'].get(),
config['plex']['token'].get(),
config['plex']['library_name'].get(),
config['plex']['secure'].get(bool),
config['plex']['ignore_cert_errors'].get(bool))
self._log.info('... started.')
config["plex"]["host"].get(),
config["plex"]["port"].get(),
config["plex"]["token"].get(),
config["plex"]["library_name"].get(),
config["plex"]["secure"].get(bool),
config["plex"]["ignore_cert_errors"].get(bool),
)
self._log.info("... started.")
except requests.exceptions.RequestException:
self._log.warning('Update failed.')
self._log.warning("Update failed.")
+24 -13
View File
@@ -16,13 +16,12 @@
"""
from beets.plugins import BeetsPlugin
from beets.ui import Subcommand, decargs, print_
from beets.random import random_objs
from beets.ui import Subcommand, decargs, print_
def random_func(lib, opts, args):
"""Select some random items or albums and print the results.
"""
"""Select some random items or albums and print the results."""
# Fetch all the objects matching the query into a list.
query = decargs(args)
if opts.album:
@@ -31,23 +30,35 @@ def random_func(lib, opts, args):
objs = list(lib.items(query))
# Print a random subset.
objs = random_objs(objs, opts.album, opts.number, opts.time,
opts.equal_chance)
objs = random_objs(
objs, opts.album, opts.number, opts.time, opts.equal_chance
)
for obj in objs:
print_(format(obj))
random_cmd = Subcommand('random',
help='choose a random track or album')
random_cmd = Subcommand("random", help="choose a random track or album")
random_cmd.parser.add_option(
'-n', '--number', action='store', type="int",
help='number of objects to choose', default=1)
"-n",
"--number",
action="store",
type="int",
help="number of objects to choose",
default=1,
)
random_cmd.parser.add_option(
'-e', '--equal-chance', action='store_true',
help='each artist has the same chance')
"-e",
"--equal-chance",
action="store_true",
help="each artist has the same chance",
)
random_cmd.parser.add_option(
'-t', '--time', action='store', type="float",
help='total length in minutes of objects to choose')
"-t",
"--time",
action="store",
type="float",
help="total length in minutes of objects to choose",
)
random_cmd.parser.add_all_common_options()
random_cmd.func = random_func
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More