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

PICARD-2032: Set albumsort/titlesort tags #369

Merged
merged 7 commits into from
Jan 27, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
331 changes: 331 additions & 0 deletions plugins/enhanced_titles/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,331 @@
# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 Giorgio Fontanive (twodoorcoupe)
#
# 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.

PLUGIN_NAME = "Enhanced Titles"
PLUGIN_AUTHOR = "Giorgio Fontanive"
PLUGIN_DESCRIPTION = """
This plugin sets the albumsort and titlesort tags. It also provides the script
functions $swapprefix_lang, $delprefix_lang and $title_lang. The languages
included at the moment are English, Spanish, Italian, German, French and Portuguese.

The functions do the same thing as their original counterparts, but take
multiple languages as parameters. If no languages are provided, all the available ones are
included. Languages are provided with ISO 639-3 codes: eng, spa, ita, fra, deu, por.

Tagging and checking aliases can be disabled in the plugin's options page, found
under "plugins". Checking aliases will slow down processing.
"""
PLUGIN_VERSION = "0.1"
PLUGIN_API_VERSIONS = ["2.10"]
PLUGIN_LICENSE = "GPL-2.0"
PLUGIN_LICENSE_URL = "https://www.gnu.org/licenses/gpl-2.0.html"

from picard import log, config
from picard.plugin import PluginPriority
from picard.metadata import register_album_metadata_processor, register_track_metadata_processor
from picard.script import register_script_function
from picard.script.functions import func_swapprefix, func_delprefix
from picard.webservice.api_helpers import MBAPIHelper

from picard.ui.options import OptionsPage, register_options_page
from .ui_options_enhanced_titles import Ui_EnhancedTitlesOptions

from functools import partial
import re

# Options.
KEEP_ALLCAPS = "et_keep_allcaps"
ENABLE_TAGGING = "et_enable_tagging"
CHECK_ALBUM = "et_check_album_aliases"
CHECK_TRACK = "et_check_track_aliases"

# ISO 639-3 language codes.
ADDED_LANGUAGES = {"eng", "spa", "ita", "fra", "deu", "por"}
phw marked this conversation as resolved.
Show resolved Hide resolved

_articles = {
"eng" : ["the", "a", "an"],
zas marked this conversation as resolved.
Show resolved Hide resolved
"spa" : ["el", "los", "la", "las", "lo", "un", "unos", "una", "unas"],
"ita" : ["il", "l'", "la", "i", "gli", "le", "un", "uno", "una", "un'"],
"fra" : ["le", "la", "les", "un", "une", "des", "l'"],
"deu" : ["der", "den", "die", "das", "dem", "des", "den"],
"por" : ["o", "os", "a", "as", "um", "uns", "uma", "umas"]
}

# Prepositions and conjunctions with 3 letters or less.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might also be helpful say that these are words which aren't title-cased.

_other_minor_words = {
phw marked this conversation as resolved.
Show resolved Hide resolved
"eng" : ["so", "yet", "as", "at", "by", "for", "in", "of", "off", "on", "per",
"to", "up", "via", "and", "as", "but", "for", "if", "nor", "or"],
"spa" : ["mas", "que", "que", "en", "con", "por", "de", "y", "e", "o", "u", "si", "ni"],
"ita" : ["di", "a", "da", "in", "con", "su", "per", "tra", "fra", "e", "o", "ma", "se"],
"fra" : ["à", "de", "en", "par", "sur", "et", "ou", "que", "si"],
"deu" : ["bis", "für", "um", "an", "auf", "in", "vor", "aus", "bei", "mit",
"von", "zu", "la", "so", "daß", "als", "ob", "ehe"],
"por" : ["dem", "em", "por", "ao", "à", "aos", "às", "do", "da", "dos", "das",
"no", "na", "nos", "nas", "num", "dum", "e", "mas", "até", "em", "ou",
"que", "se", "por"]
}


class ReleaseGroupHelper(MBAPIHelper):
"""API Helper to retreive release group information.
zas marked this conversation as resolved.
Show resolved Hide resolved
"""

Check notice on line 83 in plugins/enhanced_titles/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/enhanced_titles/__init__.py#L83

Trailing whitespace
def get_release_group_by_id(self, release_id, handler, inc = None):
"""Gets the information for a release group.
"""
return self._get_by_id("release-group", release_id, handler, inc)


class SortTagger:
"""Sets the titlesort and albumsort tags.

First, it checks if there is already a sort name available in one of the
aliases. If it does not find any, it swaps the prefix if there is one in
the title.
"""

def _select_alias(self, aliases, name):
"""Selects the first alias where the names match and the sort name is
different.

Args:
aliases (list): One dictionary for each alias available.
name (str): Title of the album/track.

Returns:
(str): The sort name of the first useful alias it finds. None if
none are found.

For example, "The Beatles" has alias "Le Double Blanc" with sort name
"Double Blanc, Le", so it's not considered. But it also has alias
"The Beatles" with sort name "Beatles, The", so this one is chosen.
Another example, "The Continuing Story of Bungalow Bill" has an alias
with the sort name equal to the title, this is not considered because
it makes more sense to swap the prefix.
"""
for alias in aliases:
sortname = alias["sort-name"]
if (alias["name"].casefold() == name.casefold() and

Check notice on line 119 in plugins/enhanced_titles/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/enhanced_titles/__init__.py#L119

Trailing whitespace
not sortname.casefold() == name.casefold()):
log.info("Enhanced Titles: sort name found for \"" + name + "\", \"" + sortname + "\".")
zas marked this conversation as resolved.
Show resolved Hide resolved
return sortname
log.info("Enhanced Titles: no proper sort name found for \"" + name + "\".")
zas marked this conversation as resolved.
Show resolved Hide resolved
return None

def _response_handler(self, document, reply, error, metadata = None, field = None):
"""Handles the response from MusicBrainz.

Args:
metadata (MetaData): The object that needs to be updated.
field (str): Either "title" or "album", depending on what is being
updated.
"""
sortname = ""
zas marked this conversation as resolved.
Show resolved Hide resolved
try:
if document:
if error:
log.error("Enhanced Titles: information retrieval error.")
if document["aliases"]:
sortname = self._select_alias(document["aliases"], metadata[field])
else:
log.info("Enhanced Titles: no aliases found for \"" + metadata[field] + "\".")
finally:
if sortname:
sortfield = field + "sort"
metadata[sortfield] = sortname
else:
self._swapprefix(metadata, field)

def _swapprefix(self, metadata, field):
Copy link
Contributor

@Sophist-UK Sophist-UK Jan 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would IMO be better of as a function taking the field value and returning a sorted value, leaving the caller to get the unsorted value and store the sorted value i.e.

    def _swapprefix(self, field):
        ....
        return func_swapprefix(None, metadata[field], *prefixes)

    metadata["titlesort"] = self._swapprefix(metadata["title"])

Also, using return enables early return improved readability avoiding multiple nested if/else i.e.

if something:
    ...
    return first_something

if something_else:
    return second_something

return third something

"""Swaps the prefix of the title based on the album/track language."

If no language information is found, then it uses all languages available.
Otherwise, if none of the languages are available, it just copies the title.
Otherwise it uses exclusively the languages that are also available.
"""
sortfield = field + "sort"
languages = [metadata["language"], metadata["_releaselanguage"]]
languages = [language for language in languages if language]
if not languages:
metadata[sortfield] = func_swapprefix(None, metadata[field], *_create_prefixes_list())
else:
languages = [language for language in languages if language in ADDED_LANGUAGES]
if not languages:
metadata[sortfield] = metadata[field]
else:
metadata[sortfield] = func_swapprefix(None, metadata[field], *_create_prefixes_list(languages))

def set_track_titlesort(self, album, metadata, track, release):
"""Sets the track's titlesort field.
"""
if config.setting[ENABLE_TAGGING]:
handler = partial(self._response_handler, metadata = metadata, field = "title")
if config.setting[CHECK_TRACK]:
MBAPIHelper(album.tagger.webservice).get_track_by_id(
metadata["musicbrainz_recordingid"],

Check notice on line 176 in plugins/enhanced_titles/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/enhanced_titles/__init__.py#L176

Trailing whitespace
handler,

Check notice on line 177 in plugins/enhanced_titles/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/enhanced_titles/__init__.py#L177

Trailing whitespace
inc = ["aliases"]
)
else:
self._swapprefix(metadata, "title")

def set_album_titlesort(self, album, metadata, release):
"""Sets the album's albumsort field.
"""
if config.setting[ENABLE_TAGGING]:
handler = partial(self._response_handler, metadata = metadata, field = "album")
if config.setting[CHECK_ALBUM]:
ReleaseGroupHelper(album.tagger.webservice).get_release_group_by_id(
metadata["musicbrainz_releasegroupid"],

Check notice on line 190 in plugins/enhanced_titles/__init__.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

plugins/enhanced_titles/__init__.py#L190

Trailing whitespace
handler,
inc = ["aliases"]
)
else:
self._swapprefix(metadata, "album")


def _create_prefixes_list(languages = None, is_title = False):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, this is coded incorrectly. This function is called each and every time a script function is called and simply repeats the exact same code each time.

IMO the set of words for all languages should be calculated once, at load time rather than each time a script function is called. Personally I would store it in the same dict as the separate languages with an empty string as the key.

"""Creates a list of all the prefixes or minor words for all the given languages.

Args:
languages (list): All the languages to be considered.
is_title (bool): If true, only articles are included, with a lower case
and a capitalized copy for each. This is because the
swapprefix and delprefix functions are case sensitive.
If false, also prepositions and conjunctions are included,
all in lowercase.

Returns:
(set): The set of prefixes or minor words.

The available languages are saved with ISO 639-3 codes.
"""
prefixes = set()
if not languages:
languages = ADDED_LANGUAGES
else:
languages = [lang.lower()[:3] for lang in languages]
languages = [lang for lang in languages if lang in ADDED_LANGUAGES]
for language in languages:
prefixes.update(_articles[language])
if is_title:
prefixes.update(_other_minor_words[language])
else:
prefixes.update([article.capitalize() for article in _articles[language]])
return prefixes

def _title_case(text, lower_case_words):
"""Returns the text in titlecase.

If a word has an apostrophe and the segment to its left is an article,
it capitalizes only the word on the right. Otherwise it capitalizes
only the word on the left.
For example, "let's groove" becomes "Let's Groove", but "voglio l'anima"
becomes "Voglio l'Anima".
"""
new_text = []
words = re.split(r"([\w']+)", text.strip().lower().replace("’", "'"))
words = [word for word in words if word]
for word in words:
if "'" in word: # Apply the rule described above
split = word.split("'")
if split[0] + "'" in lower_case_words:
split[1] = split[1].capitalize()
else:
split[0] = split[0].capitalize()
word = "'".join(split)
elif not word in lower_case_words:
word = word.capitalize()
new_text.append(word)
if new_text:
new_text[0] = new_text[0].capitalize()
return "".join(new_text)
else:
return ""

swapprefix_lang_documentation = N_(
"""`$swapprefix_lang(text,language1,language2,...)`

Moves the prefix to the end of the text. It uses a list prefixes
taken from the specified languages.
Multiple languages can be added as seperate parameters.
If none are provided, it uses all the available ones.
""")
def swapprefix_lang(parser, text, *languages):
return func_swapprefix(parser, text, *_create_prefixes_list(languages)) if text else ""

delprefix_lang_documentation = N_(
"""`$delprefix_lang(text,language1,language2,...)`

Deletes the prefix from the text. It uses a list prefixes
taken from the specified languages.
Multiple languages can be added as seperate parameters.
If none are provided, it uses all the available ones.
""")
def delprefix_lang(parser, text, *languages):
return func_delprefix(parser, text, *_create_prefixes_list(languages)) if text else ""

title_lang_documentation = N_(
"""`$title_lang(text,language1,language2,...)`

Makes the text title case based on the minor words of the specified languages.
Multiple languages can be added as seperate parameters.
If none are provided, it uses all the available ones.
""")
def title_lang(parser, text, *languages):
if text.upper() == text and config.setting[KEEP_ALLCAPS]:
return text
return _title_case(text, _create_prefixes_list(languages, True)) if text else ""


class EnhancedTitlesOptions(OptionsPage):
"""Options page found under the "plugins" page.
"""

NAME = "enhanced_titles"
TITLE = "Enhanced Titles"
PARENT = "plugins"

options = [
config.BoolOption("setting", KEEP_ALLCAPS, False),
config.BoolOption("setting", ENABLE_TAGGING, True),
config.BoolOption("setting", CHECK_ALBUM, True),
config.BoolOption("setting", CHECK_TRACK, False),
]

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

def load(self):
self.ui.check_allcaps.setChecked(config.setting[KEEP_ALLCAPS])
self.ui.check_tagging.setChecked(config.setting[ENABLE_TAGGING])
self.ui.check_album_aliases.setChecked(config.setting[CHECK_ALBUM])
self.ui.check_track_aliases.setChecked(config.setting[CHECK_TRACK])

def save(self):
config.setting[KEEP_ALLCAPS] = self.ui.check_allcaps.isChecked()
config.setting[ENABLE_TAGGING] = self.ui.check_tagging.isChecked()
config.setting[CHECK_ALBUM] = self.ui.check_album_aliases.isChecked()
config.setting[CHECK_TRACK] = self.ui.check_track_aliases.isChecked()


sort_tagger = SortTagger()
register_track_metadata_processor(sort_tagger.set_track_titlesort, priority = PluginPriority.LOW)
register_album_metadata_processor(sort_tagger.set_album_titlesort, priority = PluginPriority.LOW)
register_script_function(swapprefix_lang, check_argcount = False, documentation = swapprefix_lang_documentation)
register_script_function(delprefix_lang, check_argcount = False, documentation = delprefix_lang_documentation)
register_script_function(title_lang, check_argcount = False, documentation = title_lang_documentation)
register_options_page(EnhancedTitlesOptions)
Loading