_tag_interface.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
  1. import abc
  2. import re
  3. import mutagen.id3
  4. import mutagen.mp4
  5. class TagInterface(abc.ABC):
  6. @abc.abstractproperty
  7. def track_path(self):
  8. pass
  9. @abc.abstractmethod
  10. def get_comment(self):
  11. pass
  12. @abc.abstractmethod
  13. def set_comment(self, comment):
  14. pass
  15. @abc.abstractmethod
  16. def get_track_uuid(self):
  17. pass
  18. @abc.abstractmethod
  19. def set_track_uuid(self, uuid):
  20. pass
  21. @abc.abstractmethod
  22. def save(self):
  23. pass
  24. @abc.abstractmethod
  25. def get_free_int(self, tag_label):
  26. pass
  27. @abc.abstractmethod
  28. def set_free_int(self, tag_label, data):
  29. pass
  30. @abc.abstractmethod
  31. def get_free_ints(self, tag_label_prefix):
  32. pass
  33. class _MutagenTagInterface(TagInterface):
  34. # pylint: disable=abstract-method
  35. def __init__(self, mutagen_file):
  36. self._mutagen_file = mutagen_file
  37. @property
  38. def track_path(self):
  39. return self._mutagen_file.filename
  40. def save(self):
  41. self._mutagen_file.save()
  42. class ID3(_MutagenTagInterface):
  43. # http://id3.org/id3v2.4.0-frames#4.1.
  44. _UFID_OWNER_ID = 'symuid'
  45. def __init__(self, mutagen_file):
  46. assert isinstance(mutagen_file.tags, mutagen.id3.ID3), \
  47. mutagen_file.tags
  48. super().__init__(mutagen_file)
  49. def _get_single_text(self, tag_label):
  50. tag = self._mutagen_file.tags.get(tag_label, None)
  51. if tag is None:
  52. # {}.get('a') == None
  53. return None
  54. if len(tag.text) == 1:
  55. return tag.text[0]
  56. raise ValueError(tag)
  57. def get_free_ints(self, tag_label_prefix):
  58. for tag in self._mutagen_file.tags.getall('TXXX:' + tag_label_prefix):
  59. assert len(tag.text) == 1, tag
  60. yield (tag.desc, int(tag.text[0]))
  61. def get_free_int(self, tag_label):
  62. text = self._get_single_text('TXXX:' + tag_label)
  63. return int(text) if text else None
  64. def set_free_int(self, tag_label, data):
  65. # mutagen.id3._specs.EncodedTextSpec.write encodes 'desc' and 'text'
  66. tag = mutagen.id3.TXXX(
  67. encoding=mutagen.id3.Encoding.LATIN1,
  68. desc=tag_label,
  69. text=[str(data)],
  70. )
  71. # TODO overwrite instead of add() ?
  72. self._mutagen_file.tags.add(tag)
  73. return tag
  74. def get_comment(self):
  75. return self._get_single_text('COMM::eng')
  76. def set_comment(self, comment):
  77. tag = mutagen.id3.COMM(
  78. encoding=mutagen.id3.Encoding.UTF8,
  79. lang='eng',
  80. text=[comment],
  81. )
  82. self._mutagen_file.tags.add(tag)
  83. return tag
  84. def get_track_uuid(self):
  85. for ufid in self._mutagen_file.tags.getall('UFID'):
  86. if ufid.owner == self._UFID_OWNER_ID:
  87. return ufid.data
  88. return None
  89. def set_track_uuid(self, uuid):
  90. # mutagen.id3._specs.EncodedTextSpec.write encodes 'owner'
  91. tag = mutagen.id3.UFID(owner=self._UFID_OWNER_ID, data=uuid)
  92. self._mutagen_file.tags.add(tag)
  93. return tag
  94. class MP4(_MutagenTagInterface):
  95. _UUID_TAG_KEY = 'symuid:uuid'
  96. def __init__(self, mutagen_file):
  97. assert mutagen_file.tags, mutagen_file
  98. assert isinstance(mutagen_file.tags, mutagen.mp4.MP4Tags), \
  99. mutagen_file.tags
  100. super().__init__(mutagen_file)
  101. def _get_single(self, tag_label):
  102. tag = self._mutagen_file.tags.get(tag_label, None)
  103. if tag is None:
  104. # {}.get('a') == None
  105. return None
  106. if len(tag) == 1:
  107. return tag[0]
  108. raise ValueError(tag)
  109. @staticmethod
  110. def _freeform_to_int(freeform):
  111. # "a signed big-endian integer with length one of { 1,2,3,4,8 } bytes"
  112. assert freeform.dataformat == mutagen.mp4.AtomDataType.INTEGER, freeform
  113. return int.from_bytes(freeform, byteorder='big', signed=True)
  114. def get_free_ints(self, tag_label_prefix):
  115. label_pattern = re.compile(r'^----:{}(:|$)'.format(
  116. re.escape(tag_label_prefix),
  117. ))
  118. for label, values in self._mutagen_file.tags.items():
  119. # TODO overwrite instead of add() ?
  120. if label_pattern.match(label):
  121. assert len(values) == 1, (label, values)
  122. value = MP4._freeform_to_int(values[0])
  123. yield (re.sub(r'^----:', '', label), value)
  124. def _get_free(self, tag_label):
  125. # freeform keys start with '----'
  126. # http://mutagen.readthedocs.io/en/latest/api/mp4.html
  127. return self._get_single('----:' + tag_label)
  128. def get_free_int(self, tag_label):
  129. tag = self._get_free(tag_label)
  130. return None if tag is None else MP4._freeform_to_int(tag)
  131. def _get_free_uuid(self, tag_label):
  132. tag = self._get_free(tag_label)
  133. assert tag is None or tag.dataformat == mutagen.mp4.AtomDataType.UUID, tag.dataformat
  134. return tag
  135. def _set_free(self, tag_label, dataformat, data):
  136. assert isinstance(data, bytes)
  137. tag = mutagen.mp4.MP4FreeForm(dataformat=dataformat, data=data)
  138. self._mutagen_file.tags['----:' + tag_label] = [tag]
  139. return tag
  140. def set_free_int(self, tag_label, data):
  141. assert isinstance(data, int)
  142. return self._set_free(
  143. tag_label=tag_label,
  144. # "a signed big-endian integer with length one of { 1,2,3,4,8 } bytes"
  145. dataformat=mutagen.mp4.AtomDataType.INTEGER,
  146. # TODO set byte length properly
  147. data=data.to_bytes(1, byteorder='big', signed=True),
  148. )
  149. def _set_free_uuid(self, tag_label, data):
  150. return self._set_free(
  151. tag_label=tag_label,
  152. # https://mutagen.readthedocs.io/en/latest/api/mp4.html#mutagen.mp4.AtomDataType.UUID
  153. dataformat=mutagen.mp4.AtomDataType.UUID,
  154. data=data,
  155. )
  156. def get_comment(self):
  157. return self._get_single('\xa9cmt')
  158. def set_comment(self, comment):
  159. raise NotImplementedError()
  160. def get_track_uuid(self):
  161. return self._get_free_uuid(self._UUID_TAG_KEY)
  162. def set_track_uuid(self, uuid):
  163. return self._set_free_uuid(self._UUID_TAG_KEY, uuid)
  164. class OggOpus(_MutagenTagInterface):
  165. def __init__(self, mutagen_file):
  166. assert isinstance(mutagen_file.tags, mutagen.oggopus.OggOpusVComment), \
  167. (mutagen_file, mutagen_file.tags)
  168. super().__init__(mutagen_file)
  169. def get_comment(self):
  170. raise NotImplementedError()
  171. def set_comment(self, comment):
  172. raise NotImplementedError()
  173. def get_track_uuid(self):
  174. raise NotImplementedError()
  175. def set_track_uuid(self, uuid):
  176. raise NotImplementedError()
  177. def get_free_int(self, tag_label):
  178. raise NotImplementedError()
  179. def set_free_int(self, tag_label, data):
  180. raise NotImplementedError()
  181. def get_free_ints(self, tag_label_prefix):
  182. raise NotImplementedError()