Merge pull request #3279 from dosoe/beets_parentwork_3

add parentwork plugin
This commit is contained in:
Adrian Sampson 2019-06-08 21:35:05 -04:00
commit c2fdf04539
4 changed files with 341 additions and 0 deletions

201
beetsplug/parentwork.py Normal file
View file

@ -0,0 +1,201 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Dorian Soergel.
#
# 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.
"""Gets parent work, its disambiguation and id, composer, composer sort name
and work composition date
"""
from __future__ import division, absolute_import, print_function
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']
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.
"""
work_date = None
while True:
new_mb_workid, work_date = direct_parent_id(mb_workid, work_date)
if not new_mb_workid:
return mb_workid, work_date
mb_workid = new_mb_workid
return mb_workid, work_date
def find_parentwork_info(mb_workid):
"""Get the MusicBrainz information dict about a parent work, including
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"])
return work_info, work_date
class ParentWorkPlugin(BeetsPlugin):
def __init__(self):
super(ParentWorkPlugin, self).__init__()
self.config.add({
'auto': False,
'force': False,
})
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)
write = ui.should_write()
for item in lib.items(ui.decargs(args)):
self.find_work(item, force_parent)
item.store()
if write:
item.try_write()
command = ui.Subcommand(
'parentwork',
help=u'fetche parent works, composers and dates')
command.parser.add_option(
u'-f', u'--force', dest='force',
action='store_true', default=None,
help=u'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)
for item in task.imported_items():
self.find_work(item, force_parent)
item.store()
def get_info(self, item, work_info):
"""Given the parent work info dict, fetch parent_composer,
parent_composer_sort, parentwork, parentwork_disambig, mb_workid and
composer_ids.
"""
parent_composer = []
parent_composer_sort = []
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':
parent_composer.append(artist['artist']['name'])
parent_composer_sort.append(artist['artist']['sort-name'])
parentwork_info['parent_composer'] = u', '.join(parent_composer)
parentwork_info['parent_composer_sort'] = u', '.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'])
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']
else:
parentwork_info['parentwork_disambig'] = None
return parentwork_info
def find_work(self, item, force):
"""Finds the parent work of a recording and populates the tags
accordingly.
The parent work is found recursively, by finding the direct parent
repeatedly until there are no more links in the chain. We return the
final, topmost work in the chain.
Namely, the tags parentwork, parentwork_disambig, mb_parentworkid,
parent_composer, parent_composer_sort and work_date are populated.
"""
if not item.mb_workid:
self._log.info('No work for {}, \
add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
return
hasparent = hasattr(item, 'parentwork')
if force or not hasparent:
try:
work_info, work_date = find_parentwork_info(item.mb_workid)
except musicbrainzngs.musicbrainz.WebServiceError as e:
self._log.debug("error fetching work: {}", e)
return
parent_info = self.get_info(item, work_info)
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'])
elif hasparent:
self._log.debug("{}: Work present, skipping", item)
return
# apply all non-null values to the item
for key, value in parent_info.items():
if value:
item[key] = value
if work_date:
item['work_date'] = work_date
ui.show_model_changes(
item, fields=['parentwork', 'parentwork_disambig',
'mb_parentworkid', 'parent_composer',
'parent_composer_sort', 'work_date'])

View file

@ -80,6 +80,7 @@ like this::
missing
mpdstats
mpdupdate
parentwork
permissions
play
playlist
@ -131,6 +132,7 @@ Metadata
* :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).
* :doc:`parentwork`: Fetch work titles and works they are part of.
* :doc:`replaygain`: Calculate volume normalization for players that support it.
* :doc:`scrub`: Clean extraneous metadata from music files.
* :doc:`zero`: Nullify fields by pattern or unconditionally.

View file

@ -0,0 +1,45 @@
Parentwork Plugin
=================
The ``parentwork`` plugin fetches the work title, parent work title and
parent work composer from MusicBrainz.
In the MusicBrainz database, a recording can be associated with a work. A
work can itself be associated with another work, for example one being part
of the other (what we call the *direct parent*). This plugin looks the work id
from the library and then looks up the direct parent, then the direct parent
of the direct parent and so on until it reaches the top. The work at the top
is what we call the *parent work*. This plugin is especially designed for
classical music. For classical music, just fetching the work title as in
MusicBrainz is not satisfying, because MusicBrainz has separate works for, for
example, all the movements of a symphony. This plugin aims to solve this
problem by not only fetching the work itself from MusicBrainz but also its
parent work which would be, in this case, the whole symphony.
This plugin adds five tags:
- **parentwork**: The title of the parent work.
- **mb_parentworkid**: The musicbrainz id of the parent work.
- **parentwork_disambig**: The disambiguation of the parent work title.
- **parent_composer**: The composer of the parent work.
- **parent_composer_sort**: The sort name of the parent work composer.
- **work_date**: The composition date of the work, or the first parent work
that has a composition date. Format: yyyy-mm-dd.
To use the ``parentwork`` plugin, enable it in your configuration (see
:ref:`using-plugins`).
Configuration
-------------
To configure the plugin, make a ``parentwork:`` section in your
configuration file. The available options are:
- **force**: As a default, ``parentwork`` only fetches work info for
recordings that do not already have a ``parentwork`` tag. If ``force``
is enabled, it fetches it for all recordings.
Default: ``no``
- **auto**: If enabled, automatically fetches works at import. It takes quite
some time, because beets is restricted to one musicbrainz query per second.
Default: ``no``

93
test/test_parentwork.py Normal file
View file

@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# This file is part of beets.
# Copyright 2017, Dorian Soergel
#
# 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.
"""Tests for the 'parentwork' plugin."""
from __future__ import division, absolute_import, print_function
from mock import patch
import unittest
from test.helper import TestHelper
from beets.library import Item
from beetsplug import parentwork
@patch('beets.util.command_output')
class ParentWorkTest(unittest.TestCase, TestHelper):
def setUp(self):
"""Set up configuration"""
self.setup_beets()
self.load_plugins('parentwork')
def tearDown(self):
self.unload_plugins()
self.teardown_beets()
def test_normal_case(self, command_output):
item = Item(path='/file',
mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53')
item.add(self.lib)
command_output.return_value = u'32c8943f-1b27-3a23-8660-4567f4847c94'
self.run_command('parentwork')
item.load()
self.assertEqual(item['mb_parentworkid'],
u'32c8943f-1b27-3a23-8660-4567f4847c94')
def test_force(self, command_output):
self.config['parentwork']['force'] = True
item = Item(path='/file',
mb_workid=u'e27bda6e-531e-36d3-9cd7-b8ebc18e8c53',
mb_parentworkid=u'XXX')
item.add(self.lib)
command_output.return_value = u'32c8943f-1b27-3a23-8660-4567f4847c94'
self.run_command('parentwork')
item.load()
self.assertEqual(item['mb_parentworkid'],
u'32c8943f-1b27-3a23-8660-4567f4847c94')
def test_no_force(self, command_output):
self.config['parentwork']['force'] = True
item = Item(path='/file', mb_workid=u'e27bda6e-531e-36d3-9cd7-\
b8ebc18e8c53', mb_parentworkid=u'XXX')
item.add(self.lib)
command_output.return_value = u'32c8943f-1b27-3a23-8660-4567f4847c94'
self.run_command('parentwork')
item.load()
self.assertEqual(item['mb_parentworkid'], u'XXX')
# test different cases, still with Matthew Passion Ouverture or Mozart
# requiem
def test_direct_parent_work(self, command_output):
mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a'
self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1',
parentwork.direct_parent_id(mb_workid)[0])
self.assertEqual(u'45afb3b2-18ac-4187-bc72-beb1b1c194ba',
parentwork.work_parent_id(mb_workid)[0])
def suite():
return unittest.TestLoader().loadTestsFromName(__name__)
if __name__ == '__main__':
unittest.main(defaultTest='suite')