add parentwork plugin, first try

This commit is contained in:
Dorian Soergel 2019-05-31 14:35:51 +02:00
parent 4fc9e2686b
commit 080680c950
4 changed files with 348 additions and 0 deletions

193
beetsplug/parentwork.py Normal file
View file

@ -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()

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

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_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')