Merge pull request #1808 from diego-plan9/mbid

Add musicbrainz id option to importer
This commit is contained in:
Adrian Sampson 2016-01-22 15:04:34 -08:00
commit cb447f792f
9 changed files with 233 additions and 33 deletions

View file

@ -370,7 +370,7 @@ def _add_candidate(items, results, info):
def tag_album(items, search_artist=None, search_album=None,
search_id=None):
search_ids=[]):
"""Return a tuple of a artist name, an album name, a list of
`AlbumMatch` candidates from the metadata backend, and a
`Recommendation`.
@ -380,8 +380,11 @@ def tag_album(items, search_artist=None, search_album=None,
The `AlbumMatch` objects are generated by searching the metadata
backends. By default, the metadata of the items is used for the
search. This can be customized by setting the parameters. The
`mapping` field of the album has the matched `items` as keys.
search. This can be customized by setting the parameters.
`search_ids` is a list of metadata backend IDs: if specified,
it will restrict the candidates to those IDs, ignoring
`search_artist` and `search album`. The `mapping` field of the
album has the matched `items` as keys.
The recommendation is calculated from the match quality of the
candidates.
@ -397,9 +400,11 @@ def tag_album(items, search_artist=None, search_album=None,
candidates = {}
# Search by explicit ID.
if search_id is not None:
log.debug(u'Searching for album ID: {0}', search_id)
search_cands = hooks.albums_for_id(search_id)
if search_ids:
search_cands = []
for search_id in search_ids:
log.debug(u'Searching for album ID: {0}', search_id)
search_cands.extend(hooks.albums_for_id(search_id))
# Use existing metadata or text search.
else:
@ -444,35 +449,38 @@ def tag_album(items, search_artist=None, search_album=None,
def tag_item(item, search_artist=None, search_title=None,
search_id=None):
search_ids=[]):
"""Attempts to find metadata for a single track. Returns a
`(candidates, recommendation)` pair where `candidates` is a list of
TrackMatch objects. `search_artist` and `search_title` may be used
to override the current metadata for the purposes of the MusicBrainz
title; likewise `search_id`.
title. `search_ids` may be used for restricting the search to a list
of metadata backend IDs.
"""
# Holds candidates found so far: keys are MBIDs; values are
# (distance, TrackInfo) pairs.
candidates = {}
# First, try matching by MusicBrainz ID.
trackid = search_id or item.mb_trackid
if trackid:
log.debug(u'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)
# If this is a good match, then don't keep searching.
rec = _recommendation(candidates.values())
if rec == Recommendation.strong and not config['import']['timid']:
log.debug(u'Track ID match.')
return candidates.values(), rec
trackids = search_ids or filter(None, [item.mb_trackid])
if trackids:
for trackid in trackids:
log.debug(u'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)
# If this is a good match, then don't keep searching.
rec = _recommendation(sorted(candidates.itervalues()))
if rec == Recommendation.strong and \
not config['import']['timid']:
log.debug(u'Track ID match.')
return sorted(candidates.itervalues()), rec
# If we're searching by ID, don't proceed.
if search_id is not None:
if search_ids:
if candidates:
return candidates.values(), rec
return sorted(candidates.itervalues()), rec
else:
return [], Recommendation.none

View file

@ -22,6 +22,7 @@ import:
flat: no
group_albums: no
pretend: false
search_ids: []
clutter: ["Thumbs.DB", ".DS_Store"]
ignore: [".*", "*~", "System Volume Information"]

View file

@ -434,6 +434,7 @@ class ImportTask(BaseImportTask):
self.rec = None
self.should_remove_duplicates = False
self.is_album = True
self.search_ids = [] # user-supplied candidate IDs.
def set_choice(self, choice):
"""Given an AlbumMatch or TrackMatch object or an action constant,
@ -579,10 +580,12 @@ class ImportTask(BaseImportTask):
return tasks
def lookup_candidates(self):
"""Retrieve and store candidates for this album.
"""Retrieve and store candidates for this album. User-specified
candidate IDs are stored in self.search_ids: if present, the
initial lookup is restricted to only those IDs.
"""
artist, album, candidates, recommendation = \
autotag.tag_album(self.items)
autotag.tag_album(self.items, search_ids=self.search_ids)
self.cur_artist = artist
self.cur_album = album
self.candidates = candidates
@ -821,7 +824,8 @@ class SingletonImportTask(ImportTask):
plugins.send('item_imported', lib=lib, item=item)
def lookup_candidates(self):
candidates, recommendation = autotag.tag_item(self.item)
candidates, recommendation = autotag.tag_item(
self.item, search_ids=self.search_ids)
self.candidates = candidates
self.rec = recommendation
@ -1246,6 +1250,11 @@ def lookup_candidates(session, task):
plugins.send('import_task_start', session=session, task=task)
log.debug(u'Looking up: {0}', displayable_path(task.paths))
# Restrict the initial lookup to IDs specified by the user via the -m
# option. Currently all the IDs are passed onto the tasks directly.
task.search_ids = session.config['search_ids'].as_str_seq()
task.lookup_candidates()

View file

@ -734,7 +734,7 @@ class TerminalImportSession(importer.ImportSession):
search_id = manual_id(False)
if search_id:
_, _, candidates, rec = autotag.tag_album(
task.items, search_id=search_id
task.items, search_ids=search_id.split()
)
elif choice in extra_ops.keys():
# Allow extra ops to automatically set the post-choice.
@ -786,8 +786,8 @@ class TerminalImportSession(importer.ImportSession):
# Ask for a track ID.
search_id = manual_id(True)
if search_id:
candidates, rec = autotag.tag_item(task.item,
search_id=search_id)
candidates, rec = autotag.tag_item(
task.item, search_ids=search_id.split())
elif choice in extra_ops.keys():
# Allow extra ops to automatically set the post-choice.
post_choice = extra_ops[choice](self, task)
@ -1022,6 +1022,11 @@ import_cmd.parser.add_option(
'--pretend', dest='pretend', action='store_true',
help='just print the files to import'
)
import_cmd.parser.add_option(
'-S', '--search-id', dest='search_ids', action='append',
metavar='BACKEND_ID',
help='restrict matching to a specific metadata backend ID'
)
import_cmd.func = import_func
default_commands.append(import_cmd)

View file

@ -75,7 +75,7 @@ def match_benchmark(lib, prof, query=None, album_id=None):
# Run the match.
def _run_match():
match.tag_album(items, search_id=album_id)
match.tag_album(items, search_ids=[album_id])
if prof:
cProfile.runctx('_run_match()', {}, {'_run_match': _run_match},
'match.prof')

View file

@ -20,6 +20,10 @@ New:
session. :bug:`1779`
* :doc:`/plugins/info`: A new option will print only fields' names and not
their values. Thanks to :user:`GuilhermeHideki`. :bug:`1812`
* A new ``--search-id`` importer option lets you specify one or several
matching MusicBrainz/Discogs IDs directly, bypassing the default initial
candidate search. Also, the ``enter Id`` prompt choice now accepts several
IDs, separated by spaces. :bug:`1808`
.. _AcousticBrainz: http://acousticbrainz.org/

View file

@ -60,8 +60,8 @@ all of these limitations.
because beets by default infers tags based on existing metadata. But this is
not a hard and fast rule---there are a few ways to tag metadata-poor music:
* You can use the *E* option described below to search in MusicBrainz for
a specific album or song.
* You can use the *E* or *I* options described below to search in
MusicBrainz for a specific album or song.
* The :doc:`Acoustid plugin </plugins/chroma>` extends the autotagger to
use acoustic fingerprinting to find information for arbitrary audio.
Install that plugin if you're willing to spend a little more CPU power
@ -160,10 +160,10 @@ When beets needs your input about a match, it says something like this::
Beirut - Lon Gisland
(Similarity: 94.4%)
* Scenic World (Second Version) -> Scenic World
[A]pply, More candidates, Skip, Use as-is, as Tracks, Enter search, or aBort?
[A]pply, More candidates, Skip, Use as-is, as Tracks, Enter search, enter Id, or aBort?
When beets asks you this question, it wants you to enter one of the capital
letters: A, M, S, U, T, G, E, or B. That is, you can choose one of the
letters: A, M, S, U, T, G, E, I or B. That is, you can choose one of the
following:
* *A*: Apply the suggested changes shown and move on.
@ -190,6 +190,11 @@ following:
option if beets hasn't found any good options because the album is mistagged
or untagged.
* *I*: Enter a metadata backend ID to use as search in the database. Use this
option to specify a backend entity (for example, a MusicBrainz release or
recording) directly, by pasting its ID or the full URL. You can also specify
several IDs by separating them by a space.
* *B*: Cancel this import task altogether. No further albums will be tagged;
beets shuts down immediately. The next time you attempt to import the same
directory, though, beets will ask you if you want to resume tagging where you

View file

@ -132,6 +132,11 @@ Optional command flags:
option. If set, beets will just print a list of files that it would
otherwise import.
* If you already have a metadata backend ID that matches the items to be
imported, you can instruct beets to restrict the search to that ID instead of
searching for other candidates by using the ``--search-id SEARCH_ID`` option.
Multiple IDs can be specified by simply repeating the option several times.
.. _rarfile: https://pypi.python.org/pypi/rarfile/2.2
.. only:: html

View file

@ -1689,6 +1689,169 @@ class ImportPretendTest(_common.TestCase, ImportHelper):
.format(displayable_path(self.empty_path))])
class ImportMusicBrainzIdTest(_common.TestCase, ImportHelper):
"""Test the --musicbrainzid argument."""
MB_RELEASE_PREFIX = 'https://musicbrainz.org/release/'
MB_RECORDING_PREFIX = 'https://musicbrainz.org/recording/'
ID_RELEASE_0 = '00000000-0000-0000-0000-000000000000'
ID_RELEASE_1 = '11111111-1111-1111-1111-111111111111'
ID_RECORDING_0 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
ID_RECORDING_1 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
def setUp(self):
self.setup_beets()
self._create_import_dir(1)
# Patch calls to musicbrainzngs.
self.release_patcher = patch('musicbrainzngs.get_release_by_id',
side_effect=mocked_get_release_by_id)
self.recording_patcher = patch('musicbrainzngs.get_recording_by_id',
side_effect=mocked_get_recording_by_id)
self.release_patcher.start()
self.recording_patcher.start()
def tearDown(self):
self.recording_patcher.stop()
self.release_patcher.stop()
self.teardown_beets()
def test_one_mbid_one_album(self):
self.config['import']['search_ids'] = \
[self.MB_RELEASE_PREFIX + self.ID_RELEASE_0]
self._setup_import_session()
self.importer.add_choice(importer.action.APPLY)
self.importer.run()
self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_0')
def test_several_mbid_one_album(self):
self.config['import']['search_ids'] = \
[self.MB_RELEASE_PREFIX + self.ID_RELEASE_0,
self.MB_RELEASE_PREFIX + self.ID_RELEASE_1]
self._setup_import_session()
self.importer.add_choice(2) # Pick the 2nd best match (release 1).
self.importer.add_choice(importer.action.APPLY)
self.importer.run()
self.assertEqual(self.lib.albums().get().album, 'VALID_RELEASE_1')
def test_one_mbid_one_singleton(self):
self.config['import']['search_ids'] = \
[self.MB_RECORDING_PREFIX + self.ID_RECORDING_0]
self._setup_import_session(singletons=True)
self.importer.add_choice(importer.action.APPLY)
self.importer.run()
self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_0')
def test_several_mbid_one_singleton(self):
self.config['import']['search_ids'] = \
[self.MB_RECORDING_PREFIX + self.ID_RECORDING_0,
self.MB_RECORDING_PREFIX + self.ID_RECORDING_1]
self._setup_import_session(singletons=True)
self.importer.add_choice(2) # Pick the 2nd best match (recording 1).
self.importer.add_choice(importer.action.APPLY)
self.importer.run()
self.assertEqual(self.lib.items().get().title, 'VALID_RECORDING_1')
def test_candidates_album(self):
"""Test directly ImportTask.lookup_candidates()."""
task = importer.ImportTask(paths=self.import_dir,
toppath='top path',
items=[_common.item()])
task.search_ids = [self.MB_RELEASE_PREFIX + self.ID_RELEASE_0,
self.MB_RELEASE_PREFIX + self.ID_RELEASE_1,
'an invalid and discarded id']
task.lookup_candidates()
self.assertEqual(set(['VALID_RELEASE_0', 'VALID_RELEASE_1']),
set([c.info.album for c in task.candidates]))
def test_candidates_singleton(self):
"""Test directly SingletonImportTask.lookup_candidates()."""
task = importer.SingletonImportTask(toppath='top path',
item=_common.item())
task.search_ids = [self.MB_RECORDING_PREFIX + self.ID_RECORDING_0,
self.MB_RECORDING_PREFIX + self.ID_RECORDING_1,
'an invalid and discarded id']
task.lookup_candidates()
self.assertEqual(set(['VALID_RECORDING_0', 'VALID_RECORDING_1']),
set([c.info.title for c in task.candidates]))
# Helpers for ImportMusicBrainzIdTest.
def mocked_get_release_by_id(id_, includes=[], release_status=[],
release_type=[]):
"""Mimic musicbrainzngs.get_release_by_id, accepting only a restricted list
of MB ids (ID_RELEASE_0, ID_RELEASE_1). The returned dict differs only in
the release title and artist name, so that ID_RELEASE_0 is a closer match
to the items created by ImportHelper._create_import_dir()."""
# Map IDs to (release title, artist), so the distances are different.
releases = {ImportMusicBrainzIdTest.ID_RELEASE_0: ('VALID_RELEASE_0',
'TAG ARTIST'),
ImportMusicBrainzIdTest.ID_RELEASE_1: ('VALID_RELEASE_1',
'DISTANT_MATCH')}
return {
'release': {
'title': releases[id_][0],
'id': id_,
'medium-list': [{
'track-list': [{
'recording': {
'title': 'foo',
'id': 'bar',
'length': 59,
},
'position': 9,
}],
'position': 5,
}],
'artist-credit': [{
'artist': {
'name': releases[id_][1],
'id': 'some-id',
},
}],
'release-group': {
'id': 'another-id',
}
}
}
def mocked_get_recording_by_id(id_, includes=[], release_status=[],
release_type=[]):
"""Mimic musicbrainzngs.get_recording_by_id, accepting only a restricted
list of MB ids (ID_RECORDING_0, ID_RECORDING_1). The returned dict differs
only in the recording title and artist name, so that ID_RECORDING_0 is a
closer match to the items created by ImportHelper._create_import_dir()."""
# Map IDs to (recording title, artist), so the distances are different.
releases = {ImportMusicBrainzIdTest.ID_RECORDING_0: ('VALID_RECORDING_0',
'TAG ARTIST'),
ImportMusicBrainzIdTest.ID_RECORDING_1: ('VALID_RECORDING_1',
'DISTANT_MATCH')}
return {
'recording': {
'title': releases[id_][0],
'id': id_,
'length': 59,
'artist-credit': [{
'artist': {
'name': releases[id_][1],
'id': 'some-id',
},
}],
}
}
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)