Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add parentwork plugin #3279

Merged
merged 27 commits into from
Jun 9, 2019
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 198 additions & 0 deletions beetsplug/parentwork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# -*- 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_id(mb_workid, work_date=None):
""" Given a mb_workid, 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 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
arcresu marked this conversation as resolved.
Show resolved Hide resolved
return None, work_date


def work_parent_id(mb_workid):
"""Find the parentwork id and composition date of a work given its id. """
work_date = None
while True:
new_mb_workid, work_date = work_father_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):
"""Return the work relationships (dict) and composition date of a
parentwork given the id of the 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'Fetches parent works, composers and dates')
dosoe marked this conversation as resolved.
Show resolved Hide resolved

command.parser.add_option(
u'-f', u'--force', dest='force',
action='store_true', default=None,
help=u'Re-fetches all parent works')
dosoe marked this conversation as resolved.
Show resolved Hide resolved

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 parentwork 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.info(item.artist + ' - ' + item.title)
self._log.debug(
"no composer, add one at https://musicbrainz.org/work/" +
work_info['work']['id'])
dosoe marked this conversation as resolved.
Show resolved Hide resolved

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):
dosoe marked this conversation as resolved.
Show resolved Hide resolved
""" Finds the parentwork of a recording and populates the tags
accordingly.

Namely, the tags parentwork, parentwork_disambig, mb_parentworkid,
parent_composer, parent_composer_sort and work_date are populated. """

if hasattr(item, 'parentwork'):
hasparent = True
else:
hasparent = False
dosoe marked this conversation as resolved.
Show resolved Hide resolved
dosoe marked this conversation as resolved.
Show resolved Hide resolved
if not item.mb_workid:
self._log.info("No work attached, recording id: " +
item.mb_trackid)
self._log.info(item.artist + ' - ' + item.title)
self._log.info("add one at https://musicbrainz.org" +
"/recording/" + item.mb_trackid)
dosoe marked this conversation as resolved.
Show resolved Hide resolved
return
dosoe marked this conversation as resolved.
Show resolved Hide resolved
if force or (not hasparent):
dosoe marked this conversation as resolved.
Show resolved Hide resolved
try:
work_info, work_date = find_parentwork_info(item.mb_workid)
except musicbrainzngs.musicbrainz.WebServiceError:
dosoe marked this conversation as resolved.
Show resolved Hide resolved
self._log.debug("Work unreachable")
dosoe marked this conversation as resolved.
Show resolved Hide resolved
return
parent_info = self.get_info(item, work_info)

elif hasparent:
self._log.debug("Work already in library, not necessary fetching")
dosoe marked this conversation as resolved.
Show resolved Hide resolved
return

self._log.debug("Finished searching work for: " +
item.artist + ' - ' + item.title)
dosoe marked this conversation as resolved.
Show resolved Hide resolved
self._log.debug("Work fetched: " + parent_info['parentwork'] +
' - ' + parent_info['parent_composer'])

for key, value in parent_info.items():
dosoe marked this conversation as resolved.
Show resolved Hide resolved
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'])

item.store()
dosoe marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 2 additions & 0 deletions docs/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ like this::
missing
mpdstats
mpdupdate
parentwork
permissions
play
playlist
Expand Down Expand Up @@ -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.
Expand Down
60 changes: 60 additions & 0 deletions docs/plugins/parentwork.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
Parentwork Plugin
=================

The ``parentwork`` plugin fetches the work title, parentwork title and
parentwork composer.
arcresu marked this conversation as resolved.
Show resolved Hide resolved

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
arcresu marked this conversation as resolved.
Show resolved Hide resolved
from the library and then looks up the father, then the father of the father
arcresu marked this conversation as resolved.
Show resolved Hide resolved
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
arcresu marked this conversation as resolved.
Show resolved Hide resolved
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
dosoe marked this conversation as resolved.
Show resolved Hide resolved
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``

dosoe marked this conversation as resolved.
Show resolved Hide resolved
93 changes: 93 additions & 0 deletions test/test_parentwork.py
Original file line number Diff line number Diff line change
@@ -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):
arcresu marked this conversation as resolved.
Show resolved Hide resolved
mb_workid = u'2e4a3668-458d-3b2a-8be2-0b08e0d8243a'
self.assertEqual(u'f04b42df-7251-4d86-a5ee-67cfa49580d1',
parentwork.work_father_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')