319 lines
9.9 KiB
Python
319 lines
9.9 KiB
Python
from enum import Enum
|
||
from typing import List, Dict, Tuple
|
||
|
||
import dateutil.tz
|
||
from mutagen import id3
|
||
import datetime
|
||
|
||
from .parents import (
|
||
ID3Metadata
|
||
)
|
||
|
||
|
||
class Mapping(Enum):
|
||
"""
|
||
These frames belong to the id3 standart
|
||
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
|
||
"""
|
||
# Textframes
|
||
TITLE = "TIT2"
|
||
ISRC = "TSRC"
|
||
LENGTH = "TLEN"
|
||
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"
|
||
LANGUAGE = "TLAN" # https://en.wikipedia.org/wiki/ISO_639-2
|
||
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"
|
||
TAGGING_TIME = "TDTG"
|
||
|
||
SOURCE_WEBPAGE_URL = "WOAS"
|
||
FILE_WEBPAGE_URL = "WOAF"
|
||
INTERNET_RADIO_WEBPAGE_URL = "WORS"
|
||
ARTIST_WEBPAGE_URL = "WOAR"
|
||
COPYRIGHT_URL = "WCOP"
|
||
COMMERCIAL_INFORMATION_URL = "WCOM"
|
||
PAYMEMT_URL = "WPAY"
|
||
|
||
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)
|
||
|
||
|
||
class ID3Timestamp:
|
||
def __init__(
|
||
self,
|
||
year: int = None,
|
||
month: int = None,
|
||
day: int = None,
|
||
hour: int = None,
|
||
minute: int = None,
|
||
second: int = None
|
||
):
|
||
self.year = year
|
||
self.month = month
|
||
self.day = day
|
||
self.hour = hour
|
||
self.minute = minute
|
||
self.second = second
|
||
|
||
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
|
||
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(
|
||
year=year,
|
||
month=month,
|
||
day=day,
|
||
hour=hour,
|
||
minute=minute,
|
||
second=second
|
||
)
|
||
|
||
def get_timestamp(self) -> str:
|
||
"""
|
||
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:
|
||
return self.date_obj.strftime("%Y-%m-%dT%H:%M:%S")
|
||
if self.has_year and self.has_month and self.has_day and self.has_hour and self.has_minute:
|
||
return self.date_obj.strftime("%Y-%m-%dT%H:%M")
|
||
if self.has_year and self.has_month and self.has_day and self.has_hour:
|
||
return self.date_obj.strftime("%Y-%m-%dT%H")
|
||
if self.has_year and self.has_month and self.has_day:
|
||
return self.date_obj.strftime("%Y-%m-%d")
|
||
if self.has_year and self.has_month:
|
||
return self.date_obj.strftime("%Y-%m")
|
||
if self.has_year:
|
||
return self.date_obj.strftime("%Y")
|
||
return ""
|
||
|
||
@classmethod
|
||
def strptime(cls, time_stamp: str, format: str):
|
||
date_obj = datetime.datetime.strftime(time_stamp, format)
|
||
|
||
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
|
||
)
|
||
|
||
@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)
|
||
|
||
def __str__(self) -> str:
|
||
return self.timestamp
|
||
|
||
def __repr__(self) -> str:
|
||
return self.timestamp
|
||
|
||
timestamp: str = property(fget=get_timestamp)
|
||
|
||
|
||
class Metadata:
|
||
"""
|
||
Shall only be read or edited via the Song object.
|
||
call it like a dict to read/write values
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
# this is pretty self-explanatory
|
||
# the key is a 4 letter key from the id3 standards like TITL
|
||
|
||
self.id3_attributes: Dict[str, list] = {}
|
||
|
||
# its a null byte for the later concatenation of text frames
|
||
self.null_byte = "\x00"
|
||
|
||
def get_all_metadata(self):
|
||
return list(self.id3_attributes.items())
|
||
|
||
def __setitem__(self, key: str, value: list, override_existing: bool = True):
|
||
if len(value) == 0:
|
||
return
|
||
if type(value) != list:
|
||
raise ValueError(f"can only set attribute to list, not {type(value)}")
|
||
|
||
if override_existing:
|
||
new_val = []
|
||
for elem in value:
|
||
if elem is not None:
|
||
new_val.append(elem)
|
||
if len(new_val) > 0:
|
||
self.id3_attributes[key] = new_val
|
||
else:
|
||
if key not in self.id3_attributes:
|
||
self.id3_attributes[key] = value
|
||
return
|
||
self.id3_attributes[key].extend(value)
|
||
|
||
def __getitem__(self, key):
|
||
if key not in self.id3_attributes:
|
||
return None
|
||
return self.id3_attributes[key]
|
||
|
||
def add_id3_metadata_obj(self, id3_metadata: ID3Metadata, override_existing: bool = True):
|
||
metadata_dict = id3_metadata.get_id3_dict()
|
||
for field_enum, value in metadata_dict.items():
|
||
self.__setitem__(field_enum.value, value, override_existing=override_existing)
|
||
|
||
def add_many_id3_metadata_obj(self, id3_metadata_list: List[ID3Metadata], override_existing: bool = False):
|
||
for id3_metadata in id3_metadata_list:
|
||
self.add_id3_metadata_obj(id3_metadata, override_existing=override_existing)
|
||
|
||
def delete_item(self, key: str):
|
||
if key in self.id3_attributes:
|
||
return self.id3_attributes.pop(key)
|
||
|
||
def get_id3_value(self, key: str):
|
||
if key not in self.id3_attributes:
|
||
return None
|
||
|
||
list_data = self.id3_attributes[key]
|
||
|
||
# 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
|
||
|
||
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 key[0].upper() == "T" and key.upper() != "TXXX":
|
||
return self.null_byte.join(list_data)
|
||
|
||
return list_data[0]
|
||
|
||
def get_mutagen_object(self, key: str):
|
||
return Mapping.get_mutagen_instance(Mapping(key), self.get_id3_value(key))
|
||
|
||
def __iter__(self):
|
||
# set the tagging timestamp to the current time
|
||
self.__setitem__(Mapping.TAGGING_TIME.value, [ID3Timestamp.now()])
|
||
|
||
for key in self.id3_attributes:
|
||
yield key, self.get_mutagen_object(key)
|
||
|
||
def __str__(self) -> str:
|
||
rows = []
|
||
for key, value in self.id3_attributes.items():
|
||
rows.append(f"{key} - {str(value)}")
|
||
return "\n".join(rows)
|