From 080680c950432898a47979e96f64a50622ddf50e Mon Sep 17 00:00:00 2001 From: Dorian Soergel Date: Fri, 31 May 2019 14:35:51 +0200 Subject: [PATCH] add parentwork plugin, first try --- beetsplug/parentwork.py | 193 +++++++++++++++++++++++++++++++++++++ docs/plugins/index.rst | 2 + docs/plugins/parentwork.py | 60 ++++++++++++ test/test_parentwork.py | 93 ++++++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 beetsplug/parentwork.py create mode 100644 docs/plugins/parentwork.py create mode 100644 test/test_parentwork.py diff --git a/beetsplug/parentwork.py b/beetsplug/parentwork.py new file mode 100644 index 000000000..8a8c0c12a --- /dev/null +++ b/beetsplug/parentwork.py @@ -0,0 +1,193 @@ +# -*- 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 work title, disambiguation, parent work and its disambiguation, +composer, composer sort name and performers +""" + +from __future__ import division, absolute_import, print_function + +from beets import ui +from beets.plugins import BeetsPlugin + +import musicbrainzngs + + +def work_father(mb_workid, work_date=None): + """ This function finds the id of the father work given its id""" + 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 work_father in work_info['work']['work-relation-list']: + if work_father['type'] == 'parts' \ + and work_father.get('direction') == 'backward': + father_id = work_father['work']['id'] + return father_id, work_date + return None, work_date + + +def work_parent(mb_workid): + """This function finds the parentwork id of a work given its id. """ + work_date = None + while True: + (new_mb_workid, work_date) = work_father(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(mb_workid): + """This function gives the work relationships (dict) of a parentwork + given the id of the work""" + parent_id, work_date = work_parent(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, + }) + + self._command = ui.Subcommand( + 'parentwork', + help=u'Fetches parent works, composers and dates') + + self._command.parser.add_option( + u'-f', u'--force', dest='force', + action='store_true', default=None, + help=u'Re-fetches all parent works') + + 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() + + self._command.func = func + return [self._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 parentwork info dict, this function fetches + parent_composer, parent_composer_sort, parentwork, + parentwork_disambig, mb_workid and composer_ids""" + + parent_composer = [] + parent_composer_sort = [] + + 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']) + if not composer_exists: + self._log.info(item.artist + ' - ' + item.title) + self._log.debug( + "no composer, add one at https://musicbrainz.org/work/" + + work_info['work']['id']) + parentwork = work_info['work']['title'] + mb_parentworkid = work_info['work']['id'] + if 'disambiguation' in work_info['work']: + parentwork_disambig = work_info['work']['disambiguation'] + else: + parentwork_disambig.append('') + return parentwork, mb_parentworkid, parentwork_disambig, + parent_composer, parent_composer_sort + + def find_work(self, item, force): + + recording_id = item.mb_trackid + try: + item.parentwork + hasparent = True + except AttributeError: + hasparent = False + hasawork = True + if not item.mb_workid: + self._log.info("No work attached, recording id: " + + recording_id) + self._log.info(item.artist + ' - ' + item.title) + self._log.info("add one at https://musicbrainz.org" + + "/recording/" + recording_id) + hasawork = False + found = False + + if hasawork and (force or (not hasparent)): + try: + work_info, work_date = find_parentwork(item.mb_workid) + (parentwork, mb_parentworkid, parentwork_disambig, + parent_composer, + parent_composer_sort) = self.get_info(item, work_info) + found = True + except musicbrainzngs.musicbrainz.WebServiceError: + self._log.debug("Work unreachable") + found = False + elif parentwork: + self._log.debug("Work already in library, not necessary fetching") + return + + if found: + self._log.debug("Finished searching work for: " + + item.artist + ' - ' + item.title) + self._log.debug("Work fetched: " + parentwork + + ' - ' + u', '.join(parent_composer)) + item['parentwork'] = parentwork + item['parentwork_disambig'] = parentwork_disambig + item['mb_parentworkid'] = mb_parentworkid + item['parent_composer'] = u'' + item['parent_composer'] = u', '.join(parent_composer) + item['parent_composer_sort'] = u'' + item['parent_composer_sort'] = u', '.join(parent_composer_sort) + 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']) + + item.store() diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index e75e2f810..b962f7a10 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.py b/docs/plugins/parentwork.py new file mode 100644 index 000000000..d64934b88 --- /dev/null +++ b/docs/plugins/parentwork.py @@ -0,0 +1,60 @@ +Parentwork Plugin +================= + +The ``parentwork`` plugin fetches the work title, parentwork title and +parentwork composer. + +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 I call the father work). This plugin looks the work id +from the library and then looks up the father, then the father of the father +and so on until it reaches the top. The work at the top is what I call the +parentwork. 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 parentwork which would +be, in this case, the whole symphony. + +This plugin adds five tags: + +- **parentwork**: The title of the parentwork. +- **mb_parentworkid**: The musicbrainz id of the parentwork. +- **parentwork_disambig**: The disambiguation of the parentwork title. +- **parent_composer**: The composer of the parentwork. +- **parent_composer_sort**: The sort name of the parentwork composer. +- **work_date**: THe composition date of the work, or the first parent work + that has a composition date. Format: yyyy-mm-dd. + +To fill in the parentwork tag and the associated parent** tags, in case there +are several works on the recording, it fills it with the results of the first +work and then appends the results of the second work only if they differ from +the ones already there. This is to care for cases of, for example, an opera +recording that contains several scenes of the opera: neither the parentwork +nor all the associated tags will be duplicated. +If there are several works linked to a recording, they all get a +disambiguation (empty as default) and if all disambiguations are empty, the +disambiguation field is left empty, else the disambiguation field can look +like ``,disambig,,`` (if there are four works and only the second has a +disambiguation) if only the second work has a disambiguation. This may +seem clumsy but it allows to identify which of the four works the +disambiguation belongs to. + +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 000000000..44545c63e --- /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_father_work(self, command_output): + mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a' + self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1', + parentwork.work_father(mb_workid)[0]) + self.assertEqual(u'45afb3b2-18ac-4187-bc72-beb1b1c194ba', + parentwork.work_parent(mb_workid)[0]) + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + + +if __name__ == '__main__': + unittest.main(defaultTest='suite')