# -*- coding: utf-8 -*- # # Copyright (C) 2018-2019, 2021-2022 Philipp Wolfer # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. PLUGIN_NAME = 'Work & Movement' PLUGIN_AUTHOR = 'Philipp Wolfer' PLUGIN_DESCRIPTION = '''Set work and movement based on work relationships. This plugin attempts to set the `movement` and `movementnumber` tags, but only if the work linked to the recording is part of a larger work. The `work` tag then gets set to the work of which the movement is a part of. If the recording is only linked to a simple work without separate parts then it is not considered a proper work and the work and movement related tags will be cleared. The plugin will always set the original values, as loaded from MusicBrainz, of the `work` and `musicbrainz_workid` tags into the variables `%%recording_work%` and `%%recording_workid%` to be used in scripting. ''' PLUGIN_VERSION = '1.1' PLUGIN_API_VERSIONS = ['2.1', '2.2', '2.3', '2.4', '2.5', '2.6', '2.7', '2.8'] PLUGIN_LICENSE = 'GPL-2.0-or-later' PLUGIN_LICENSE_URL = 'https://www.gnu.org/licenses/gpl-2.0.html' import re from .roman import ( fromRoman, RomanError, ) from picard import log from picard.metadata import register_track_metadata_processor _re_work_title = re.compile(r'(?P.*):\s+(?P[IVXLCDM]+)\.\s[\s,:;./]*(?P.*)') _re_part_number = re.compile(r'(?P[0-9IVXLCDM]+)\.?\s[\s,:;./]*') _re_separators = re.compile(r'^[\s,:;./-]+') class Work: def __init__(self, title, mbid=None): self.mbid = mbid self.title = title self.is_movement = False self.is_work = False self.part_number = 0 self.parent = None def __str__(self): s = [] if self.parent: s.append(str(self.parent)) if self.is_movement: work_type = 'Movement' elif self.is_work: work_type = 'Work' else: work_type = 'Unknown' s.append('%s %i: %s' % (work_type, self.part_number, self.title)) return '\n'.join(s) def is_performance_work(rel): return (rel['target-type'] == 'work' and rel['direction'] == 'forward' and rel['type'] == 'performance') def is_parent_work(rel): return (rel['target-type'] == 'work' and rel['direction'] == 'backward' and rel['type'] == 'parts') def is_movement_like(rel): return ('movement' in rel['attributes'] or 'act' in rel['attributes'] or 'ordering-key' in rel) def is_child_work(rel): return (rel['target-type'] == 'work' and rel['direction'] == 'forward' and rel['type'] == 'parts') def number_to_int(s): """ Converts a numeric string to int. `s` can also be a Roman numeral. """ try: return int(s) except ValueError: try: return fromRoman(s) except RomanError as e: raise ValueError(e) def parse_work_name(title): return _re_work_title.search(title) def create_work_and_movement_from_title(work): """ Attempts to parse work.title in the form ": . ", where is in Roman numerals. Sets the `is_movement` and `part_number` properties on `work` and creates a `parent` work if not already present. """ title = work.title match = parse_work_name(title) if match: work.title = match.group('movement') work.is_movement = True try: number = number_to_int(match.group('movementnumber')) except ValueError as e: log.error(e) number = 0 if not work.part_number: work.part_number = number elif work.part_number != number: log.warning('Movement number mismatch for "%s": %s != %i', title, match.group('movementnumber'), work.part_number) if not work.parent: work.parent = Work(match.group('work')) work.parent.is_work = True elif work.parent.title != match.group('work'): log.warning('Movement work name mismatch for "%s": "%s" != "%s"', title, match.group('work'), work.parent.title) return work def normalize_movement_title(work): """ Removes the parent work title and part number from the beginning of `work.title`. This ensures movement names don't contain duplicated information even if they do not follow the strict naming format used by `create_work_and_movement_from_title`. """ movement_title = work.title if work.parent: work_title = work.parent.title if movement_title.startswith(work_title): movement_title = movement_title[len(work_title):] movement_title = _re_separators.sub('', movement_title) match = _re_part_number.match(movement_title) if match: # Only remove the number if it matches the part_number try: number = number_to_int(match.group('number')) if number == work.part_number: movement_title = _re_part_number.sub('', movement_title) except ValueError as e: log.warning(e) return movement_title def parse_work(work_rel): work = Work(work_rel['title'], work_rel['id']) if 'relations' in work_rel: for rel in work_rel['relations']: # If this work has parents and is linked to those as 'movement' or # 'act' we consider it a part of a larger work and store it # in the movement tag. The parent will be set as the work. if is_parent_work(rel): if is_movement_like(rel): work.is_movement = True work.part_number = rel.get('ordering-key') if 'work' in rel: work.parent = parse_work(rel['work']) work.parent.is_work = True work.title = normalize_movement_title(work) else: # Not a movement, but still part of a larger work. # Mark it as a work. work.is_work = True # If this work has any parts, we consider it a proper work. # This is a recording directly linked to a larger work. if is_child_work(rel): work.is_work = True return work def unset_work(metadata): metadata.delete('work') metadata.delete('musicbrainz_workid') metadata.delete('movement') metadata.delete('movementnumber') metadata.delete('movementtotal') metadata.delete('showmovement') def set_work(metadata, work): metadata['work'] = work.title metadata['musicbrainz_workid'] = work.mbid metadata['showmovement'] = 1 def process_track(album, metadata, track, release): if 'recording' in track: recording = track['recording'] else: recording = track if 'relations' not in recording: return # Remember original work and work ID metadata['~recording_work'] = metadata.getall('work') metadata['~recording_workid'] = metadata.getall('musicbrainz_workid') # Try to find a suitable work linked to the recording. # As a fallback try to get work + movement information by parsing # the recording title. work = Work(recording['title']) for rel in recording['relations']: if is_performance_work(rel): work = parse_work(rel['work']) # Only use the first work that qualifies as a work or movement log.debug('Found work:\n%s', work) if work.is_movement or work.is_work: break unset_work(metadata) if not work.is_movement: work = create_work_and_movement_from_title(work) if work.is_movement and work.parent and work.parent.is_work: metadata['movement'] = work.title if work.part_number: metadata['movementnumber'] = work.part_number set_work(metadata, work.parent) elif work.is_work: set_work(metadata, work) register_track_metadata_processor(process_track)