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',
'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

@ -1648,6 +1648,18 @@ class DefaultTemplateFunctions(object):
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.
DefaultTemplateFunctions._func_names = \
[s for s in dir(DefaultTemplateFunctions)

View file

@ -26,7 +26,7 @@ from functools import wraps
import beets
from beets import logging
from beets import library, logging, ui, util
import mediafile
import six
@ -635,11 +635,11 @@ class MetadataSourcePlugin(object):
: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
:param id_key: Key or index corresponding to ``artist_id`` value.
:type id_key: str or int
:param name_key: Key or index corresponding to values to concatenate
for ``artist``.
:type name_key: str
:type name_key: str or int
:return: Normalized artist string.
:rtype: str
"""
@ -649,6 +649,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)
@ -724,3 +726,4 @@ class MetadataSourcePlugin(object):
return get_distance(
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.ui
from beets.autotag.hooks import AlbumInfo, TrackInfo, Distance
from beets.plugins import BeetsPlugin
from beets.plugins import BeetsPlugin, MetadataSourcePlugin
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({
@ -337,7 +340,7 @@ class BeatportPlugin(BeetsPlugin):
for albums.
"""
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())
return dist
@ -346,7 +349,7 @@ class BeatportPlugin(BeetsPlugin):
for individual tracks.
"""
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())
return dist
@ -435,7 +438,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 +453,15 @@ 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.

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}"
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__()
@ -103,7 +90,7 @@ class MBSyncPlugin(BeetsPlugin):
# Apply.
with lib.transaction():
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):
"""Retrieve and apply info from the autotagger for albums matched by
@ -175,7 +162,7 @@ class MBSyncPlugin(BeetsPlugin):
changed |= item_changed
if item_changed:
any_changed_item = item
apply_item_changes(lib, item, move, pretend, write)
library.apply_item_changes(lib, item, move, pretend, write)
if not changed:
# No change to any item.

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):