Skip to content

Commit

Permalink
Merge pull request #333 from rdswift/genre_mapper_plugin
Browse files Browse the repository at this point in the history
Add Genre Mapper plugin
  • Loading branch information
zas authored Jul 25, 2023
2 parents 1c09bd5 + d7acc14 commit c5708b2
Show file tree
Hide file tree
Showing 3 changed files with 490 additions and 0 deletions.
170 changes: 170 additions & 0 deletions plugins/genre_mapper/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2022 Bob Swift (rdswift)
#
# 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 = 'Genre Mapper'
PLUGIN_AUTHOR = 'Bob Swift'
PLUGIN_DESCRIPTION = '''
This plugin provides the ability to standardize genres in the "genre"
tag by matching the genres as found to a standard genre as defined in
the genre replacement mapping configuration option. Once installed a
settings page will be added to Picard's options, which is where the
plugin is configured.
<br /><br />
Please see the <a href="https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/genre_mapper/docs/README.md">user guide</a> on GitHub for more information.
'''
PLUGIN_VERSION = '0.4'
PLUGIN_API_VERSIONS = ['2.0', '2.1', '2.2', '2.3', '2.6', '2.7', '2.8']
PLUGIN_LICENSE = "GPL-2.0"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.txt"

import re

from picard import (
config,
log,
)
from picard.metadata import (
MULTI_VALUED_JOINER,
register_track_metadata_processor,
)
from picard.plugin import PluginPriority
from picard.plugins.genre_mapper.ui_options_genre_mapper import (
Ui_GenreMapperOptionsPage,
)

from picard.ui.options import (
OptionsPage,
register_options_page,
)


pairs_split = re.compile(r"\r\n|\n\r|\n").split

OPT_MATCH_ENABLED = 'genre_mapper_enabled'
OPT_MATCH_PAIRS = 'genre_mapper_replacement_pairs'
OPT_MATCH_FIRST = 'genre_mapper_apply_first_match_only'
OPT_MATCH_REGEX = 'genre_mapper_use_regex'


class GenreMappingPairs():
pairs = []

@classmethod
def refresh(cls):
log.debug("%s: Refreshing the genre replacement maps processing pairs using '%s' translation.",
PLUGIN_NAME, 'RegEx' if config.Option.exists("setting", OPT_MATCH_REGEX) and config.setting[OPT_MATCH_REGEX] else 'Simple',)
if not config.Option.exists("setting", OPT_MATCH_PAIRS):
log.warning("%s: Unable to read the '%s' setting.", PLUGIN_NAME, OPT_MATCH_PAIRS,)
return

def _make_re(map_string):
# Replace period with temporary placeholder character (newline)
re_string = str(map_string).strip().replace('.', '\n')
# Convert wildcard characters to regular expression equivalents
re_string = re_string.replace('*', '.*').replace('?', '.')
# Escape carat and dollar sign for regular expression
re_string = re_string.replace('^', '\\^').replace('$', '\\$')
# Replace temporary placeholder characters with escaped periods
re_string = '^' + re_string.replace('\n', '\\.') + '$'
# Return regular expression with carat and dollar sign to force match condition on full string
return re_string

cls.pairs = []
for pair in pairs_split(config.setting[OPT_MATCH_PAIRS]):
if "=" not in pair:
continue
original, replacement = pair.split('=', 1)
original = original.strip()
if not original:
continue
replacement = replacement.strip()
cls.pairs.append((original if config.setting[OPT_MATCH_REGEX] else _make_re(original), replacement))
log.debug('%s: Add genre mapping pair: "%s" = "%s"', PLUGIN_NAME, original, replacement,)
if not cls.pairs:
log.debug("%s: No genre replacement maps defined.", PLUGIN_NAME,)


class GenreMapperOptionsPage(OptionsPage):

NAME = "genre_mapper"
TITLE = "Genre Mapper"
PARENT = "plugins"

options = [
config.TextOption("setting", OPT_MATCH_PAIRS, ''),
config.BoolOption("setting", OPT_MATCH_FIRST, False),
config.BoolOption("setting", OPT_MATCH_ENABLED, False),
config.BoolOption("setting", OPT_MATCH_REGEX, False),
]

def __init__(self, parent=None):
super().__init__(parent)
self.ui = Ui_GenreMapperOptionsPage()
self.ui.setupUi(self)

def load(self):
# Enable external link
self.ui.format_description.setOpenExternalLinks(True)

self.ui.genre_mapper_replacement_pairs.setPlainText(config.setting[OPT_MATCH_PAIRS])
self.ui.genre_mapper_first_match_only.setChecked(config.setting[OPT_MATCH_FIRST])
self.ui.cb_enable_genre_mapping.setChecked(config.setting[OPT_MATCH_ENABLED])
self.ui.cb_use_regex.setChecked(config.setting[OPT_MATCH_REGEX])

self.ui.cb_enable_genre_mapping.stateChanged.connect(self._set_enabled_state)
self._set_enabled_state()

def save(self):
config.setting[OPT_MATCH_PAIRS] = self.ui.genre_mapper_replacement_pairs.toPlainText()
config.setting[OPT_MATCH_FIRST] = self.ui.genre_mapper_first_match_only.isChecked()
config.setting[OPT_MATCH_ENABLED] = self.ui.cb_enable_genre_mapping.isChecked()
config.setting[OPT_MATCH_REGEX] = self.ui.cb_use_regex.isChecked()

GenreMappingPairs.refresh()

def _set_enabled_state(self, *args):
self.ui.gm_replacement_pairs.setEnabled(self.ui.cb_enable_genre_mapping.isChecked())


def track_genre_mapper(album, metadata, *args):
if not config.setting[OPT_MATCH_ENABLED]:
return
if 'genre' not in metadata or not metadata['genre']:
log.debug("%s: No genres found for: \"%s\"", PLUGIN_NAME, metadata['title'],)
return
genres = set()
metadata_genres = str(metadata['genre']).split(MULTI_VALUED_JOINER)
for genre in metadata_genres:
for (original, replacement) in GenreMappingPairs.pairs:
if genre and re.search(original, genre, re.IGNORECASE):
genre = replacement
if config.setting[OPT_MATCH_FIRST]:
break
if genre:
genres.add(genre.title())
genres = sorted(genres)
log.debug("{0}: Genres updated from {1} to {2}".format(PLUGIN_NAME, metadata_genres, genres,))
metadata['genre'] = genres


# Register the plugin to run at a LOW priority.
register_track_metadata_processor(track_genre_mapper, priority=PluginPriority.LOW)
register_options_page(GenreMapperOptionsPage)

GenreMappingPairs.refresh()
198 changes: 198 additions & 0 deletions plugins/genre_mapper/options_genre_mapper.ui
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>GenreMapperOptionsPage</class>
<widget class="QWidget" name="GenreMapperOptionsPage">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>398</width>
<height>568</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QVBoxLayout">
<property name="spacing">
<number>16</number>
</property>
<item>
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="QGroupBox" name="gm_description">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>50</height>
</size>
</property>
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="title">
<string>Genre Mapper</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>9</number>
</property>
<property name="topMargin">
<number>9</number>
</property>
<property name="rightMargin">
<number>9</number>
</property>
<property name="bottomMargin">
<number>1</number>
</property>
<item>
<widget class="QLabel" name="format_description">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;These are the original / replacement pairs used to map one genre entry to another. Each pair must be entered on a separate line in the form:&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-weight:600;&quot;&gt;[genre match test string]=[replacement genre]&lt;/span&gt;&lt;/p&gt;&lt;p&gt;Unless the &amp;quot;regular expressions&amp;quot; option is enabled, supported wildcards in the test string part of the mapping include '*' and '?' to match any number of characters and a single character respectively. An example for mapping all types of Rock genres (e.g. Country Rock, Hard Rock, Progressive Rock) to &amp;quot;Rock&amp;quot; would be done using the following line:&lt;/p&gt;&lt;p&gt;&lt;span style=&quot; font-family:'Courier New'; font-size:10pt; font-weight:600;&quot;&gt;*rock*=Rock&lt;/span&gt;&lt;/p&gt;&lt;p&gt;Blank lines and lines beginning with an equals sign (=) will be ignored. Case-insensitive tests are used when matching. Replacements will be made in the order they are found in the list.&lt;/p&gt;&lt;p&gt;For more information please see the &lt;a href=&quot;https://github.com/rdswift/picard-plugins/blob/2.0_RDS_Plugins/plugins/genre_mapper/docs/README.md&quot;&gt;&lt;span style=&quot; text-decoration: underline; color:#0000ff;&quot;&gt;User Guide&lt;/span&gt;&lt;/a&gt; on GitHub.&lt;br/&gt;&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="alignment">
<set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop</set>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QCheckBox" name="cb_enable_genre_mapping">
<property name="text">
<string>Enable genre mapping</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="gm_replacement_pairs">
<property name="enabled">
<bool>true</bool>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>50</height>
</size>
</property>
<property name="font">
<font>
<weight>75</weight>
<bold>true</bold>
</font>
</property>
<property name="title">
<string>Replacement Pairs</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QCheckBox" name="cb_use_regex">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Match tests are entered as regular expressions</string>
</property>
</widget>
</item>
<item>
<widget class="QCheckBox" name="genre_mapper_first_match_only">
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="text">
<string>Apply only the first matching replacement</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QPlainTextEdit" name="genre_mapper_replacement_pairs">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>50</height>
</size>
</property>
<property name="font">
<font>
<family>Courier New</family>
<pointsize>10</pointsize>
</font>
</property>
<property name="tabChangesFocus">
<bool>true</bool>
</property>
<property name="lineWrapMode">
<enum>QPlainTextEdit::NoWrap</enum>
</property>
<property name="placeholderText">
<string>Enter replacement pairs (one per line)</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<tabstops>
<tabstop>cb_enable_genre_mapping</tabstop>
<tabstop>cb_use_regex</tabstop>
<tabstop>genre_mapper_first_match_only</tabstop>
<tabstop>genre_mapper_replacement_pairs</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>
Loading

0 comments on commit c5708b2

Please sign in to comment.