diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py new file mode 100644 index 0000000000..63ef0102cd --- /dev/null +++ b/beetsplug/parentwork.py @@ -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']) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1c8a8d4174..7417a56b39 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -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. diff --git a/docs/plugins/parentwork.rst b/docs/plugins/parentwork.rst new file mode 100644 index 0000000000..a221e19b54 --- /dev/null +++ b/docs/plugins/parentwork.rst @@ -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`` diff --git a/test/test_parentwork.py b/test/test_parentwork.py new file mode 100644 index 0000000000..dfebc66023 --- /dev/null +++ b/test/test_parentwork.py @@ -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')