mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-13 20:44:00 +01:00
update Beets
This commit is contained in:
@@ -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__)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
|
||||
import sys
|
||||
|
||||
from .ui import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+108
-112
@@ -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})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+114
-41
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+431
-288
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+826
-547
File diff suppressed because it is too large
Load Diff
+59
-47
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
"""
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1309
-654
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+522
-315
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -17,4 +17,5 @@
|
||||
|
||||
# Make this a namespace package.
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
+86
-55
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
+357
-329
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+20
-19
@@ -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
@@ -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 + '"'
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -1459,7 +1459,6 @@ tribal house
|
||||
trikitixa
|
||||
trip hop
|
||||
trip rock
|
||||
trip-hop
|
||||
tropicalia
|
||||
tropicalismo
|
||||
tropipop
|
||||
|
||||
+141
-96
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
+758
-523
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
Reference in New Issue
Block a user