Skip to content

Instantly share code, notes, and snippets.

@mtfurlan
Last active May 18, 2025 20:29
Show Gist options
  • Save mtfurlan/8420e9834e979e9eca81692922747c61 to your computer and use it in GitHub Desktop.
Save mtfurlan/8420e9834e979e9eca81692922747c61 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
# StupidifyQuotes: turn “typographical quotes” into "typewriter quote"
# The Unicode Consortium prefers "smart quotes" and musicbrainz and beets are
# following this.
# I Disagree
# also endash(U+2013) to hyphen(U+2D)
# also double prime to "
# this will modify incoming metadata, and also provide a stupidify-quotes
# command to edit existing items
# https://www.unicode.org/charts/PDF/U0000.pdf
# for double quote " U+0022, unicode 16.0 says
# > preferred characters in English for paired quotation marks are
# > 201C “ & 201D ”
# for single quote ' U+0027, unicode 16.0 says
# > 2019 ’ is preferred for apostrophe
# > preferred characters in English for paired quotation marks are
# > 2018 ‘ & 2019 ’
# https://beets.readthedocs.io/en/stable/dev/plugins.html
# WTFPL
import re
import collections.abc
replacementMap = {
# left/right single quotation ‘’
"\u2018": "'",
"\u2019": "'",
# left/right double quotation “”
"\u201C": "\"",
"\u201D": "\"",
# en dash –
"\u2013": "-",
# double prime ″
"\u2033": "\"",
}
regexStr = f"[{''.join(list(replacementMap.keys()))}]"
quoteRegex = re.compile(regexStr)
def doReplace(inputString: str) -> str:
return quoteRegex.sub(lambda match: replacementMap[match.group(0)], inputString)
# if run as main, do some unit testing
# do all the beets stuff below so we don't import that if testing
if __name__ == "__main__":
import sys
print(f"regexStr is {regexStr}")
examples = {
"9–5ers Anthem": "9-5ers Anthem",
"The Truth 7″": "The Truth 7\"",
"I’ll Believe in Anything": "I'll Believe in Anything",
}
failed = False
for input, expected in examples.items():
actual = doReplace(input)
if(actual != expected):
print(f"{input} => {expected} returned {actual}")
failed=True
if failed:
sys.exit(1)
print("looks good")
sys.exit(0)
from beets import plugins, ui
from beets.autotag.hooks import AlbumInfo, TrackInfo, AttrDict
from beets.library import Album, Item, LibModel
def isList(item):
return isinstance(item, collections.abc.Sequence) and not isinstance(item, (str))
def replaceInMetadataObject(item: AttrDict | list) -> None:
"""
recursively modify fields inside AttrDict which contains strings and lists of string and lists of AttrDict
"""
indicies = []
if isinstance(item, AttrDict):
indicies = item.keys()
elif isList(item): # TODO: I think isinstance(item, list) works here safely try that
indicies = range(len(item))
else:
print(f"unknown type {type(item)}")
print(item)
import pdb; pdb.set_trace()
raise Exception(f"StupidifyQuotes: unknown type {type(item)} on item")
for k in indicies:
if isinstance(item[k], str):
item[k] = doReplace(item[k])
elif isList(item[k]):
replaceInMetadataObject(item[k]);
elif isinstance(item[k], AttrDict):
replaceInMetadataObject(item[k]);
elif isinstance(item[k], float | int | None):
pass
else:
print(f"unknown type {item[k]}")
print(item[k])
import pdb; pdb.set_trace()
raise Exception(f"StupidifyQuotes: unknown type {type(item[k])} on subitem")
class StupidifyQuotes(plugins.BeetsPlugin):
def __init__(self):
super().__init__()
self.register_listener('albuminfo_received', lambda info: replaceInMetadataObject(info))
def commands(self):
stupidify_command = ui.Subcommand("stupidify-quotes", help="fix quotes in db")
stupidify_command.func = self._stupidfy_command
return [stupidify_command]
def _stupidfy_command(self, lib, opts, args):
"""The CLI command function for the `beet stupidify-quotes` command."""
# modify albums then modify tracks
if len(args) > 0:
query = args[0]
else:
# TODO this doesn't work
query = '|'.join(list(replacementMap.keys()))
items = list(lib.albums(query)) + list(lib.items(query))
if not items:
ui.print_("Nothing to edit :)")
return
self._thingy(items)
print("TODO: actually edit these steal edit code for the confirm stuff")
def _thingy(self, items: list[LibModel]) -> None:
for item in items:
# TODO: steal edit code till we edit what we need
ui.print_(format(item))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment