-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3279 from dosoe/beets_parentwork_3
add parentwork plugin
- Loading branch information
Showing
4 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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']) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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`` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') |