diff --git a/smalltag/__init__.py b/smalltag/__init__.py index 6ccc8aa..1a24a8c 100644 --- a/smalltag/__init__.py +++ b/smalltag/__init__.py @@ -1,3 +1,10 @@ #!/usr/bin/python3 -from .smalltag import SmallTag +if False: + from .smalltag import SmallTag # noqa + + +def __getattr__(name): + if name == "SmallTag": + from .smalltag import SmallTag + return SmallTag diff --git a/smalltag/_formats/__init__.py b/smalltag/_formats/__init__.py new file mode 100644 index 0000000..a68faf1 --- /dev/null +++ b/smalltag/_formats/__init__.py @@ -0,0 +1,11 @@ +#!/usr/bin/python3 + +if False: + from ._id3 import _ID3 # noqa + + +def __getattr__(name): + match name: + case "_ID3": + from ._id3 import _ID3 + return _ID3 diff --git a/smalltag/_formats/_id3.py b/smalltag/_formats/_id3.py new file mode 100644 index 0000000..e38767b --- /dev/null +++ b/smalltag/_formats/_id3.py @@ -0,0 +1,95 @@ +#!/usr/bin/python3 + +from struct import pack +from tinytag import TinyTag + +from .. import SmallTag + +if True: + from collections.abc import Callable, Iterator # noqa + from typing import Any, BinaryIO, Dict, List + + +_tinytag_id3 = TinyTag._get_parser_for_filename(".mp3") # noqa + + +class _ID3(SmallTag, _tinytag_id3): + @staticmethod + def _synchsafe(unsynchsafe_int: int) -> tuple[int, ...]: + ints = ( + (unsynchsafe_int >> 21) & 0x7F, + (unsynchsafe_int >> 14) & 0x7F, + (unsynchsafe_int >> 7) & 0x7F, + unsynchsafe_int & 0x7F + ) + + return ints + + def write(self, file_obj: BinaryIO=None): + should_close_file = self._set_filehandler_for_writing(file_obj) + + # change keys to values in the mapping dict + self._ID3_WRITE_MAPPING = {} # noqa + for frame_id, name in self._ID3_MAPPING.items(): + if len(frame_id) == 4: + self._ID3_WRITE_MAPPING[name] = frame_id + + new_frames = self._compose_id3v2_frames() + + size = len(new_frames) + + new_header = self._compose_id3v2_header(size) + + new_tag = new_header + new_frames + print(new_tag) + + size, extended, major = self._parse_id3v2_header(self._filehandler) + + file_content = self._filehandler.read() + audio = file_content[size:] + + self._filehandler.seek(0) + self._filehandler.write(new_tag + audio) + self._filehandler.truncate() + + if should_close_file: + self._filehandler.close() + + def _compose_id3v2_header(self, size: int): + header = b"ID3\x04\x00\x00" + + synchsafe_size = self._synchsafe(size) + header += pack("4B", *synchsafe_size) + + return header + + def _compose_id3v2_frames(self): + tag_dict = self.as_dict() + + frames = b"" + + for field_name, field_value in tag_dict.items(): + if field_name not in self._ID3_WRITE_MAPPING: + continue + + frames += self._compose_id3v2_frame(field_name, field_value) + + return frames + + def _compose_id3v2_frame(self, field_name, field_value): + frame_id = bytes(self._ID3_WRITE_MAPPING[field_name], "ISO-8859-1") + + field_value = field_value[0] + + if isinstance(field_value, str): + frame_value = b"\x00" + bytes(field_value, "ISO-8859-1") + + else: + print(f"Writing of {field_name} is not implemented.") + return b"" + + frame_size = pack("4B", *self._synchsafe(len(frame_value))) + + frame_data = frame_id + frame_size + b"\x00\x00" + frame_value + + return frame_data diff --git a/smalltag/smalltag.py b/smalltag/smalltag.py index f22ba93..7ff0c04 100644 --- a/smalltag/smalltag.py +++ b/smalltag/smalltag.py @@ -1,7 +1,70 @@ #!/usr/bin/python3 -from tinytag import TinyTag +from __future__ import annotations +from tinytag import TinyTag, UnsupportedFormatError + + +if False: # just stole this lazy import type hinting trick from tinytag + from collections.abc import Callable, Iterator # noqa + from typing import Any, BinaryIO, Dict, List class SmallTag(TinyTag): - pass + @classmethod + def _get_parser_class( + cls, + filename: str | None = None, + filehandle: BinaryIO | None = None + ) -> type[SmallTag]: + if cls != SmallTag: + return cls + if filename: + parser_class = cls._get_parser_for_filename(filename) + if parser_class is not None: + return parser_class + # try determining the file type by magic byte header + if filehandle: + parser_class = cls._get_parser_for_file_handle(filehandle) + if parser_class is not None: + return parser_class + raise UnsupportedFormatError( + 'No tag reader found to support file type') + + def write(self, file_obj: BinaryIO = None): + raise NotImplementedError + + def _set_filehandler_for_writing(self, file_obj: BinaryIO) -> bool: + should_close_file = file_obj is None and not self._filehandler.writable() + + if self.filename is None and should_close_file: + raise ValueError( + "You must specify a new file object that is in write mode. " + "(Has to be from the same file as specified while creating this TinyTag instance.)" + ) + + if file_obj is not None and not self._filehandler.closed(): + self._filehandler.close() + + if file_obj is not None: + self._filehandler = file_obj + + else: + self._filehandler = open(self.filename, "rb+") + + return should_close_file + + +# import formats after SmallTag definition to avoid circular imports +# (this solution is stupid, but I don't know anything better.) +from ._formats import _ID3 # noqa + +SmallTag._file_extension_mapping = { + ('.mp1', '.mp2', '.mp3'): _ID3, + # ('.oga', '.ogg', '.opus', '.spx'): _Ogg, (Not yet implemented.) + # ('.wav',): _Wave, + # ('.flac',): _Flac, + # ('.wma',): _Wma, + # ('.m4b', '.m4a', '.m4r', '.m4v', '.mp4', + # '.aax', '.aaxc'): _MP4, + # ('.aiff', '.aifc', '.aif', '.afc'): _Aiff, +} diff --git a/tests/id3.py b/tests/id3.py new file mode 100644 index 0000000..f025c2f --- /dev/null +++ b/tests/id3.py @@ -0,0 +1,17 @@ +#!/usr/bin/python3 + +from json import dumps +from smalltag import SmallTag + + +test_tag = SmallTag.get("test.mp3") +print(test_tag) + +print(test_tag.title) +test_tag.title = "bTest" + +test_tag.write() + +test_tag = SmallTag.get("test.mp3") + +print(dumps(test_tag.as_dict(), indent=" ")) diff --git a/tests/test.mp3 b/tests/test.mp3 new file mode 100644 index 0000000..e92a390 Binary files /dev/null and b/tests/test.mp3 differ