Source code for audiorename.meta
"""Extend the class ``MediaFile`` of the package ``phrydy``."""
import re
from typing import Any, Dict, List, Optional, Tuple
from phrydy import MediaFileExtended
from tmep import Functions
import audiorename.musicbrainz as musicbrainz
Diff = List[Tuple[str, Optional[str], Optional[str]]]
PerformerRaw = List[List[str]]
[docs]
def compare_dicts(first: Dict[str, str], second: Dict[str, str]) -> Diff:
"""Compare two dictionaries for differenes.
:param first: First dictionary to diff.
:param second: Second dicationary to diff.
:return: As list of key entries whose values differ.
"""
diff: Diff = []
for key, _ in sorted(first.items()):
if key not in second:
diff.append((key, first[key], None))
for key, _ in sorted(second.items()):
if key not in first:
diff.append((key, None, second[key]))
all_keys: set[str] = set()
for key, _ in first.items():
all_keys.add(key)
for key, _ in second.items():
all_keys.add(key)
for key in sorted(all_keys):
if key in first and key in second and first[key] != second[key]:
diff.append((key, first[key], second[key]))
return diff
[docs]
class Meta(MediaFileExtended):
[docs]
def __init__(self, path: str, shell_friendly: bool = False):
super(Meta, self).__init__(path, False)
self.shell_friendly = shell_friendly
###############################################################################
# Public methods
###############################################################################
[docs]
def export_dict(self, sanitize: bool = True) -> Dict[str, str]:
"""
Export all fields into a dictionary.
:param sanitize: Set the parameter to true to trigger the sanitize
function.
"""
out: Dict[str, str] = {}
for field in self.fields_sorted():
value = getattr(self, field)
if value:
if sanitize:
out[field] = self._sanitize(str(value))
else:
out[field] = value
return out
def enrich_metadata(self) -> None:
musicbrainz.set_useragent()
if self.mb_trackid:
recording = musicbrainz.query("recording", self.mb_trackid)
else:
print("No music brainz track id found.")
return
release = None
if self.mb_albumid:
release = musicbrainz.query("release", self.mb_albumid)
if release and "release-group" in release:
release_group = release["release-group"]
types: List[str] = []
if "type" in release_group:
types.append(release_group["type"])
if "primary-type" in release_group:
types.append(release_group["primary-type"])
if "secondary-type-list" in release_group:
types = types + release_group["secondary-type-list"]
types = self._uniquify_list(types)
self.releasegroup_types = "/".join(types).lower()
work_id = ""
if self.mb_workid:
work_id = self.mb_workid
elif recording:
try:
work_id = recording["work-relation-list"][0]["work"]["id"]
except KeyError:
pass
if work_id:
work_hierarchy = musicbrainz.query_works_recursively(work_id, [])
if work_hierarchy:
work_hierarchy.reverse()
work_bottom = work_hierarchy[-1]
if "artist-relation-list" in work_bottom:
for artist in work_bottom["artist-relation-list"]:
if (
artist["direction"] == "backward"
and artist["type"] == "composer"
):
self.composer = artist["artist"]["name"]
self.composer_sort = artist["artist"]["sort-name"]
break
self.mb_workid = work_bottom["id"]
self.work = work_bottom["title"]
wh_titles: List[str] = []
wh_ids: List[str] = []
for work in work_hierarchy:
wh_titles.append(work["title"])
wh_ids.append(work["id"])
self.work_hierarchy = " -> ".join(wh_titles)
self.mb_workhierarchy_ids = "/".join(wh_ids)
[docs]
def remap_classical(self) -> None:
"""Remap some fields to fit better for classical music:
``composer`` becomes ``artist``, ``work`` becomes ``album``, from the
``title`` the work prefix is removed (``Symphonie No. 9: I. Allegro``
-> ``I. Allegro``) and ``track`` becomes the movement number. All
overwritten fields are safed in the ``comments`` field. No combined
properties (like ``ar_combined_composer``) are used and therefore some
code duplications are done on purpose to avoid circular endless loops.
"""
safe: List[List[str]] = []
if self.title:
safe.append(["title", self.title])
self.title = re.sub(r"^[^:]*: ?", "", self.title)
roman = re.findall(r"^([IVXLCDM]*)\.", self.title)
if roman:
safe.append(["track", str(self.track)])
self.track = self._roman_to_int(roman[0])
if self.composer:
safe.append(["artist", self.artist])
self.artist = self.composer
if self.ar_combined_work_top:
safe.append(["album", self.album])
self.ar_performer_short
album = self.ar_combined_work_top
if self.ar_performer_short:
album += " (" + self.ar_performer_short + ")"
self.album = album
if safe:
comments = "Original metadata: "
for safed in safe:
comments = comments + str(safed[0]) + ": " + str(safed[1]) + "; "
self.comments = comments
###############################################################################
# Class methods
###############################################################################
@classmethod
def fields_phrydy(cls):
for field in sorted(MediaFileExtended.readable_fields()):
yield field
@classmethod
def fields_audiorename(cls):
for prop, _ in sorted(cls.__dict__.items()):
if isinstance(getattr(cls, prop), property):
if isinstance(prop, bytes):
# On Python 2, class field names are bytes. This method
# produces text strings.
yield prop.decode("utf8", "ignore")
else:
yield prop
[docs]
@classmethod
def fields(cls):
for field in cls.fields_phrydy():
yield field
for field in cls.fields_audiorename():
yield field
@classmethod
def fields_sorted(cls):
for field in sorted(cls.fields()):
yield field
###############################################################################
# Static methods
###############################################################################
@staticmethod
def _find_initials(value: str) -> str:
"""
Find the first character of a string.
:param str value: A string to extract the initials.
:return: A single character in lowercase. The possible return values
are lowercase letters from the ASCII alphabet (``a-z``), the digit
``0`` and the underscore character (``_``).
"""
# To avoid ae -> a
value = Functions.tmpl_asciify(value)
# To avoid “!K7-Compilations” -> “!”
value = re.sub(r"^\W*", "", value)
initial = value[0:1].lower()
if re.match(r"\d", initial):
return "0"
if initial == "":
return "_"
return initial
@staticmethod
def _normalize_performer(ar_performer: List[str]) -> PerformerRaw:
"""
:param ar_performer: A list of raw ar_performer strings like
.. code-block:: python
['John Lennon (vocals)', 'Ringo Starr (drums)']
:return: A list
.. code-block:: python
[
['vocals', 'John Lennon'],
['drums', 'Ringo Starr'],
]
"""
out: PerformerRaw = []
for value in ar_performer:
value = value[:-1]
performers: List[str] = value.split(" (")
if len(performers) == 2:
out.append([performers[1], performers[0]])
return out
@staticmethod
def _roman_to_int(n: str) -> int:
numeral_map = tuple(
zip(
(1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1),
("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"),
)
)
i = result = 0
for integer, numeral in numeral_map:
while n[i : i + len(numeral)] == numeral:
result += integer
i += len(numeral)
return result
@staticmethod
def _sanitize(value: Any) -> str:
if isinstance(value, str) or isinstance(value, bytes):
value = Functions.tmpl_sanitize(str(value))
value = re.sub(r"\s{2,}", " ", str(value))
else:
value = ""
return value
@staticmethod
def _shorten_performer(
ar_performer: str,
length: int = 3,
separator: str = " ",
abbreviation: str = ".",
) -> str:
out = ""
count = 0
for s in ar_performer.split(" "):
if count < 3:
if len(s) > length:
part = s[:length] + abbreviation
else:
part = s
out = out + separator + part
count = count + 1
return out[len(separator) :]
@staticmethod
def _uniquify_list(sequence: List[Any]) -> List[Any]:
"""https://www.peterbe.com/plog/uniqifiers-benchmark"""
unique: List[Any] = []
for element in sequence:
if not unique.count(element):
unique.append(element)
return unique
###############################################################################
# Properties
###############################################################################
@property
def ar_classical_album(self) -> Optional[str]:
"""Uses:
* ``phrydy.mediafile.MediaFile.work``
Examples:
* ``Horn Concerto: I. Allegro`` → ``Horn Concerto``
* ``Die Meistersinger von Nürnberg``
"""
if self.work:
return re.sub(r":.*$", "", (str(self.work)))
return None
@property
def ar_combined_album(self) -> Optional[str]:
"""Uses:
* ``phrydy.mediafile.MediaFile.album``
Example:
* ``Just Friends (Disc 2)`` → ``Just Friends``
"""
if self.album:
return re.sub(r" ?\([dD]is[ck].*\)$", "", str(self.album))
return None
@property
def ar_initial_album(self) -> Optional[str]:
"""Uses:
* :class:`audiorename.meta.Meta.ar_combined_album`
Examples:
* ``Just Friends`` → ``j``
* ``Die Meistersinger von Nürnberg`` → ``d``
"""
if self.ar_combined_album:
return self._find_initials(self.ar_combined_album)
return None
@property
def ar_initial_artist(self) -> str:
"""Uses:
* :class:`audiorename.meta.Meta.ar_combined_artist_sort`
Examples:
* ``Just Friends`` → ``j``
* ``Die Meistersinger von Nürnberg`` → ``d``
"""
return self._find_initials(self.ar_combined_artist_sort)
@staticmethod
def __remove_feat_vs_second_artist(artist: str) -> str:
"""Give only the first artist, remove the second after ``feat.``,
``ft.`` or ``vs.``"""
return re.sub(r"\s+(feat|ft|vs)\.?\s.*", "", artist, flags=re.IGNORECASE)
@property
def ar_combined_artist(self) -> str:
"""Uses:
* ``phrydy.mediafile.MediaFile.albumartist``
* ``phrydy.mediafile.MediaFile.artist``
* ``phrydy.mediafile.MediaFile.albumartist_credit``
* ``phrydy.mediafile.MediaFile.artist_credit``
* ``phrydy.mediafile.MediaFile.albumartist_sort``
* ``phrydy.mediafile.MediaFile.artist_sort``
Removes the second artist after ``feat.``, ``ft.`` or ``vs.``.
"""
out: str
if self.albumartist:
out = self.albumartist
elif self.artist:
out = self.artist
elif self.albumartist_credit:
out = self.albumartist_credit
elif self.artist_credit:
out = self.artist_credit
# Same as aristsafe_sort
elif self.albumartist_sort:
out = self.albumartist_sort
elif self.artist_sort:
out = self.artist_sort
else:
out = "Unknown"
return Meta.__remove_feat_vs_second_artist(out)
@property
def ar_combined_artist_sort(self) -> str:
"""Uses:
* ``phrydy.mediafile.MediaFile.albumartist_sort``
* ``phrydy.mediafile.MediaFile.artist_sort``
* ``phrydy.mediafile.MediaFile.albumartist``
* ``phrydy.mediafile.MediaFile.artist``
* ``phrydy.mediafile.MediaFile.albumartist_credit``
* ``phrydy.mediafile.MediaFile.artist_credit``
Removes the second artist after ``feat.``, ``ft.`` or ``vs.``.
"""
out: str
if self.albumartist_sort:
out = self.albumartist_sort
elif self.artist_sort:
out = self.artist_sort
# Same as ar_combined_artist
elif self.albumartist:
out = self.albumartist
elif self.artist:
out = self.artist
elif self.albumartist_credit:
out = self.albumartist_credit
elif self.artist_credit:
out = self.artist_credit
else:
out = "Unknown"
out = Meta.__remove_feat_vs_second_artist(out)
if self.shell_friendly:
out = out.replace(", ", "_")
return out
@property
def ar_initial_composer(self) -> str:
"""Uses:
* :class:`audiorename.meta.Meta.ar_combined_composer`
"""
return self._find_initials(self.ar_combined_composer)
@property
def ar_combined_composer(self) -> str:
"""Uses:
* ``phrydy.mediafile.MediaFile.composer_sort``
* ``phrydy.mediafile.MediaFile.composer``
* :class:`audiorename.meta.Meta.ar_combined_artist`
"""
out: str = ""
if self.composer_sort:
out = self.composer_sort
elif self.composer:
out = self.composer
else:
out = self.ar_combined_artist
if self.shell_friendly:
out = out.replace(", ", "_")
# e. g. 'Mozart, Wolfgang Amadeus/Süßmeyer, Franz Xaver'
return re.sub(r" ?/.*", "", out)
@property
def ar_combined_disctrack(self) -> Optional[str]:
"""
Generate a combination of track and disc number, e. g.: ``1-04``,
``3-06``.
Uses:
* ``phrydy.mediafile.MediaFile.disctotal``
* ``phrydy.mediafile.MediaFile.disc``
* ``phrydy.mediafile.MediaFile.tracktotal``
* ``phrydy.mediafile.MediaFile.track``
"""
if not self.track:
return None
if self.disctotal and int(self.disctotal) > 99:
disk = str(self.disc).zfill(3)
elif self.disctotal and int(self.disctotal) > 9:
disk = str(self.disc).zfill(2)
else:
disk = str(self.disc)
if self.tracktotal and int(self.tracktotal) > 99:
track = str(self.track).zfill(3)
else:
track = str(self.track).zfill(2)
if self.disc and self.disctotal and int(self.disctotal) > 1:
out = disk + "-" + track
elif self.disc and not self.disctotal:
out = disk + "-" + track
else:
out = track
return out
@property
def ar_performer(self) -> str:
"""Uses:
* :class:`audiorename.meta.Meta.ar_performer_raw`
"""
out: str = ""
for ar_performer in self.ar_performer_raw:
out = out + ", " + ar_performer[1]
out = out[2:]
return out
@property
def ar_classical_performer(self) -> str:
"""http://musicbrainz.org/doc/Style/Classical/Release/Artist
Uses:
* :class:`audiorename.meta.Meta.ar_performer_short`
* ``phrydy.mediafile.MediaFile.albumartist``
"""
if len(self.ar_performer_short) > 0:
out = self.ar_performer_short
elif self.albumartist:
out = re.sub(r"^.*; ?", "", self.albumartist)
else:
out = ""
return out
@property
def ar_performer_raw(self) -> PerformerRaw:
"""Generate a unifed ar_performer list.
Picard doesn’t store ar_performer values in m4a, alac.m4a, wma, wav,
aiff.
:return: A list
.. code-block:: python
[
['conductor', 'Herbert von Karajan'],
['violin', 'Anne-Sophie Mutter'],
]
Uses:
* ``phrydy.mediafile.MediaFile.mgfile``
"""
out: PerformerRaw = []
if (
self.format == "FLAC" or self.format == "OGG"
) and "performer" in self.mgfile:
out = self._normalize_performer(self.mgfile["performer"])
if "conductor" in self.mgfile:
out.insert(0, ["conductor", self.mgfile["conductor"][0]])
elif self.format == "MP3":
# 4.2.2 TMCL Musician credits list
if "TMCL" in self.mgfile:
out = self.mgfile["TMCL"].people
# 4.2.2 TIPL Involved people list
# TIPL is used for producer
if "TIPL" in self.mgfile:
out = self.mgfile["TIPL"].people
# 4.2.2 TPE3 Conductor/ar_performer refinement
if len(out) > 0 and "conductor" not in out[0] and "TPE3" in self.mgfile:
out.insert(0, ["conductor", self.mgfile["TPE3"].text[0]])
else:
out = []
return self._uniquify_list(out)
@property
def ar_performer_short(self):
"""Uses:
* ``phrydy.mediafile.MediaFile.ar_performer_raw``
"""
out: List[str] = []
performers = self.ar_performer_raw
picked: PerformerRaw = []
for performer in performers:
if performer[0] == "conductor" or performer[0] == "orchestra":
picked.append(performer)
if len(picked) > 0:
performers = picked
for performer in performers:
if (
performer[0] == "producer"
or performer[0] == "executive producer"
or performer[0] == "balance engineer"
):
pass
elif (
performer[0] == "orchestra"
or performer[0] == "choir vocals"
or performer[0] == "string quartet"
):
out.append(
self._shorten_performer(performer[1], separator="", abbreviation="")
)
else:
out.append(performer[1].split(" ")[-1])
return ", ".join(out)
@property
def ar_combined_soundtrack(self):
if (
self.releasegroup_types and "soundtrack" in self.releasegroup_types.lower()
) or (self.albumtype and "soundtrack" in self.albumtype.lower()):
return True
else:
return False
@property
def ar_classical_title(self) -> Optional[str]:
"""Uses:
* ``phrydy.mediafile.MediaFile.title``
Example:
* ``Horn Concerto: I. Allegro``
"""
if self.title:
return re.sub(r"^[^:]*: ?", "", self.title)
return None
@property
def ar_classical_track(self) -> Optional[str]:
"""Uses:
* :class:`audiorename.meta.Meta.ar_classical_title`
* :class:`audiorename.meta.Meta.ar_combined_disctrack`
"""
roman = None
if self.ar_classical_title:
roman = re.findall(r"^([IVXLCDM]*)\.", self.ar_classical_title)
if roman:
return str(self._roman_to_int(roman[0])).zfill(2)
elif self.ar_combined_disctrack:
return self.ar_combined_disctrack
return None
@property
def ar_combined_work_top(self) -> Optional[str]:
"""Uses:
* ``phrydy.mediafile.MediaFile.work_hierarchy``
* ``phrydy.mediafile.MediaFile.work``
"""
if self.work_hierarchy:
return self.work_hierarchy.split(" -> ")[0]
elif self.ar_classical_album:
return self.ar_classical_album
return None
@property
def ar_combined_year(self):
"""Uses:
* ``phrydy.mediafile.MediaFile.original_year``
* ``phrydy.mediafile.MediaFile.year``
"""
if self.original_year:
return self.original_year
elif self.year:
return self.year