# Copyright (c) 2020 Johannes Nolte
# SPDX-License-Identifier: GPL-3.0-or-later
import copy
import logging
import mutagen
import mmusicc.util.allocationmap as am
from mmusicc.formats._audio import AudioFile
from mmusicc.util.metadatadict import Empty, scan_dictionary, AlbumArt
from mmusicc.util.util import text_parser_get, join_str_list
extensions = [".mp3", ".mp2", ".mp1", ".mpg", ".mpeg"]
"""list of all extensions associated with this module"""
# loader see bottom
# types see bottom
def _to_album_art(frame):
album_art = AlbumArt()
album_art.desc = frame.desc
album_art.ptype = frame.type
album_art.data = frame.data
album_art.mime = frame.mime
return album_art
[docs]class MP3File(AudioFile):
"""Tag object for mp3 files using ID3 tag standard.
Instance created by loader (at the bottom of this code) which in this case
is this class itself (vgl. xiph).
Args:
file_path (str): file path of represented audio file
"""
format = "MPEG-1/2"
mimes = ["audio/mp3", "audio/x-mp3", "audio/mpeg", "audio/mpg", "audio/x-mpeg"]
def __init__(self, file_path):
super().__init__()
self._file = mutagen.File(file_path)
self._changed_tags = None
[docs] def file_read(self):
"""reads file tags into AudioFile tag dictionary (dict_meta).
First tries to associate ID3 tags, than takes all txxx tags and runs
them through the scan dictionary function.
"""
tags_txxx = dict()
for frame in self._file.values():
frame_type = type(frame)
frame_type_parent = frame_type.mro()[1]
# handle each frame depending on parent class
if frame.FrameID == "APIC":
tag_val = _to_album_art(frame)
elif frame_type is mutagen.id3.TXXX:
tags_txxx[frame.desc] = frame.text[0]
continue
elif frame_type_parent is mutagen.id3.TextFrame:
tag_val = frame.text[0]
elif frame_type_parent is mutagen.id3.PairedTextFrame:
tmp_key = "PairedTextFrame not supported:" + frame.HashKey
self.unprocessed_tag[tmp_key] = frame.people
continue
elif frame_type_parent is mutagen.id3.NumericPartTextFrame:
tag_val = frame.text[0]
elif frame_type_parent is mutagen.id3.NumericTextFrame:
tag_val = frame.text[0]
elif frame_type_parent is mutagen.id3.TimeStampTextFrame:
tag_val = frame.text[0].get_text()
elif frame_type_parent is mutagen.id3.Frame:
tag_val = frame.text
else:
raise Exception("frame not implemented %s", frame.HashKey)
if Empty.is_empty(tag_val):
tag_val = Empty()
tag_key = am.dict_id32tag.get(frame.FrameID)
if tag_key:
if isinstance(tag_val, Empty) or isinstance(tag_val, AlbumArt):
val = tag_val
else:
val = text_parser_get(tag_val)
if len(val) == 1:
val = val[0]
self.dict_meta[tag_key] = val
else:
self.unprocessed_tag[frame.HashKey] = tag_val
if len(tags_txxx) > 0:
unprocessed_txxx = scan_dictionary(tags_txxx, self.dict_meta)
# restore original tag hash to be able to delete it
restored_txxx = dict()
for key in tags_txxx:
if key.casefold() in unprocessed_txxx:
restored_txxx["TXXX:" + key] = tags_txxx[key]
self.unprocessed_tag.update(restored_txxx)
return self
[docs] def file_save(
self, remove_existing=False, write_empty=False, remove_v1=False, dry_run=False
):
"""saves file tags from tag dictionary (dict_meta) to AudioFile.
Note:
write_empty may have no effect. Since mutagen will not load empty
tags it can't be checked. Correction or Info is appreciated.
Args:
remove_existing ('bool', optional): if true clear all tags on file
before writing. Defaults to False.
write_empty (bool): Only affects TXXX tags. Existing tags will
always be set to None. If true create empty TXXX tags with
value none. If false no tag will be created or existing
TXXX tag on file will be deleted. Defaults to False.
remove_v1 ('bool'): If True, remove existing ID3.V1 tags.
Defaults to False.
dry_run (bool): if true, do anything but saving to file. Defaults to False
Returns:
int: 1 if data was saved to file, zero if nothing was changed on file.
"""
self._changed_tags = list()
if not dry_run:
audio = self._file
else:
audio = copy.deepcopy(self._file)
if audio.tags is None:
audio.add_tags()
if audio.tags is None:
audio.add_tags()
new_tags = self.dict_meta.copy()
tag_remove = list()
for tag_key, value in new_tags.items():
id3_tag = am.dict_tag2id3.get(tag_key)
# load existing frame (or not)
if id3_tag == "TXXX":
txxx_tags = audio.tags.getall("TXXX")
for tx in txxx_tags:
if tag_key in tx.desc.casefold():
frame = [tx]
break
else:
frame = []
else:
frame = audio.tags.getall(id3_tag)
if frame:
if len(frame) > 1:
raise Exception(f"to many tags of {id3_tag} found")
else:
frame = frame[0]
# if it exists, check if it has to be deleted
if Empty.is_empty(value) and not write_empty:
tag_remove.append(tag_key)
audio.tags.delall(frame.FrameID)
continue
elif value is None:
if remove_existing:
tag_remove.append(tag_key)
audio.tags.delall(frame.FrameID)
continue
else:
if value is None:
continue
# if it does not exist, create a new one and add it to file
frame = eval("mutagen.id3.{}()".format(id3_tag))
audio.tags.add(frame)
if Empty.is_empty(value):
value = ""
frame_type = type(frame)
frame_type_parent = frame_type.mro()[1]
if frame_type is mutagen.id3.TXXX:
if tag_key not in frame.desc.casefold():
self._set_value(frame, "desc", tag_key)
if frame.FrameID == "APIC":
self._fill_apic_frame_with_albumart(frame, value)
elif frame_type_parent is mutagen.id3.PairedTextFrame:
# text frame support dropped
continue
else:
if isinstance(value, list):
value = join_str_list(value)
self._set_value(frame, "text", value)
if not frame.encoding:
self._set_value(frame, "encoding", mutagen.id3.Encoding.UTF16)
try:
lang = frame.lang
if not lang:
self._set_value(frame, "lang", "eng")
except AttributeError:
pass
if remove_v1:
v1 = 0 # ID3v1 tags will be removed
else: # 1 # ID3v1 tags will be updated but not added
v1 = 2 # ID3v1 tags will be created and / or updated
if remove_existing:
for tag, value in (
MP3File(self.file_path).file_read().unprocessed_tag.items()
):
audio.tags.delall(tag)
self._changed_tags.append(("delall", tag, value, "*"))
if len(self._changed_tags) == 0:
return 0
logging.debug(f"changed tag: {self._changed_tags}")
if not dry_run:
audio.save(v1=v1, v2_version=4, v23_sep=None)
logging.debug(f"File '{self.file_path}' saved.")
return 1
def _fill_apic_frame_with_albumart(self, frame, album_art):
self._set_value(frame, "desc", album_art.desc)
self._set_value(frame, "type", album_art.ptype)
self._set_value(frame, "data", album_art.data)
self._set_value(frame, "mime", album_art.mime)
def _set_value(self, frame, attr, value):
org = getattr(frame, attr)
setattr(frame, attr, value)
new = getattr(frame, attr)
if not org == new:
self._changed_tags.append((frame, attr, org, new))
types = [MP3File]
"""list of all subclasses of AudioFile in this module"""
loader = MP3File
"""class alias for dynamic loading of available AudioFile subclasses"""