Source code for audiorename.meta
"""Extend the class ``MediaFile`` of the package ``phrydy``.
.. code-block:: Python
import json
print(json.dumps(result,indent=2))
``get_recording_by_id`` with ``work-rels``
soundtrack/Pulp-Fiction/01.mp3
.. code-block:: JSON
{
"recording": {
"length": "149000",
"id": "0480672d-4d88-4824-a06b-917ff408eabe",
"title": "Pumpkin and Honey Bunny ..."
}
}
classical/Mozart_Horn-concertos/01.mp3
.. code-block:: JSON
{
"recording": {
"length": "286826",
"work-relation-list": [
{
"type-id": "a3005666-a872-32c3-ad06-98af558e99b0",
"begin": "1987-03",
"end": "1987-03",
"target": "21fe0bf0-a040-387c-a39d-369d53c251fe",
"ended": "true",
"work": {
"id": "21fe0bf0-a040-387c-a39d-369d53c251fe",
"language": "zxx",
"title": "Concerto [...] KV 412: I. Allegro"
},
"type": "performance"
}
],
"id": "7886ad6c-11af-435b-8ec3-bca5711f7728",
"title": "Konzert f\u00fcr [...] K. 386b/514: I. Allegro"
}
}
``get_work_by_id`` with ``work-rels``
.. code-block:: JSON
{
"work": {
"work-relation-list": [
{
"type-id": "ca8d3642-ce5f-49f8-91f2-125d72524e6a",
"direction": "backward",
"target": "5adc213f-700a-4435-9e95-831ed720f348",
"ordering-key": "3",
"work": {
"id": "5adc213f-700a-4435-9e95-831ed720f348",
"language": "deu",
"title": "Die Zauberfl\u00f6te, K. 620: Akt I"
},
"type": "parts"
},
{
"type-id": "51975ed8-bbfa-486b-9f28-5947f4370299",
"work": {
"disambiguation": "for piano, arr. Matthias",
"id": "798f4c25-0ab3-44ba-81b6-3d856aedf82a",
"language": "zxx",
"title": "Die Zauberfl\u00f6te, K. 620: Aria ..."
},
"type": "arrangement",
"target": "798f4c25-0ab3-44ba-81b6-3d856aedf82a"
}
],
"type": "Aria",
"id": "eafec51f-47c5-3c66-8c36-a524246c85f8",
"language": "deu",
"title": "Die Zauberfl\u00f6te: Act I, Scene II. No. 2 Aria ..",
"artist-relation-list": [
{
"type-id": "7474ab81-486f-40b5-8685-3a4f8ea624cb",
"direction": "backward",
"type": "librettist",
"target": "86104c7c-cda4-4798-a4ab-104318c7ae9c",
"artist": {
"sort-name": "Schikaneder, Emanuel",
"id": "86104c7c-cda4-4798-a4ab-104318c7ae9c",
"name": "Emanuel Schikaneder"
}
},
{
"begin": "1791",
"end": "1791",
"target": "b972f589-fb0e-474e-b64a-803b0364fa75",
"artist": {
"sort-name": "Mozart, Wolfgang Amadeus",
"disambiguation": "classical composer",
"id": "b972f589-fb0e-474e-b64a-803b0364fa75",
"name": "Wolfgang Amadeus Mozart"
},
"direction": "backward",
"type-id": "d59d99ea-23d4-4a80-b066-edca32ee158f",
"ended": "true",
"type": "composer"
}
]
}
}
.. code-block:: JSON
{
"work": {
"work-relation-list": [
{
"type-id": "c1dca2cd-194c-36dd-93f8-6a359167e992",
"direction": "backward",
"work": {
"id": "70e53569-258c-463d-9505-5b69dcbf374a",
"title": "Can\u2019t Stop the Classics, Part 2"
},
"type": "medley",
"target": "70e53569-258c-463d-9505-5b69dcbf374a"
},
{
"type-id": "ca8d3642-ce5f-49f8-91f2-125d72524e6a",
"direction": "backward",
"target": "73663bd3-392f-45a7-b4ff-e75c01f5926a",
"ordering-key": "1",
"work": {
"id": "73663bd3-392f-45a7-b4ff-e75c01f5926a",
"language": "deu",
"title": "Die Meistersinger von N\u00fcrnberg, WWV 96: Akt I"
},
"type": "parts"
}
]
}
}
``get_release_by_id`` with ``release-groups``
soundtrack/Pulp-Fiction/01.mp3
.. code-block:: JSON
{
"release": {
"status": "Bootleg",
"release-event-count": 1,
"title": "Pulp Fiction",
"country": "US",
"cover-art-archive": {
"count": "1",
"front": "true",
"back": "false",
"artwork": "true"
},
"release-event-list": [
{
"date": "2005-12-01",
"area": {
"sort-name": "United States",
"iso-3166-1-code-list": [
"US"
],
"id": "489ce91b-6658-3307-9877-795b68554c98",
"name": "United States"
}
}
],
"release-group": {
"first-release-date": "1994-09-27",
"secondary-type-list": [
"Compilation",
"Soundtrack"
],
"primary-type": "Album",
"title": "Pulp Fiction: Music From the Motion Picture",
"type": "Soundtrack",
"id": "1703cd63-9401-33c0-87c6-50c4ba2e0ba8"
},
"text-representation": {
"language": "eng",
"script": "Latn"
},
"date": "2005-12-01",
"quality": "normal",
"id": "ab81edcb-9525-47cd-8247-db4fa969f525",
"asin": "B000002OTL"
}
}
classical/Mozart_Horn-concertos/01.mp3
.. code-block:: JSON
{
"release": {
"status": "Official",
"release-event-count": 1,
"title": "4 Hornkonzerte (Concertos for Horn and Orchestra)",
"country": "DE",
"barcode": "028942781429",
"cover-art-archive": {
"count": "0",
"front": "false",
"back": "false",
"artwork": "false"
},
"release-event-list": [
{
"date": "1988",
"area": {
"sort-name": "Germany",
"iso-3166-1-code-list": [
"DE"
],
"id": "85752fda-13c4-31a3-bee5-0e5cb1f51dad",
"name": "Germany"
}
}
],
"release-group": {
"first-release-date": "1988",
"title": "4 Hornkonzerte (Concertos for Horn and Orchestra)",
"type": "Album",
"id": "e1fa28f0-e56e-395b-82d3-a8de54e8c627",
"primary-type": "Album"
},
"text-representation": {
"language": "deu",
"script": "Latn"
},
"date": "1988",
"quality": "normal",
"id": "5ed650c5-0f72-4b79-80a7-c458c869f53e",
"asin": "B00000E4FA"
}
}
"""
from audiorename._version import get_versions
from phrydy import MediaFileExtended
from tmep import Functions
import musicbrainzngs as mbrainz
import re
import typing
[docs]def set_useragent() -> None:
mbrainz.set_useragent(
'audiorename',
get_versions()['version'],
'https://github.com/Josef-Friedrich/audiorename',
)
[docs]def query_mbrainz(
mb_type: typing.Literal['recording', 'work', 'release'],
mb_id: str) -> typing.Union[typing.Dict[str, typing.Any], None]:
method = 'get_' + mb_type + '_by_id'
query = getattr(mbrainz, method)
if mb_type == 'recording' or mb_type == 'work':
mb_includes = ['work-rels']
elif mb_type == 'release':
mb_includes = ['release-groups']
else:
mb_includes = []
if mb_type == 'work':
mb_includes.append('artist-rels')
try:
result = query(mb_id, includes=mb_includes)
return result[mb_type]
except mbrainz.ResponseError as err:
if err.cause and err.cause.code == 404:
print('Item of type “' + mb_type + '” with the ID '
'“' + mb_id + '” not found.')
else:
print("Received bad response from the MusicBrainz server.")
[docs]def query_works_recursively(work_id: str, works=[]):
work = query_mbrainz('work', work_id)
if not work:
return works
works.append(work)
parent_work = False
if 'work-relation-list' in work:
for relation in work['work-relation-list']:
if 'direction' in relation and \
relation['direction'] == 'backward' and \
relation['type'] == 'parts':
parent_work = relation
break
if parent_work:
query_works_recursively(parent_work['work']['id'], works)
return works
[docs]def compare_dicts(first: typing.Dict[str, str],
second: typing.Dict[str, str]) -> typing.List[str]:
"""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 = []
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()
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, 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) -> typing.Dict[str, str]:
"""
Export all fields into a dictionary.
:param sanitize: Set the parameter to true to trigger the sanitize
function.
"""
out = {}
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
[docs] def enrich_metadata(self) -> None:
set_useragent()
if self.mb_trackid:
recording = query_mbrainz('recording', self.mb_trackid)
else:
print('No music brainz track id found.')
return
release = None
if self.mb_albumid:
release = query_mbrainz('release', self.mb_albumid)
if release and 'release-group' in release:
release_group = release['release-group']
types = []
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._unify_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 = 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 = []
wh_ids = []
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. For example
``composer`` becomes ``artist`` and ``work`` becomes ``album``.
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 = []
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', self.track])
self.track = str(self._roman_to_int(roman[0])).zfill(2)
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
###############################################################################
[docs] @classmethod
def fields_phrydy(cls):
for field in sorted(MediaFileExtended.readable_fields()):
yield field
[docs] @classmethod
def fields_audiorename(cls):
for prop, descriptor 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
###############################################################################
# Static methods
###############################################################################
[docs] @staticmethod
def _initials(value: str) -> str:
"""
:param str value: A string to extract the initials.
"""
return value[0:1].lower()
[docs] @staticmethod
def _normalize_performer(
ar_performer: typing.List[str]) -> typing.List[typing.List[str]]:
"""
:param list 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 = []
if isinstance(ar_performer, list):
for value in ar_performer:
value = value[:-1]
value = value.split(' (')
if isinstance(value, list) and len(value) == 2:
out.append([value[1], value[0]])
return out
else:
return []
[docs] @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
[docs] @staticmethod
def _sanitize(value) -> str:
if isinstance(value, str) or isinstance(value, bytes):
value = Functions.tmpl_sanitize(value)
value = re.sub(r'\s{2,}', ' ', str(value))
else:
value = ''
return value
[docs] @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):]
[docs] @staticmethod
def _unify_list(seq):
"""https://www.peterbe.com/plog/uniqifiers-benchmark"""
noDupes = []
[noDupes.append(i) for i in seq if not noDupes.count(i)]
return noDupes
###############################################################################
# Properties
###############################################################################
@property
def ar_classical_album(self) -> typing.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)))
@property
def ar_combined_album(self) -> typing.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))
@property
def ar_initial_album(self) -> typing.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._initials(self.ar_combined_album)
@property
def ar_initial_artist(self):
"""Uses:
* :class:`audiorename.meta.Meta.ar_combined_artist_sort`
Examples:
* ``Just Friends`` → ``j``
* ``Die Meistersinger von Nürnberg`` → ``d``
"""
return self._initials(self.ar_combined_artist_sort)
@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``
"""
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 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``
"""
out = ''
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'
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._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`
"""
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) -> typing.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
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):
"""Uses:
* :class:`audiorename.meta.Meta.ar_performer_raw`
"""
out = ''
for ar_performer in self.ar_performer_raw:
out = out + ', ' + ar_performer[1]
out = out[2:]
return out
@property
def ar_classical_performer(self):
"""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):
"""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 = []
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
elif '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._unify_list(out)
@property
def ar_performer_short(self):
"""Uses:
* ``phrydy.mediafile.MediaFile.ar_performer_raw``
"""
out = []
performers = self.ar_performer_raw
picked = []
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) -> typing.Optional[str]:
"""Uses:
* ``phrydy.mediafile.MediaFile.title``
Example:
* ``Horn Concerto: I. Allegro``
"""
if self.title:
return re.sub(r'^[^:]*: ?', '', self.title)
@property
def ar_classical_track(self) -> typing.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
@property
def ar_combined_work_top(self) -> typing.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
@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