Add BPSyncPlugin

This commit is contained in:
Rahul Ahuja 2019-10-03 18:04:12 -07:00
parent d2c194df15
commit f14137fcc2
8 changed files with 238 additions and 51 deletions

View file

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

View file

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

View file

@ -1648,6 +1648,18 @@ class DefaultTemplateFunctions(object):
return falseval return falseval
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()
# Get the name of tmpl_* functions in the above class. # Get the name of tmpl_* functions in the above class.
DefaultTemplateFunctions._func_names = \ DefaultTemplateFunctions._func_names = \
[s for s in dir(DefaultTemplateFunctions) [s for s in dir(DefaultTemplateFunctions)

View file

@ -26,7 +26,7 @@ from functools import wraps
import beets import beets
from beets import logging from beets import library, logging, ui, util
import mediafile import mediafile
import six import six
@ -635,11 +635,11 @@ class MetadataSourcePlugin(object):
:param artists: Iterable of artist dicts returned by API. :param artists: Iterable of artist dicts returned by API.
:type artists: list[dict] :type artists: list[dict]
:param id_key: Key corresponding to ``artist_id`` value. :param id_key: Key or index corresponding to ``artist_id`` value.
:type id_key: str :type id_key: str or int
:param name_key: Keys corresponding to values to concatenate :param name_key: Key or index corresponding to values to concatenate
for ``artist``. for ``artist``.
:type name_key: str :type name_key: str or int
:return: Normalized artist string. :return: Normalized artist string.
:rtype: str :rtype: str
""" """
@ -649,6 +649,8 @@ class MetadataSourcePlugin(object):
if not artist_id: if not artist_id:
artist_id = artist[id_key] artist_id = artist[id_key]
name = artist[name_key] name = artist[name_key]
# Strip disambiguation number.
name = re.sub(r' \(\d+\)$', '', name)
# Move articles to the front. # Move articles to the front.
name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I) name = re.sub(r'^(.*?), (a|an|the)$', r'\2 \1', name, flags=re.I)
artist_names.append(name) artist_names.append(name)
@ -724,3 +726,4 @@ class MetadataSourcePlugin(object):
return get_distance( 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
) )

View file

@ -29,7 +29,7 @@ from requests_oauthlib.oauth1_session import (TokenRequestDenied, TokenMissing,
import beets import beets
import beets.ui import beets.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
from beets.plugins import BeetsPlugin from beets.plugins import BeetsPlugin, MetadataSourcePlugin
import confuse import confuse
@ -228,6 +228,7 @@ class BeatportRelease(BeatportObject):
if 'slug' in data: if 'slug' in data:
self.url = "https://beatport.com/release/{0}/{1}".format( self.url = "https://beatport.com/release/{0}/{1}".format(
data['slug'], data['id']) data['slug'], data['id'])
self.genre = data.get('genre')
@six.python_2_unicode_compatible @six.python_2_unicode_compatible
@ -258,7 +259,7 @@ class BeatportTrack(BeatportObject):
.format(data['slug'], data['id']) .format(data['slug'], data['id'])
self.track_number = data.get('trackNumber') self.track_number = data.get('trackNumber')
self.bpm = data.get('bpm') self.bpm = data.get('bpm')
self.musical_key = six.text_type( self.initial_key = six.text_type(
(data.get('key') or {}).get('shortName') (data.get('key') or {}).get('shortName')
) )
@ -270,6 +271,8 @@ class BeatportTrack(BeatportObject):
class BeatportPlugin(BeetsPlugin): class BeatportPlugin(BeetsPlugin):
data_source = 'Beatport'
def __init__(self): def __init__(self):
super(BeatportPlugin, self).__init__() super(BeatportPlugin, self).__init__()
self.config.add({ self.config.add({
@ -337,7 +340,7 @@ class BeatportPlugin(BeetsPlugin):
for albums. for albums.
""" """
dist = Distance() dist = Distance()
if album_info.data_source == 'Beatport': if album_info.data_source == self.data_source:
dist.add('source', self.config['source_weight'].as_number()) dist.add('source', self.config['source_weight'].as_number())
return dist return dist
@ -346,7 +349,7 @@ class BeatportPlugin(BeetsPlugin):
for individual tracks. for individual tracks.
""" """
dist = Distance() dist = Distance()
if track_info.data_source == 'Beatport': if track_info.data_source == self.data_source:
dist.add('source', self.config['source_weight'].as_number()) dist.add('source', self.config['source_weight'].as_number())
return dist return dist
@ -435,7 +438,8 @@ class BeatportPlugin(BeetsPlugin):
day=release.release_date.day, day=release.release_date.day,
label=release.label_name, label=release.label_name,
catalognum=release.catalog_number, media=u'Digital', 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): def _get_track_info(self, track):
"""Returns a TrackInfo object for a Beatport Track object. """Returns a TrackInfo object for a Beatport Track object.
@ -449,26 +453,15 @@ class BeatportPlugin(BeetsPlugin):
artist=artist, artist_id=artist_id, artist=artist, artist_id=artist_id,
length=length, index=track.track_number, length=length, index=track.track_number,
medium_index=track.track_number, medium_index=track.track_number,
data_source=u'Beatport', data_url=track.url, data_source=self.data_source, data_url=track.url,
bpm=track.bpm, musical_key=track.musical_key) bpm=track.bpm, initial_key=track.initial_key,
genre=track.genre)
def _get_artist(self, artists): def _get_artist(self, artists):
"""Returns an artist string (all artists) and an artist_id (the main """Returns an artist string (all artists) and an artist_id (the main
artist) for a list of Beatport release or track artists. artist) for a list of Beatport release or track artists.
""" """
artist_id = None return MetadataSourcePlugin.get_artist(artists=artists, id_key=0, name_key=1)
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
def _get_tracks(self, query): def _get_tracks(self, query):
"""Returns a list of TrackInfo objects for a Beatport query. """Returns a list of TrackInfo objects for a Beatport query.

192
beetsplug/bpsync.py Normal file
View file

@ -0,0 +1,192 @@
# -*- 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 MusicBrainz.
"""
from __future__ import division, absolute_import, print_function
from beets.plugins import BeetsPlugin
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.
track_info = self.beatport_plugin.track_for_id(item.mb_trackid)
with lib.transaction():
autotag.apply_item_metadata(item, track_info)
library.apply_item_changes(lib, item, move, pretend, write)
@staticmethod
def is_beatport_track(track):
return (
track.get('data_source') == BeatportPlugin.data_source
and track.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
tracks = list(album.items())
if album.get('data_source') == self.beatport_plugin.data_source:
return tracks
if not all(self.is_beatport_track(track) for track in tracks):
self._log.info(
u'Skipping non-{} release: {}',
self.beatport_plugin.data_source,
album,
)
return False
return tracks
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.
album_info = self.beatport_plugin.album_for_id(album.mb_albumid)
if not album_info:
self._log.info(
u'Release ID {} not found for album {}',
album.mb_albumid,
album,
)
continue
beatport_track_id_to_info = {
track.track_id: track for track in album_info.tracks
}
library_track_id_to_item = {
int(item.mb_trackid): item for item in items
}
item_to_info_mapping = {
library_track_id_to_item[track_id]: track_info
for track_id, track_info in beatport_track_id_to_info.items()
}
self._log.info(u'applying changes to {}', album)
with lib.transaction():
autotag.apply_metadata(album_info, item_to_info_mapping)
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
library.apply_item_changes(
lib, item, move, pretend, write
)
if not changed:
# No change to any item.
continue
if not pretend:
# 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

@ -27,19 +27,6 @@ import re
MBID_REGEX = r"(\d|\w){8}-(\d|\w){4}-(\d|\w){4}-(\d|\w){4}-(\d|\w){12}" 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): class MBSyncPlugin(BeetsPlugin):
def __init__(self): def __init__(self):
super(MBSyncPlugin, self).__init__() super(MBSyncPlugin, self).__init__()
@ -103,7 +90,7 @@ class MBSyncPlugin(BeetsPlugin):
# Apply. # Apply.
with lib.transaction(): with lib.transaction():
autotag.apply_item_metadata(item, track_info) autotag.apply_item_metadata(item, track_info)
apply_item_changes(lib, item, move, pretend, write) library.apply_item_changes(lib, item, move, pretend, write)
def albums(self, lib, query, move, pretend, write): def albums(self, lib, query, move, pretend, write):
"""Retrieve and apply info from the autotagger for albums matched by """Retrieve and apply info from the autotagger for albums matched by
@ -175,7 +162,7 @@ class MBSyncPlugin(BeetsPlugin):
changed |= item_changed changed |= item_changed
if item_changed: if item_changed:
any_changed_item = item any_changed_item = item
apply_item_changes(lib, item, move, pretend, write) library.apply_item_changes(lib, item, move, pretend, write)
if not changed: if not changed:
# No change to any item. # No change to any item.

View file

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