2023-01-12 22:01:19 +00:00
|
|
|
|
from enum import Enum
|
2023-01-13 08:44:57 +00:00
|
|
|
|
from typing import List, Dict, Tuple
|
2023-01-14 13:41:40 +00:00
|
|
|
|
|
|
|
|
|
import dateutil.tz
|
2023-01-12 22:01:19 +00:00
|
|
|
|
from mutagen import id3
|
2023-01-13 13:37:15 +00:00
|
|
|
|
import datetime
|
2023-01-12 22:01:19 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Mapping(Enum):
|
|
|
|
|
"""
|
|
|
|
|
These frames belong to the id3 standart
|
2023-01-13 13:37:15 +00:00
|
|
|
|
https://web.archive.org/web/20220830091059/https://id3.org/id3v2.4.0-frames
|
|
|
|
|
https://id3lib.sourceforge.net/id3/id3v2com-00.html
|
|
|
|
|
https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html
|
2023-01-12 22:01:19 +00:00
|
|
|
|
"""
|
|
|
|
|
# Textframes
|
|
|
|
|
TITLE = "TIT2"
|
|
|
|
|
ISRC = "TSRC"
|
2023-02-25 21:16:32 +00:00
|
|
|
|
LENGTH = "TLEN" # in milliseconds
|
2023-01-12 22:01:19 +00:00
|
|
|
|
DATE = "TYER"
|
|
|
|
|
TRACKNUMBER = "TRCK"
|
|
|
|
|
TOTALTRACKS = "TRCK" # Stored in the same frame with TRACKNUMBER, separated by '/': e.g. '4/9'.
|
|
|
|
|
TITLESORTORDER = "TSOT"
|
|
|
|
|
ENCODING_SETTINGS = "TSSE"
|
|
|
|
|
SUBTITLE = "TIT3"
|
|
|
|
|
SET_SUBTITLE = "TSST"
|
|
|
|
|
RELEASE_DATE = "TDRL"
|
|
|
|
|
RECORDING_DATES = "TXXX"
|
|
|
|
|
PUBLISHER_URL = "WPUB"
|
|
|
|
|
PUBLISHER = "TPUB"
|
|
|
|
|
RATING = "POPM"
|
|
|
|
|
DISCNUMBER = "TPOS"
|
|
|
|
|
MOVEMENT_COUNT = "MVIN"
|
|
|
|
|
TOTALDISCS = "TPOS"
|
|
|
|
|
ORIGINAL_RELEASE_DATE = "TDOR"
|
|
|
|
|
ORIGINAL_ARTIST = "TOPE"
|
|
|
|
|
ORIGINAL_ALBUM = "TOAL"
|
|
|
|
|
MEDIA_TYPE = "TMED"
|
|
|
|
|
LYRICIST = "TEXT"
|
|
|
|
|
WRITER = "TEXT"
|
|
|
|
|
ARTIST = "TPE1"
|
2023-01-30 22:54:21 +00:00
|
|
|
|
LANGUAGE = "TLAN" # https://en.wikipedia.org/wiki/ISO_639-2
|
2023-01-12 22:01:19 +00:00
|
|
|
|
ITUNESCOMPILATION = "TCMP"
|
|
|
|
|
REMIXED_BY = "TPE4"
|
|
|
|
|
RADIO_STATION_OWNER = "TRSO"
|
|
|
|
|
RADIO_STATION = "TRSN"
|
|
|
|
|
INITIAL_KEY = "TKEY"
|
|
|
|
|
OWNER = "TOWN"
|
|
|
|
|
ENCODED_BY = "TENC"
|
|
|
|
|
COPYRIGHT = "TCOP"
|
|
|
|
|
GENRE = "TCON"
|
|
|
|
|
GROUPING = "TIT1"
|
|
|
|
|
CONDUCTOR = "TPE3"
|
|
|
|
|
COMPOSERSORTORDER = "TSOC"
|
|
|
|
|
COMPOSER = "TCOM"
|
|
|
|
|
BPM = "TBPM"
|
|
|
|
|
ALBUM_ARTIST = "TPE2"
|
|
|
|
|
BAND = "TPE2"
|
|
|
|
|
ARTISTSORTORDER = "TSOP"
|
|
|
|
|
ALBUM = "TALB"
|
|
|
|
|
ALBUMSORTORDER = "TSOA"
|
|
|
|
|
ALBUMARTISTSORTORDER = "TSO2"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
TAGGING_TIME = "TDTG"
|
2023-01-12 22:01:19 +00:00
|
|
|
|
|
|
|
|
|
SOURCE_WEBPAGE_URL = "WOAS"
|
|
|
|
|
FILE_WEBPAGE_URL = "WOAF"
|
|
|
|
|
INTERNET_RADIO_WEBPAGE_URL = "WORS"
|
|
|
|
|
ARTIST_WEBPAGE_URL = "WOAR"
|
|
|
|
|
COPYRIGHT_URL = "WCOP"
|
|
|
|
|
COMMERCIAL_INFORMATION_URL = "WCOM"
|
2023-01-13 11:05:44 +00:00
|
|
|
|
PAYMEMT_URL = "WPAY"
|
2023-01-12 22:01:19 +00:00
|
|
|
|
|
|
|
|
|
MOVEMENT_INDEX = "MVIN"
|
|
|
|
|
MOVEMENT_NAME = "MVNM"
|
|
|
|
|
|
|
|
|
|
UNSYNCED_LYRICS = "USLT"
|
|
|
|
|
COMMENT = "COMM"
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_text_instance(cls, key: str, value: str):
|
|
|
|
|
return id3.Frames[key](encoding=3, text=value)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_url_instance(cls, key: str, url: str):
|
|
|
|
|
return id3.Frames[key](encoding=3, url=url)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def get_mutagen_instance(cls, attribute, value):
|
|
|
|
|
key = attribute.value
|
|
|
|
|
|
|
|
|
|
if key[0] == 'T':
|
|
|
|
|
# a text fiel
|
|
|
|
|
return cls.get_text_instance(key, value)
|
|
|
|
|
if key[0] == "W":
|
|
|
|
|
# an url field
|
|
|
|
|
return cls.get_url_instance(key, value)
|
|
|
|
|
|
|
|
|
|
|
2023-01-16 11:26:56 +00:00
|
|
|
|
class ID3Timestamp:
|
2023-01-13 13:37:15 +00:00
|
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
year: int = None,
|
|
|
|
|
month: int = None,
|
|
|
|
|
day: int = None,
|
|
|
|
|
hour: int = None,
|
|
|
|
|
minute: int = None,
|
2023-01-16 11:26:56 +00:00
|
|
|
|
second: int = None
|
2023-01-13 13:37:15 +00:00
|
|
|
|
):
|
2023-01-16 11:26:56 +00:00
|
|
|
|
self.year = year
|
|
|
|
|
self.month = month
|
|
|
|
|
self.day = day
|
|
|
|
|
self.hour = hour
|
|
|
|
|
self.minute = minute
|
|
|
|
|
self.second = second
|
|
|
|
|
|
2023-01-13 13:37:15 +00:00
|
|
|
|
self.has_year = year is not None
|
|
|
|
|
self.has_month = month is not None
|
|
|
|
|
self.has_day = day is not None
|
|
|
|
|
self.has_hour = hour is not None
|
|
|
|
|
self.has_minute = minute is not None
|
|
|
|
|
self.has_second = second is not None
|
|
|
|
|
|
|
|
|
|
if not self.has_year:
|
|
|
|
|
year = 1
|
|
|
|
|
if not self.has_month:
|
|
|
|
|
month = 1
|
|
|
|
|
if not self.has_day:
|
|
|
|
|
day = 1
|
2023-01-16 11:26:56 +00:00
|
|
|
|
if not self.has_hour:
|
|
|
|
|
hour = 1
|
|
|
|
|
if not self.has_minute:
|
|
|
|
|
minute = 1
|
|
|
|
|
if not self.has_second:
|
|
|
|
|
second = 1
|
|
|
|
|
|
|
|
|
|
self.date_obj = datetime.datetime(
|
2023-01-13 13:37:15 +00:00
|
|
|
|
year=year,
|
|
|
|
|
month=month,
|
|
|
|
|
day=day,
|
|
|
|
|
hour=hour,
|
|
|
|
|
minute=minute,
|
2023-01-16 11:26:56 +00:00
|
|
|
|
second=second
|
2023-01-13 13:37:15 +00:00
|
|
|
|
)
|
|
|
|
|
|
2023-02-23 22:52:41 +00:00
|
|
|
|
def __lt__(self, other):
|
|
|
|
|
return self.date_obj < other.date_obj
|
|
|
|
|
|
|
|
|
|
def __le__(self, other):
|
|
|
|
|
return self.date_obj <= other.date_obj
|
|
|
|
|
|
|
|
|
|
def __gt__(self, other):
|
|
|
|
|
return self.date_obj > other.date_obj
|
|
|
|
|
|
|
|
|
|
def __ge__(self, other):
|
|
|
|
|
return self.date_obj >= other.date_obj
|
|
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
|
return self.date_obj == other.date_obj
|
|
|
|
|
|
2023-01-31 08:53:05 +00:00
|
|
|
|
def get_time_format(self) -> str:
|
2023-01-13 13:37:15 +00:00
|
|
|
|
"""
|
|
|
|
|
https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-structure.html
|
|
|
|
|
|
|
|
|
|
The timestamp fields are based on a subset of ISO 8601. When being as precise as possible the format of a
|
|
|
|
|
time string is
|
|
|
|
|
- yyyy-MM-ddTHH:mm:ss
|
|
|
|
|
- (year[%Y], “-”, month[%m], “-”, day[%d], “T”, hour (out of 24)[%H], ”:”, minutes[%M], ”:”, seconds[%S])
|
|
|
|
|
- %Y-%m-%dT%H:%M:%S
|
|
|
|
|
but the precision may be reduced by removing as many time indicators as wanted. Hence valid timestamps are
|
|
|
|
|
- yyyy
|
|
|
|
|
- yyyy-MM
|
|
|
|
|
- yyyy-MM-dd
|
|
|
|
|
- yyyy-MM-ddTHH
|
|
|
|
|
- yyyy-MM-ddTHH:mm
|
|
|
|
|
- yyyy-MM-ddTHH:mm:ss
|
|
|
|
|
All time stamps are UTC. For durations, use the slash character as described in 8601,
|
|
|
|
|
and for multiple non-contiguous dates, use multiple strings, if allowed by the frame definition.
|
|
|
|
|
|
|
|
|
|
:return timestamp: as timestamp in the format of the id3 time as above described
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
if self.has_year and self.has_month and self.has_day and self.has_hour and self.has_minute and self.has_second:
|
2023-01-31 08:53:05 +00:00
|
|
|
|
return "%Y-%m-%dT%H:%M:%S"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
if self.has_year and self.has_month and self.has_day and self.has_hour and self.has_minute:
|
2023-01-31 08:53:05 +00:00
|
|
|
|
return "%Y-%m-%dT%H:%M"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
if self.has_year and self.has_month and self.has_day and self.has_hour:
|
2023-01-31 08:53:05 +00:00
|
|
|
|
return "%Y-%m-%dT%H"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
if self.has_year and self.has_month and self.has_day:
|
2023-01-31 08:53:05 +00:00
|
|
|
|
return "%Y-%m-%d"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
if self.has_year and self.has_month:
|
2023-01-31 08:53:05 +00:00
|
|
|
|
return "%Y-%m"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
if self.has_year:
|
2023-01-31 08:53:05 +00:00
|
|
|
|
return "%Y"
|
2023-01-13 13:37:15 +00:00
|
|
|
|
return ""
|
|
|
|
|
|
2023-01-31 08:53:05 +00:00
|
|
|
|
def get_timestamp(self) -> str:
|
|
|
|
|
time_format = self.get_time_format()
|
|
|
|
|
return self.date_obj.strftime(time_format)
|
2023-02-25 21:16:32 +00:00
|
|
|
|
|
2023-01-31 08:53:05 +00:00
|
|
|
|
def get_timestamp_w_format(self) -> Tuple[str, str]:
|
|
|
|
|
time_format = self.get_time_format()
|
|
|
|
|
return time_format, self.date_obj.strftime(time_format)
|
|
|
|
|
|
2023-01-16 11:26:56 +00:00
|
|
|
|
@classmethod
|
|
|
|
|
def strptime(cls, time_stamp: str, format: str):
|
2023-01-16 11:57:04 +00:00
|
|
|
|
"""
|
|
|
|
|
day: "%d"
|
|
|
|
|
month: "%b", "%B", "%m"
|
|
|
|
|
year: "%y", "%Y"
|
|
|
|
|
hour: "%H", "%I"
|
|
|
|
|
minute: "%M"
|
|
|
|
|
second: "%S"
|
|
|
|
|
"""
|
|
|
|
|
date_obj = datetime.datetime.strptime(time_stamp, format)
|
|
|
|
|
|
|
|
|
|
day = None
|
|
|
|
|
if "%d" in format:
|
|
|
|
|
day = date_obj.day
|
|
|
|
|
month = None
|
|
|
|
|
if any([i in format for i in ("%b", "%B", "%m")]):
|
|
|
|
|
month = date_obj.month
|
|
|
|
|
year = None
|
|
|
|
|
if any([i in format for i in ("%y", "%Y")]):
|
|
|
|
|
year = date_obj.year
|
|
|
|
|
hour = None
|
|
|
|
|
if any([i in format for i in ("%H", "%I")]):
|
|
|
|
|
hour = date_obj.hour
|
|
|
|
|
minute = None
|
|
|
|
|
if "%M" in format:
|
|
|
|
|
minute = date_obj.minute
|
|
|
|
|
second = None
|
|
|
|
|
if "%S" in format:
|
|
|
|
|
second = date_obj.second
|
2023-01-16 11:26:56 +00:00
|
|
|
|
|
|
|
|
|
return cls(
|
2023-01-16 11:57:04 +00:00
|
|
|
|
year=year,
|
|
|
|
|
month=month,
|
|
|
|
|
day=day,
|
|
|
|
|
hour=hour,
|
|
|
|
|
minute=minute,
|
|
|
|
|
second=second
|
2023-01-16 11:26:56 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def now(cls):
|
|
|
|
|
date_obj = datetime.datetime.now()
|
|
|
|
|
|
|
|
|
|
return cls(
|
|
|
|
|
year=date_obj.year,
|
|
|
|
|
month=date_obj.month,
|
|
|
|
|
day=date_obj.day,
|
|
|
|
|
hour=date_obj.hour,
|
|
|
|
|
minute=date_obj.minute,
|
|
|
|
|
second=date_obj.second
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def strftime(self, format: str) -> str:
|
|
|
|
|
return self.date_obj.strftime(format)
|
|
|
|
|
|
2023-01-13 13:37:15 +00:00
|
|
|
|
def __str__(self) -> str:
|
|
|
|
|
return self.timestamp
|
|
|
|
|
|
|
|
|
|
def __repr__(self) -> str:
|
|
|
|
|
return self.timestamp
|
|
|
|
|
|
|
|
|
|
timestamp: str = property(fget=get_timestamp)
|
2023-02-19 22:35:52 +00:00
|
|
|
|
timeformat: str = property(fget=get_time_format)
|
2023-01-13 13:37:15 +00:00
|
|
|
|
|
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
class MetadataAttribute:
|
2023-01-12 22:01:19 +00:00
|
|
|
|
"""
|
2023-01-27 11:56:59 +00:00
|
|
|
|
This class shall be added to any object, which can return data for tagging
|
2023-01-12 22:01:19 +00:00
|
|
|
|
"""
|
2023-01-14 13:41:40 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
class Metadata:
|
2023-01-30 17:27:49 +00:00
|
|
|
|
# it's a null byte for the later concatenation of text frames
|
2023-01-27 11:56:59 +00:00
|
|
|
|
NULL_BYTE: str = "\x00"
|
|
|
|
|
# this is pretty self-explanatory
|
|
|
|
|
# the key is an enum from Mapping
|
|
|
|
|
# the value is a list with each value
|
|
|
|
|
# the mutagen object for each frame will be generated dynamically
|
2023-01-30 13:41:02 +00:00
|
|
|
|
id3_dict: Dict[any, list]
|
2023-01-12 22:01:19 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
def __init__(self, id3_dict: Dict[any, list] = None) -> None:
|
2023-01-30 13:41:02 +00:00
|
|
|
|
self.id3_dict = dict()
|
2023-01-27 11:56:59 +00:00
|
|
|
|
if id3_dict is not None:
|
|
|
|
|
self.add_metadata_dict(id3_dict)
|
2023-01-14 13:41:40 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
def __setitem__(self, frame, value_list: list, override_existing: bool = True):
|
|
|
|
|
if type(value_list) != list:
|
|
|
|
|
raise ValueError(f"can only set attribute to list, not {type(value_list)}")
|
2023-01-30 22:54:21 +00:00
|
|
|
|
|
|
|
|
|
new_val = [i for i in value_list if i not in {None, ''}]
|
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
if len(new_val) == 0:
|
|
|
|
|
return
|
2023-01-30 22:54:21 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
if override_existing:
|
|
|
|
|
self.id3_dict[frame] = new_val
|
|
|
|
|
else:
|
|
|
|
|
if frame not in self.id3_dict:
|
|
|
|
|
self.id3_dict[frame] = new_val
|
|
|
|
|
return
|
|
|
|
|
|
2023-01-30 13:41:02 +00:00
|
|
|
|
self.id3_dict[frame].extend(new_val)
|
2023-01-27 11:56:59 +00:00
|
|
|
|
|
|
|
|
|
def __getitem__(self, key):
|
|
|
|
|
if key not in self.id3_dict:
|
|
|
|
|
return None
|
|
|
|
|
return self.id3_dict[key]
|
|
|
|
|
|
|
|
|
|
def delete_field(self, key: str):
|
2023-01-30 22:54:21 +00:00
|
|
|
|
if key in self.id3_dict:
|
|
|
|
|
return self.id3_dict.pop(key)
|
2023-01-27 11:56:59 +00:00
|
|
|
|
|
|
|
|
|
def add_metadata_dict(self, metadata_dict: dict, override_existing: bool = True):
|
|
|
|
|
for field_enum, value in metadata_dict.items():
|
2023-01-30 13:41:02 +00:00
|
|
|
|
self.__setitem__(field_enum, value, override_existing=override_existing)
|
2023-01-27 11:56:59 +00:00
|
|
|
|
|
|
|
|
|
def merge(self, other, override_existing: bool = False):
|
|
|
|
|
"""
|
|
|
|
|
adds the values of another metadata obj to this one
|
2023-01-30 13:41:02 +00:00
|
|
|
|
|
|
|
|
|
other is a value of the type MetadataAttribute.Metadata
|
2023-01-27 11:56:59 +00:00
|
|
|
|
"""
|
2023-01-30 13:41:02 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
self.add_metadata_dict(other.id3_dict, override_existing=override_existing)
|
2023-01-30 22:54:21 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
def merge_many(self, many_other):
|
|
|
|
|
"""
|
|
|
|
|
adds the values of many other metadata objects to this one
|
|
|
|
|
"""
|
2023-01-30 22:54:21 +00:00
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
for other in many_other:
|
|
|
|
|
self.merge(other)
|
|
|
|
|
|
|
|
|
|
def get_id3_value(self, field):
|
2023-01-30 13:41:02 +00:00
|
|
|
|
if field not in self.id3_dict:
|
2023-01-27 11:56:59 +00:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
list_data = self.id3_dict[field]
|
|
|
|
|
|
|
|
|
|
# convert for example the time objects to timestamps
|
|
|
|
|
for i, element in enumerate(list_data):
|
|
|
|
|
# for performance’s sake I don't do other checks if it is already the right type
|
|
|
|
|
if type(element) == str:
|
|
|
|
|
continue
|
|
|
|
|
|
2023-01-30 22:54:21 +00:00
|
|
|
|
if type(element) in {int}:
|
|
|
|
|
list_data[i] = str(element)
|
|
|
|
|
|
2023-01-27 11:56:59 +00:00
|
|
|
|
if type(element) == ID3Timestamp:
|
|
|
|
|
list_data[i] = element.timestamp
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
Version 2.4 of the specification prescribes that all text fields (the fields that start with a T, except for TXXX) can contain multiple values separated by a null character.
|
|
|
|
|
Thus if above conditions are met, I concatenate the list,
|
|
|
|
|
else I take the first element
|
|
|
|
|
"""
|
|
|
|
|
if field.value[0].upper() == "T" and field.value.upper() != "TXXX":
|
2023-01-30 13:41:02 +00:00
|
|
|
|
return self.NULL_BYTE.join(list_data)
|
2023-01-27 11:56:59 +00:00
|
|
|
|
|
|
|
|
|
return list_data[0]
|
|
|
|
|
|
|
|
|
|
def get_mutagen_object(self, field):
|
|
|
|
|
return Mapping.get_mutagen_instance(field, self.get_id3_value(field))
|
|
|
|
|
|
|
|
|
|
def __str__(self) -> str:
|
|
|
|
|
rows = []
|
2023-01-30 13:41:02 +00:00
|
|
|
|
for key, value in self.id3_dict.items():
|
2023-01-27 11:56:59 +00:00
|
|
|
|
rows.append(f"{key} - {str(value)}")
|
|
|
|
|
return "\n".join(rows)
|
|
|
|
|
|
|
|
|
|
def __iter__(self):
|
|
|
|
|
"""
|
|
|
|
|
returns a generator, you can iterate through,
|
|
|
|
|
to directly tagg a file with id3 container.
|
|
|
|
|
"""
|
|
|
|
|
# set the tagging timestamp to the current time
|
2023-01-30 13:41:02 +00:00
|
|
|
|
self.__setitem__(Mapping.TAGGING_TIME, [ID3Timestamp.now()])
|
2023-01-27 11:56:59 +00:00
|
|
|
|
|
2023-01-30 13:41:02 +00:00
|
|
|
|
for field in self.id3_dict:
|
2023-01-27 11:56:59 +00:00
|
|
|
|
yield self.get_mutagen_object(field)
|
|
|
|
|
|
|
|
|
|
def get_metadata(self) -> Metadata:
|
2023-01-12 22:01:19 +00:00
|
|
|
|
"""
|
2023-01-27 11:56:59 +00:00
|
|
|
|
this is intendet to be overwritten by the child class
|
2023-01-12 22:01:19 +00:00
|
|
|
|
"""
|
2023-01-30 13:41:02 +00:00
|
|
|
|
return MetadataAttribute.Metadata()
|
2023-01-12 22:01:19 +00:00
|
|
|
|
|
2023-01-30 13:41:02 +00:00
|
|
|
|
metadata = property(fget=lambda self: self.get_metadata())
|