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 21 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
200 changes: 200 additions & 0 deletions beetsplug/parentwork.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# -*- 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 mb_workid, find the id one of the works the work is part of
and the first composition date it encounters.
dosoe marked this conversation as resolved.
Show resolved Hide resolved
"""
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.
dosoe marked this conversation as resolved.
Show resolved Hide resolved
"""
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):
"""Return the work relationships (dict) and composition date of a
parent work given the id of the work
dosoe marked this conversation as resolved.
Show resolved Hide resolved
"""
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 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):
dosoe marked this conversation as resolved.
Show resolved Hide resolved
"""Finds the parent work 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 for {}, \
add one at https://musicbrainz.org/recording/{}', item, item.mb_trackid)
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 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)
dosoe marked this conversation as resolved.
Show resolved Hide resolved
return

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
46 changes: 46 additions & 0 deletions docs/plugins/parentwork.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
Parentwork Plugin
=================

The ``parentwork`` plugin fetches the work title, parent work title and
parent work composer.
dosoe 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 direct parent). This plugin looks the work id
dosoe marked this conversation as resolved.
Show resolved Hide resolved
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 I call the parent work. This plugin is especially designed for
dosoe marked this conversation as resolved.
Show resolved Hide resolved
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``

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