Merge pull request #3389 from rhlahuja/add-bpsync-plugin

Add BPSyncPlugin
This commit is contained in:
Adrian Sampson 2019-10-06 11:45:13 -04:00 committed by GitHub
commit 61a56f1b51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 320 additions and 65 deletions

View file

@ -186,7 +186,7 @@ def apply_metadata(album_info, mapping):
'mb_workid',
'work_disambig',
'bpm',
'musical_key',
'initial_key',
'genre'
)
}

View file

@ -180,7 +180,7 @@ class TrackInfo(object):
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,
musical_key=None, genre=None):
initial_key=None, genre=None):
self.title = title
self.track_id = track_id
self.release_track_id = release_track_id
@ -206,7 +206,7 @@ class TrackInfo(object):
self.mb_workid = mb_workid
self.work_disambig = work_disambig
self.bpm = bpm
self.musical_key = musical_key
self.initial_key = initial_key
self.genre = genre
# As above, work around a bug in python-musicbrainz-ngs.

View file

@ -206,7 +206,7 @@ class BeetsPlugin(object):
``descriptor`` must be an instance of ``mediafile.MediaField``.
"""
# Defer impor to prevent circular dependency
# Defer import to prevent circular dependency
from beets import library
mediafile.MediaFile.add_field(name, descriptor)
library.Item._media_fields.add(name)
@ -590,6 +590,36 @@ def get_distance(config, data_source, info):
return dist
def apply_item_changes(lib, item, move, pretend, write):
"""Store, move, and write the item according to the arguments.
:param lib: beets library.
:type lib: beets.library.Library
:param item: Item whose changes to apply.
:type item: beets.library.Item
:param move: Move the item if it's in the library.
:type move: bool
:param pretend: Return without moving, writing, or storing the item's
metadata.
:type pretend: bool
:param write: Write the item's metadata to its media file.
:type write: bool
"""
if pretend:
return
from beets import util
# Move the item if it's in the library.
if move and lib.directory in util.ancestry(item.path):
item.move(with_album=False)
if write:
item.try_write()
item.store()
@six.add_metaclass(abc.ABCMeta)
class MetadataSourcePlugin(object):
def __init__(self):
@ -633,13 +663,20 @@ class MetadataSourcePlugin(object):
"""Returns an artist string (all artists) and an artist_id (the main
artist) for a list of artist object dicts.
:param artists: Iterable of artist dicts returned by API.
:type artists: list[dict]
:param id_key: Key corresponding to ``artist_id`` value.
:type id_key: str
:param name_key: Keys corresponding to values to concatenate
for ``artist``.
:type name_key: str
For each artist, this function moves articles (such as 'a', 'an',
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.
:param artists: Iterable of artist dicts or lists returned by API.
:type artists: list[dict] or list[list]
:param id_key: Key or index corresponding to the value of ``id`` for
the main/first artist. Defaults to 'id'.
:type id_key: str or int
:param name_key: Key or index corresponding to values of names
to concatenate for the artist string (containing all artists).
Defaults to 'name'.
:type name_key: str or int
:return: Normalized artist string.
:rtype: str
"""
@ -649,6 +686,8 @@ class MetadataSourcePlugin(object):
if not artist_id:
artist_id = artist[id_key]
name = artist[name_key]
# Strip disambiguation number.
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)
@ -694,8 +733,9 @@ class MetadataSourcePlugin(object):
query_filters = {'album': album}
if not va_likely:
query_filters['artist'] = artist
albums = self._search_api(query_type='album', filters=query_filters)
return [self.album_for_id(album_id=a['id']) for a in albums]
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):
"""Returns a list of TrackInfo objects for Search API results

View file

@ -28,8 +28,8 @@ from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing,
import beets
import beets.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
from beets.plugins import BeetsPlugin
from beets.autotag.hooks import AlbumInfo, TrackInfo
from beets.plugins import BeetsPlugin, MetadataSourcePlugin, get_distance
import confuse
@ -228,6 +228,7 @@ class BeatportRelease(BeatportObject):
if 'slug' in data:
self.url = "https://beatport.com/release/{0}/{1}".format(
data['slug'], data['id'])
self.genre = data.get('genre')
@six.python_2_unicode_compatible
@ -258,7 +259,7 @@ class BeatportTrack(BeatportObject):
.format(data['slug'], data['id'])
self.track_number = data.get('trackNumber')
self.bpm = data.get('bpm')
self.musical_key = six.text_type(
self.initial_key = six.text_type(
(data.get('key') or {}).get('shortName')
)
@ -270,6 +271,8 @@ class BeatportTrack(BeatportObject):
class BeatportPlugin(BeetsPlugin):
data_source = 'Beatport'
def __init__(self):
super(BeatportPlugin, self).__init__()
self.config.add({
@ -333,22 +336,24 @@ class BeatportPlugin(BeetsPlugin):
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
"""Returns the Beatport source weight and the maximum source weight
for albums.
"""
dist = Distance()
if album_info.data_source == 'Beatport':
dist.add('source', self.config['source_weight'].as_number())
return dist
return get_distance(
data_source=self.data_source,
info=album_info,
config=self.config
)
def track_distance(self, item, track_info):
"""Returns the beatport source weight and the maximum source weight
"""Returns the Beatport source weight and the maximum source weight
for individual tracks.
"""
dist = Distance()
if track_info.data_source == 'Beatport':
dist.add('source', self.config['source_weight'].as_number())
return dist
return get_distance(
data_source=self.data_source,
info=track_info,
config=self.config
)
def candidates(self, items, artist, release, va_likely):
"""Returns a list of AlbumInfo objects for beatport search results
@ -435,7 +440,8 @@ class BeatportPlugin(BeetsPlugin):
day=release.release_date.day,
label=release.label_name,
catalognum=release.catalog_number, media=u'Digital',
data_source=u'Beatport', data_url=release.url)
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.
@ -449,26 +455,17 @@ class BeatportPlugin(BeetsPlugin):
artist=artist, artist_id=artist_id,
length=length, index=track.track_number,
medium_index=track.track_number,
data_source=u'Beatport', data_url=track.url,
bpm=track.bpm, musical_key=track.musical_key)
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
artist) for a list of Beatport release or track artists.
"""
artist_id = None
bits = []
for artist in artists:
if not artist_id:
artist_id = artist[0]
name = artist[1]
# Strip disambiguation number.
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)
bits.append(name)
artist = ', '.join(bits).replace(' ,', ',') or None
return artist, artist_id
return MetadataSourcePlugin.get_artist(
artists=artists, id_key=0, name_key=1
)
def _get_tracks(self, query):
"""Returns a list of TrackInfo objects for a Beatport query.

188
beetsplug/bpsync.py Normal file
View file

@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2019, Rahul Ahuja.
#
# 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.
"""Update library's tags using Beatport.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from .beatport import BeatportPlugin
class BPSyncPlugin(BeetsPlugin):
def __init__(self):
super(BPSyncPlugin, self).__init__()
self.beatport_plugin = BeatportPlugin()
self.beatport_plugin.setup()
def commands(self):
cmd = ui.Subcommand('bpsync', help=u'update metadata from Beatport')
cmd.parser.add_option(
u'-p',
u'--pretend',
action='store_true',
help=u'show all changes but do nothing',
)
cmd.parser.add_option(
u'-m',
u'--move',
action='store_true',
dest='move',
help=u"move files in the library directory",
)
cmd.parser.add_option(
u'-M',
u'--nomove',
action='store_false',
dest='move',
help=u"don't move files in library",
)
cmd.parser.add_option(
u'-W',
u'--nowrite',
action='store_false',
default=None,
dest='write',
help=u"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 bpsync function.
"""
move = ui.should_move(opts.move)
pretend = opts.pretend
write = ui.should_write(opts.write)
query = ui.decargs(args)
self.singletons(lib, query, move, pretend, write)
self.albums(lib, query, move, pretend, write)
def singletons(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for items matched by
query.
"""
for item in lib.items(query + [u'singleton:true']):
if not item.mb_trackid:
self._log.info(
u'Skipping singleton with no mb_trackid: {}', item
)
continue
if not self.is_beatport_track(item):
self._log.info(
u'Skipping non-{} singleton: {}',
self.beatport_plugin.data_source,
item,
)
continue
# Apply.
trackinfo = self.beatport_plugin.track_for_id(item.mb_trackid)
with lib.transaction():
autotag.apply_item_metadata(item, trackinfo)
apply_item_changes(lib, item, move, pretend, write)
@staticmethod
def is_beatport_track(item):
return (
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(u'Skipping album with no mb_albumid: {}', album)
return False
if not album.mb_albumid.isnumeric():
self._log.info(
u'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:
return items
if not all(self.is_beatport_track(item) for item in items):
self._log.info(
u'Skipping non-{} release: {}',
self.beatport_plugin.data_source,
album,
)
return False
return items
def albums(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for albums matched by
query and their items.
"""
# Process matching albums.
for album in lib.albums(query):
# Do we have a valid Beatport album?
items = self.get_album_tracks(album)
if not items:
continue
# Get the Beatport album information.
albuminfo = self.beatport_plugin.album_for_id(album.mb_albumid)
if not albuminfo:
self._log.info(
u'Release ID {} not found for album {}',
album.mb_albumid,
album,
)
continue
beatport_trackid_to_trackinfo = {
track.track_id: track for track in albuminfo.tracks
}
library_trackid_to_item = {
int(item.mb_trackid): item for item in items
}
item_to_trackinfo = {
item: beatport_trackid_to_trackinfo[track_id]
for track_id, item in library_trackid_to_item.items()
}
self._log.info(u'applying changes to {}', album)
with lib.transaction():
autotag.apply_metadata(albuminfo, item_to_trackinfo)
changed = False
# Find any changed item to apply Beatport changes to album.
any_changed_item = items[0]
for item in items:
item_changed = ui.show_model_changes(item)
changed |= item_changed
if item_changed:
any_changed_item = item
apply_item_changes(lib, item, move, pretend, write)
if pretend or not changed:
continue
# Update album structure to reflect an item in it.
for key in library.Album.item_keys:
album[key] = any_changed_item[key]
album.store()
# Move album art (and any inconsistent items).
if move and lib.directory in util.ancestry(items[0].path):
self._log.debug(u'moving album {}', album)
album.move()

View file

@ -83,6 +83,8 @@ class DeezerPlugin(MetadataSourcePlugin, BeetsPlugin):
tracks_data = requests.get(
self.album_url + deezer_id + '/tracks'
).json()['data']
if not tracks_data:
return None
tracks = []
medium_totals = collections.defaultdict(int)
for i, track_data in enumerate(tracks_data, start=1):

View file

@ -17,7 +17,7 @@
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
from beets.plugins import BeetsPlugin, apply_item_changes
from beets import autotag, library, ui, util
from beets.autotag import hooks
from collections import defaultdict
@ -27,19 +27,6 @@ import re
MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}"
def apply_item_changes(lib, item, move, pretend, write):
"""Store, move and write the item according to the arguments.
"""
if not pretend:
# Move the item if it's in the library.
if move and lib.directory in util.ancestry(item.path):
item.move(with_album=False)
if write:
item.try_write()
item.store()
class MBSyncPlugin(BeetsPlugin):
def __init__(self):
super(MBSyncPlugin, self).__init__()

View file

@ -75,6 +75,11 @@ New features:
:bug:`2080`
* :doc:`/plugins/beatport`: Fix default assignment of the musical key.
:bug:`3377`
* :doc:`/plugins/bpsync`: Add `bpsync` plugin to sync metadata changes
from the Beatport database.
* :doc:`/plugins/beatport`: Fix assignment of `genre` and rename `musical_key`
to `initial_key`.
:bug:`3387`
Fixes:

34
docs/plugins/bpsync.rst Normal file
View file

@ -0,0 +1,34 @@
BPSync Plugin
=============
This plugin provides the ``bpsync`` command, which lets you fetch metadata
from Beatport for albums and tracks that already have Beatport IDs.
This plugin works similarly to :doc:`/plugins/mbsync`.
If you have downloaded music from Beatport, this can speed
up the initial import if you just import "as-is" and then use ``bpsync`` to
get up-to-date tags that are written to the files according to your beets
configuration.
Usage
-----
Enable the ``bpsync`` plugin in your configuration (see :ref:`using-plugins`)
and then run ``beet bpsync QUERY`` to fetch updated metadata for a part of your
collection (or omit the query to run over your whole library).
This plugin treats albums and singletons (non-album tracks) separately. It
first processes all matching singletons and then proceeds on to full albums.
The same query is used to search for both kinds of entities.
The command has a few command-line options:
* To preview the changes that would be made without applying them, use the
``-p`` (``--pretend``) flag.
* By default, files will be moved (renamed) according to their metadata if
they are inside your beets library directory. To disable this, use the
``-M`` (``--nomove``) command-line option.
* If you have the ``import.write`` configuration option enabled, then this
plugin will write new metadata to files' tags. To disable this, use the
``-W`` (``--nowrite``) option.

View file

@ -65,6 +65,7 @@ following to your configuration::
beatport
bpd
bpm
bpsync
bucket
chroma
convert
@ -142,6 +143,7 @@ Metadata
* :doc:`absubmit`: Analyse audio with the `streaming_extractor_music`_ program and submit the metadata to the AcousticBrainz server
* :doc:`acousticbrainz`: Fetch various AcousticBrainz metadata
* :doc:`bpm`: Measure tempo using keystrokes.
* :doc:`bpsync`: Fetch updated metadata from Beatport.
* :doc:`edit`: Edit metadata from a text editor.
* :doc:`embedart`: Embed album art images into files' metadata.
* :doc:`fetchart`: Fetch album cover art from various sources.
@ -154,7 +156,7 @@ Metadata
* :doc:`lastgenre`: Fetch genres based on Last.fm tags.
* :doc:`lastimport`: Collect play counts from Last.fm.
* :doc:`lyrics`: Automatically fetch song lyrics.
* :doc:`mbsync`: Fetch updated metadata from MusicBrainz
* :doc:`mbsync`: Fetch updated metadata from MusicBrainz.
* :doc:`metasync`: Fetch metadata from local or remote sources
* :doc:`mpdstats`: Connect to `MPD`_ and update the beets library with play
statistics (last_played, play_count, skip_count, rating).

View file

@ -482,12 +482,12 @@ class BeatportTest(_common.TestCase, TestHelper):
items[4].bpm = 123
items[5].bpm = 123
items[0].musical_key = 'Gmin'
items[1].musical_key = 'Gmaj'
items[2].musical_key = 'Fmaj'
items[3].musical_key = 'Amin'
items[4].musical_key = 'E♭maj'
items[5].musical_key = 'Amaj'
items[0].initial_key = 'Gmin'
items[1].initial_key = 'Gmaj'
items[2].initial_key = 'Fmaj'
items[3].initial_key = 'Amin'
items[4].initial_key = 'E♭maj'
items[5].initial_key = 'Amaj'
for item in items:
self.lib.add(item)
@ -549,9 +549,9 @@ class BeatportTest(_common.TestCase, TestHelper):
for track, test_track in zip(self.tracks, self.test_tracks):
self.assertEqual(track.bpm, test_track.bpm)
def test_musical_key_applied(self):
def test_initial_key_applied(self):
for track, test_track in zip(self.tracks, self.test_tracks):
self.assertEqual(track.musical_key, test_track.musical_key)
self.assertEqual(track.initial_key, test_track.initial_key)
def test_genre_applied(self):
for track, test_track in zip(self.tracks, self.test_tracks):