tinytag 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tinytag might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinytag
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: Read audio file metadata
5
5
  Keywords: metadata,audio,music,mp3,m4a,wav,ogg,opus,flac,wma,aiff
6
6
  Author: Tom Wallroth, Mat (mathiascode)
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: License :: OSI Approved :: MIT License
19
20
  Classifier: Development Status :: 5 - Production/Stable
20
21
  Classifier: Environment :: Web Environment
@@ -28,6 +29,7 @@ Classifier: Typing :: Typed
28
29
  License-File: LICENSE
29
30
  Requires-Dist: coverage ; extra == "tests"
30
31
  Requires-Dist: mypy ; extra == "tests"
32
+ Requires-Dist: mypy<1.19.0 ; extra == "tests" and ( platform_python_implementation == 'PyPy')
31
33
  Requires-Dist: pycodestyle ; extra == "tests"
32
34
  Requires-Dist: pylint ; extra == "tests"
33
35
  Requires-Dist: pyright ; extra == "tests"
@@ -182,7 +184,7 @@ These are helpful when you need quick access to common metadata.
182
184
 
183
185
  ### Additional Metadata
184
186
 
185
- For additional values of the same field type, non-common metadata fields, or
187
+ For additional values of the same field type, uncommon metadata fields, or
186
188
  metadata specific to certain file formats, use `other`:
187
189
 
188
190
  tag.other # a dictionary of additional fields
@@ -202,6 +204,7 @@ present when files provide such metadata:
202
204
  director
203
205
  encoded_by
204
206
  encoder_settings
207
+ grouping
205
208
  initial_key
206
209
  isrc
207
210
  language
@@ -209,9 +212,14 @@ present when files provide such metadata:
209
212
  lyricist
210
213
  lyrics
211
214
  media
215
+ movement
216
+ movement_name
217
+ movement_total
212
218
  publisher
213
219
  set_subtitle
220
+ show_movement
214
221
  url
222
+ work
215
223
 
216
224
  Additional `other` field names not documented above may be present, but are
217
225
  format-specific and may change or disappear in future tinytag releases. If
@@ -421,6 +429,19 @@ TinyTag.get(file_obj=your_file_obj)
421
429
 
422
430
  ## Changelog
423
431
 
432
+ ### 2.2.0 (2025-12-15)
433
+
434
+ - Add support for movement, work and grouping fields
435
+ - ID3: Make synced lyrics available in 'other.lyrics' (LRC format)
436
+ - ID3: Continue reading after encountering empty frame
437
+ - ID3: Fix frame reading when image parsing is disabled
438
+ - ID3: Exclude more frames containing binary data
439
+ - ID3: Avoid unnecessary string decoding
440
+ - M4A: Support extended atom sizes
441
+ - M4A: Ensure all field names are lowercase
442
+ - OGG: Stop reading after reaching EOS page
443
+ - Vorbis: Map UNSYNCEDLYRICS field to other.lyrics
444
+
424
445
  ### 2.1.2 (2025-08-14)
425
446
 
426
447
  - M4A: Add a few missing additional metadata fields
@@ -146,7 +146,7 @@ These are helpful when you need quick access to common metadata.
146
146
 
147
147
  ### Additional Metadata
148
148
 
149
- For additional values of the same field type, non-common metadata fields, or
149
+ For additional values of the same field type, uncommon metadata fields, or
150
150
  metadata specific to certain file formats, use `other`:
151
151
 
152
152
  tag.other # a dictionary of additional fields
@@ -166,6 +166,7 @@ present when files provide such metadata:
166
166
  director
167
167
  encoded_by
168
168
  encoder_settings
169
+ grouping
169
170
  initial_key
170
171
  isrc
171
172
  language
@@ -173,9 +174,14 @@ present when files provide such metadata:
173
174
  lyricist
174
175
  lyrics
175
176
  media
177
+ movement
178
+ movement_name
179
+ movement_total
176
180
  publisher
177
181
  set_subtitle
182
+ show_movement
178
183
  url
184
+ work
179
185
 
180
186
  Additional `other` field names not documented above may be present, but are
181
187
  format-specific and may change or disappear in future tinytag releases. If
@@ -385,6 +391,19 @@ TinyTag.get(file_obj=your_file_obj)
385
391
 
386
392
  ## Changelog
387
393
 
394
+ ### 2.2.0 (2025-12-15)
395
+
396
+ - Add support for movement, work and grouping fields
397
+ - ID3: Make synced lyrics available in 'other.lyrics' (LRC format)
398
+ - ID3: Continue reading after encountering empty frame
399
+ - ID3: Fix frame reading when image parsing is disabled
400
+ - ID3: Exclude more frames containing binary data
401
+ - ID3: Avoid unnecessary string decoding
402
+ - M4A: Support extended atom sizes
403
+ - M4A: Ensure all field names are lowercase
404
+ - OGG: Stop reading after reaching EOS page
405
+ - Vorbis: Map UNSYNCEDLYRICS field to other.lyrics
406
+
388
407
  ### 2.1.2 (2025-08-14)
389
408
 
390
409
  - M4A: Add a few missing additional metadata fields
@@ -35,6 +35,7 @@ classifiers = [
35
35
  "Programming Language :: Python :: 3.11",
36
36
  "Programming Language :: Python :: 3.12",
37
37
  "Programming Language :: Python :: 3.13",
38
+ "Programming Language :: Python :: 3.14",
38
39
  "License :: OSI Approved :: MIT License",
39
40
  "Development Status :: 5 - Production/Stable",
40
41
  "Environment :: Web Environment",
@@ -58,6 +59,7 @@ Homepage = "https://github.com/tinytag/tinytag"
58
59
  tests = [
59
60
  "coverage",
60
61
  "mypy",
62
+ "mypy<1.19.0; platform_python_implementation == 'PyPy'",
61
63
  "pycodestyle",
62
64
  "pylint",
63
65
  "pyright"
@@ -3,7 +3,7 @@
3
3
 
4
4
  """Audio file metadata reader."""
5
5
 
6
- __version__ = '2.1.2'
6
+ __version__ = '2.2.0'
7
7
 
8
8
  from .tinytag import (
9
9
  TinyTag, Image, Images, OtherFields, OtherImages,
@@ -524,17 +524,26 @@ class _MP4(TinyTag):
524
524
  b'\xa9des': {b'data': _MP4._data_parser('other.description')},
525
525
  b'\xa9dir': {b'data': _MP4._data_parser('other.director')},
526
526
  b'\xa9gen': {b'data': _MP4._data_parser('genre')},
527
+ b'\xa9grp': {b'data': _MP4._data_parser('other.grouping')},
527
528
  b'\xa9lyr': {b'data': _MP4._data_parser('other.lyrics')},
528
- b'\xa9mvn': {b'data': _MP4._data_parser('movement')},
529
+ b'\xa9mvc': {
530
+ b'data': _MP4._data_parser('other.movement_total')
531
+ },
532
+ b'\xa9mvi': {b'data': _MP4._data_parser('other.movement')},
533
+ b'\xa9mvn': {
534
+ b'data': _MP4._data_parser('other.movement_name')
535
+ },
529
536
  b'\xa9nam': {b'data': _MP4._data_parser('title')},
530
537
  b'\xa9pub': {b'data': _MP4._data_parser('other.publisher')},
531
538
  b'\xa9too': {b'data': _MP4._data_parser('other.encoded_by')},
539
+ b'\xa9wrk': {b'data': _MP4._data_parser('other.work')},
532
540
  b'\xa9wrt': {b'data': _MP4._data_parser('composer')},
533
541
  b'aART': {b'data': _MP4._data_parser('albumartist')},
534
542
  b'cprt': {b'data': _MP4._data_parser('other.copyright')},
535
543
  b'desc': {b'data': _MP4._data_parser('other.description')},
536
544
  b'disk': {b'data': _MP4._nums_parser('disc', 'disc_total')},
537
545
  b'gnre': {b'data': _MP4._parse_id3v1_genre},
546
+ b'shwm': {b'data': _MP4._data_parser('other.show_movement')},
538
547
  b'trkn': {b'data': _MP4._nums_parser('track', 'track_total')},
539
548
  b'tmpo': {b'data': _MP4._data_parser('other.bpm')},
540
549
  b'covr': {b'data': _MP4._parse_cover_image},
@@ -547,13 +556,18 @@ class _MP4(TinyTag):
547
556
  path: _DataTreeDict,
548
557
  stop_pos: int | None = None,
549
558
  curr_path: list[bytes] | None = None) -> None:
550
- header_len = 8
559
+ header_len = ext_size_len = 8
551
560
  atom_header = fh.read(header_len)
552
561
  while len(atom_header) == header_len:
553
- atom_size = unpack('>I', atom_header[:4])[0] - header_len
562
+ atom_size = unpack('>I', atom_header[:4])[0]
554
563
  atom_type = atom_header[4:]
555
564
  if curr_path is None: # keep track how we traversed in the tree
556
565
  curr_path = [atom_type]
566
+ if atom_size == 1: # 64-bit size
567
+ ext_size_header = fh.read(ext_size_len)
568
+ if len(ext_size_header) == ext_size_len:
569
+ atom_size = unpack('>Q', ext_size_header)[0] - ext_size_len
570
+ atom_size -= header_len
557
571
  if atom_size <= 0: # empty atom, jump to next one
558
572
  atom_header = fh.read(header_len)
559
573
  continue
@@ -563,8 +577,10 @@ class _MP4(TinyTag):
563
577
  f'atom: {atom_type!r} len: {atom_size + header_len}')
564
578
  if atom_type in self._VERSIONED_ATOMS: # jump atom version for now
565
579
  fh.seek(4, SEEK_CUR)
580
+ atom_size -= 4
566
581
  if atom_type in self._FLAGGED_ATOMS: # jump atom flags for now
567
582
  fh.seek(4, SEEK_CUR)
583
+ atom_size -= 4
568
584
  sub_path = path.get(atom_type, None)
569
585
  # if the path leaf is a dict, traverse deeper into the tree:
570
586
  if isinstance(sub_path, dict):
@@ -589,7 +605,9 @@ class _MP4(TinyTag):
589
605
  # unknown data atom, try to parse it
590
606
  elif curr_path == self._ILST_PATH:
591
607
  atom_end_pos = fh.tell() + atom_size
592
- field_name = self._OTHER_PREFIX + atom_type.decode('latin-1')
608
+ field_name = (
609
+ self._OTHER_PREFIX + atom_type.decode('latin-1').lower()
610
+ )
593
611
  fh.seek(-header_len, SEEK_CUR)
594
612
  self._traverse_atoms(
595
613
  fh,
@@ -739,31 +757,35 @@ class _ID3(TinyTag):
739
757
  _ID3_MAPPING = {
740
758
  # Mapping from Frame ID to a field of the TinyTag
741
759
  # https://exiftool.org/TagNames/ID3.html
742
- 'COMM': 'comment', 'COM': 'comment',
743
- 'TRCK': 'track', 'TRK': 'track',
744
- 'TYER': 'year', 'TYE': 'year', 'TDRC': 'year',
745
- 'TALB': 'album', 'TAL': 'album',
746
- 'TPE1': 'artist', 'TP1': 'artist',
747
- 'TIT2': 'title', 'TT2': 'title',
748
- 'TCON': 'genre', 'TCO': 'genre',
749
- 'TPOS': 'disc', 'TPA': 'disc',
750
- 'TPE2': 'albumartist', 'TP2': 'albumartist',
751
- 'TCOM': 'composer', 'TCM': 'composer',
752
- 'WOAR': 'other.url', 'WAR': 'other.url',
753
- 'TSRC': 'other.isrc', 'TRC': 'other.isrc',
754
- 'TCOP': 'other.copyright', 'TCR': 'other.copyright',
755
- 'TBPM': 'other.bpm', 'TBP': 'other.bpm',
756
- 'TKEY': 'other.initial_key', 'TKE': 'other.initial_key',
757
- 'TLAN': 'other.language', 'TLA': 'other.language',
758
- 'TPUB': 'other.publisher', 'TPB': 'other.publisher',
759
- 'USLT': 'other.lyrics', 'ULT': 'other.lyrics',
760
- 'TPE3': 'other.conductor', 'TP3': 'other.conductor',
761
- 'TEXT': 'other.lyricist', 'TXT': 'other.lyricist',
762
- 'TSST': 'other.set_subtitle',
763
- 'TENC': 'other.encoded_by', 'TEN': 'other.encoded_by',
764
- 'TSSE': 'other.encoder_settings', 'TSS': 'other.encoder_settings',
765
- 'TMED': 'other.media', 'TMT': 'other.media',
766
- 'WCOP': 'other.license',
760
+ b'COMM': 'comment', b'COM': 'comment',
761
+ b'TRCK': 'track', b'TRK': 'track',
762
+ b'TYER': 'year', b'TYE': 'year', b'TDRC': 'year',
763
+ b'TALB': 'album', b'TAL': 'album',
764
+ b'TPE1': 'artist', b'TP1': 'artist',
765
+ b'TIT2': 'title', b'TT2': 'title',
766
+ b'TCON': 'genre', b'TCO': 'genre',
767
+ b'TPOS': 'disc', b'TPA': 'disc',
768
+ b'TPE2': 'albumartist', b'TP2': 'albumartist',
769
+ b'TCOM': 'composer', b'TCM': 'composer',
770
+ b'WOAR': 'other.url', b'WAR': 'other.url',
771
+ b'TSRC': 'other.isrc', b'TRC': 'other.isrc',
772
+ b'TCOP': 'other.copyright', b'TCR': 'other.copyright',
773
+ b'TBPM': 'other.bpm', b'TBP': 'other.bpm',
774
+ b'TKEY': 'other.initial_key', b'TKE': 'other.initial_key',
775
+ b'TLAN': 'other.language', b'TLA': 'other.language',
776
+ b'TPUB': 'other.publisher', b'TPB': 'other.publisher',
777
+ b'USLT': 'other.lyrics', b'ULT': 'other.lyrics',
778
+ b'TPE3': 'other.conductor', b'TP3': 'other.conductor',
779
+ b'TEXT': 'other.lyricist', b'TXT': 'other.lyricist',
780
+ b'TSST': 'other.set_subtitle',
781
+ b'TENC': 'other.encoded_by', b'TEN': 'other.encoded_by',
782
+ b'TSSE': 'other.encoder_settings', b'TSS': 'other.encoder_settings',
783
+ b'TMED': 'other.media', b'TMT': 'other.media',
784
+ b'WCOP': 'other.license',
785
+ b'MVNM': 'other.movement_name',
786
+ b'MVIN': 'other.movement',
787
+ b'GRP1': 'modern_grouping', b'GP1': 'modern_grouping',
788
+ b'TIT1': 'legacy_grouping', b'TT1': 'legacy_grouping',
767
789
  }
768
790
  _ID3_MAPPING_CUSTOM = {
769
791
  'artists': 'artist',
@@ -771,23 +793,40 @@ class _ID3(TinyTag):
771
793
  'license': 'other.license',
772
794
  'barcode': 'other.barcode',
773
795
  'catalognumber': 'other.catalog_number',
796
+ 'showmovement': 'other.show_movement'
774
797
  }
775
- _IMAGE_FRAME_IDS = {'APIC', 'PIC'}
776
- _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'}
798
+ _EMPTY_FRAME_IDS = {b'\x00\x00\x00\x00', b'\x00\x00\x00'}
799
+ _IMAGE_FRAME_IDS = {b'APIC', b'PIC'}
800
+ _CUSTOM_FRAME_IDS = {b'TXXX', b'TXX'}
801
+ _SYNCED_LYRICS_FRAME_IDS = {b'SYLT', b'SLT'}
777
802
  _IGNORED_FRAME_IDS = {
778
- 'AENC', 'CRA',
779
- 'ATXT',
780
- 'CHAP',
781
- 'COMR',
782
- 'CRM',
783
- 'CTOC',
784
- 'ENCR',
785
- 'GEOB', 'GEO',
786
- 'GRID',
787
- 'MCDI', 'MCI',
788
- 'PRIV',
789
- 'RGAD',
790
- 'STC', 'SYTC'
803
+ b'AENC', b'CRA',
804
+ b'APIC', b'PIC',
805
+ b'ASPI',
806
+ b'ATXT',
807
+ b'CHAP',
808
+ b'COMR',
809
+ b'CRM',
810
+ b'CTOC',
811
+ b'ENCR',
812
+ b'EQU2', b'EQU',
813
+ b'ETCO', b'ETC',
814
+ b'GEOB', b'GEO',
815
+ b'GRID',
816
+ b'LINK', b'LNK',
817
+ b'MCDI', b'MCI',
818
+ b'MLLT', b'MLL',
819
+ b'PCNT', b'CNT',
820
+ b'POPM', b'POP',
821
+ b'POSS',
822
+ b'PRIV',
823
+ b'RBUF', b'BUF',
824
+ b'RGAD',
825
+ b'RVA2', b'RVA',
826
+ b'RVRB', b'REV',
827
+ b'SEEK',
828
+ b'SIGN',
829
+ b'SYTC', b'STC',
791
830
  }
792
831
  _ID3V1_TAG_SIZE = 128
793
832
  _MAX_ESTIMATION_SEC = 30.0
@@ -837,9 +876,9 @@ class _ID3(TinyTag):
837
876
  'Psybient',
838
877
  )
839
878
  _ID3V2_2_IMAGE_FORMATS = {
840
- 'bmp': 'image/bmp',
841
- 'jpg': 'image/jpeg',
842
- 'png': 'image/png',
879
+ b'bmp': 'image/bmp',
880
+ b'jpg': 'image/jpeg',
881
+ b'png': 'image/png',
843
882
  }
844
883
  _IMAGE_TYPES = (
845
884
  'other.generic',
@@ -904,6 +943,8 @@ class _ID3(TinyTag):
904
943
  super().__init__()
905
944
  # save position after the ID3 tag for duration measurement speedup
906
945
  self._bytepos_after_id3v2 = -1
946
+ self._modern_grouping_values: list[str] = []
947
+ self._legacy_grouping_values: list[str] = []
907
948
 
908
949
  @staticmethod
909
950
  def _parse_xing_header(fh: BinaryIO) -> tuple[int, int]:
@@ -1048,10 +1089,11 @@ class _ID3(TinyTag):
1048
1089
  fh.seek(extd_size - 6, SEEK_CUR) # jump over extended_header
1049
1090
  while parsed_size < size:
1050
1091
  frame_size = self._parse_frame(fh, size, id3version=major)
1051
- if frame_size == 0:
1092
+ if frame_size == -1:
1052
1093
  break
1053
1094
  parsed_size += frame_size
1054
1095
  fh.seek(end_pos)
1096
+ self._set_grouping_work_fields()
1055
1097
 
1056
1098
  def _parse_id3v1(self, fh: BinaryIO) -> None:
1057
1099
  content = fh.read(3 + 30 + 30 + 30 + 4 + 30 + 1)
@@ -1093,7 +1135,7 @@ class _ID3(TinyTag):
1093
1135
  if genre_id < len(self._ID3V1_GENRES):
1094
1136
  self._set_field('genre', self._ID3V1_GENRES[genre_id])
1095
1137
 
1096
- def __parse_custom_field(self, content: str) -> bool:
1138
+ def _parse_custom_field(self, content: str) -> bool:
1097
1139
  custom_field_name, separator, value = content.partition('\x00')
1098
1140
  custom_field_name_lower = custom_field_name.lower()
1099
1141
  value = value.lstrip('\ufeff')
@@ -1105,6 +1147,18 @@ class _ID3(TinyTag):
1105
1147
  return True
1106
1148
  return False
1107
1149
 
1150
+ def _set_grouping_work_fields(self) -> None:
1151
+ # iTunes 12.5.4.42 added a new GRP1 frame for 'grouping', and
1152
+ # repurposed the TIT1 frame for 'work'. Handle this mess here.
1153
+ if self._modern_grouping_values:
1154
+ for value in self._modern_grouping_values:
1155
+ self._set_field('other.grouping', value)
1156
+ for value in self._legacy_grouping_values:
1157
+ self._set_field('other.work', value)
1158
+ return
1159
+ for value in self._legacy_grouping_values:
1160
+ self._set_field('other.grouping', value)
1161
+
1108
1162
  @classmethod
1109
1163
  def _create_tag_image(cls,
1110
1164
  data: bytes,
@@ -1124,6 +1178,75 @@ class _ID3(TinyTag):
1124
1178
  image.description = description
1125
1179
  return field_name, image
1126
1180
 
1181
+ def _parse_image(self,
1182
+ frame_id: bytes,
1183
+ content: bytes) -> tuple[str, Image]:
1184
+ # See section 4.14: http://id3.org/id3v2.4.0-frames
1185
+ encoding = content[:1]
1186
+ if frame_id == b'PIC': # ID3 v2.2:
1187
+ imgformat = content[1:4].lower()
1188
+ mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat)
1189
+ # skip encoding (1), imgformat (3), pictype(1)
1190
+ desc_start_pos = 5
1191
+ else: # ID3 v2.3+
1192
+ mime_start_pos = 1
1193
+ mime_end_pos = self._find_string_end_pos(
1194
+ content, start_pos=mime_start_pos)
1195
+ mime_type = self._decode_string(
1196
+ content[mime_start_pos:mime_end_pos]).lower()
1197
+ # skip mtype, pictype(1)
1198
+ desc_start_pos = mime_end_pos + 1
1199
+ pic_type = content[desc_start_pos - 1]
1200
+ desc_end_pos = self._find_string_end_pos(
1201
+ content, encoding, desc_start_pos)
1202
+ # skip stray null byte in broken file
1203
+ if (desc_end_pos + 1 < len(content)
1204
+ and content[desc_end_pos] == 0
1205
+ and content[desc_end_pos + 1] != 0):
1206
+ desc_end_pos += 1
1207
+ desc = self._decode_string(
1208
+ encoding + content[desc_start_pos:desc_end_pos])
1209
+ return self._create_tag_image(
1210
+ content[desc_end_pos:], pic_type, mime_type, desc)
1211
+
1212
+ @staticmethod
1213
+ def _lrc_timestamp(seconds: float) -> str:
1214
+ cs = int(seconds * 100)
1215
+ minutes, cs = divmod(cs, 6000)
1216
+ seconds, cs = divmod(cs, 100)
1217
+ return f"{minutes:02d}:{seconds:02d}.{cs:02d}"
1218
+
1219
+ def _parse_synced_lyrics(self, content: bytes) -> str:
1220
+ # Convert ID3 synced lyrics to LRC format
1221
+ content_length = len(content)
1222
+ encoding = content[:1]
1223
+ # skip language (3)
1224
+ timestamp_format = content[4:5]
1225
+ # skip content type (1)
1226
+ start_pos = 6
1227
+ end_pos = self._find_string_end_pos(content, encoding, start_pos)
1228
+ lyrics = ""
1229
+ offset = end_pos
1230
+ found_line = False
1231
+ while offset < content_length:
1232
+ end_pos = self._find_string_end_pos(content, encoding, offset)
1233
+ value = self._decode_string(
1234
+ encoding + content[offset:end_pos]).lstrip('\n')
1235
+ offset = end_pos
1236
+ time = unpack('>I', content[offset:offset + 4])[0]
1237
+ offset += 4
1238
+ if found_line:
1239
+ lyrics += '\n'
1240
+ found_line = True
1241
+ if timestamp_format == b'\x02':
1242
+ # time in milliseconds
1243
+ timestamp = self._lrc_timestamp(time / 1000)
1244
+ else:
1245
+ lyrics += value
1246
+ continue
1247
+ lyrics += f'[{timestamp}]{value}'
1248
+ return lyrics
1249
+
1127
1250
  def _parse_frame(self,
1128
1251
  fh: BinaryIO,
1129
1252
  total_size: int,
@@ -1134,8 +1257,10 @@ class _ID3(TinyTag):
1134
1257
  is_synchsafe_int = id3version == 4
1135
1258
  header = fh.read(header_len)
1136
1259
  if len(header) != header_len:
1137
- return 0
1138
- frame_id = self._decode_string(header[:frame_size_bytes])
1260
+ return -1
1261
+ frame_id = header[:frame_size_bytes]
1262
+ if frame_id in self._EMPTY_FRAME_IDS:
1263
+ return -1
1139
1264
  frame_size: int
1140
1265
  if frame_size_bytes == 3:
1141
1266
  frame_size = unpack('>I', b'\x00' + header[3:6])[0]
@@ -1144,15 +1269,13 @@ class _ID3(TinyTag):
1144
1269
  else:
1145
1270
  frame_size = unpack('>I', header[4:8])[0]
1146
1271
  if _DEBUG:
1147
- print(f'Found id3 Frame {frame_id} at '
1272
+ print(f'Found id3 Frame {frame_id!r} at '
1148
1273
  f'{fh.tell()}-{fh.tell() + frame_size} of {self.filesize}')
1149
1274
  if frame_size > total_size:
1150
1275
  # invalid frame size, stop here
1151
- return 0
1276
+ return -1
1152
1277
  should_set_field = True
1153
- if frame_id in self._ID3_MAPPING:
1154
- if not self._parse_tags:
1155
- return frame_size
1278
+ if self._parse_tags and frame_id in self._ID3_MAPPING:
1156
1279
  fieldname = self._ID3_MAPPING[frame_id]
1157
1280
  language = fieldname in {'comment', 'other.lyrics'}
1158
1281
  value = self._decode_string(fh.read(frame_size), language)
@@ -1160,8 +1283,8 @@ class _ID3(TinyTag):
1160
1283
  return frame_size
1161
1284
  if fieldname == "comment":
1162
1285
  # check if comment is a key-value pair (used by iTunes)
1163
- should_set_field = not self.__parse_custom_field(value)
1164
- elif fieldname in {'track', 'disc'}:
1286
+ should_set_field = not self._parse_custom_field(value)
1287
+ elif fieldname in {'track', 'disc', 'other.movement'}:
1165
1288
  if '/' in value:
1166
1289
  value, total = value.split('/')[:2]
1167
1290
  if total.isdecimal():
@@ -1182,62 +1305,52 @@ class _ID3(TinyTag):
1182
1305
  genre_id = int(parens_text)
1183
1306
  if 0 <= genre_id < len(self._ID3V1_GENRES):
1184
1307
  value = self._ID3V1_GENRES[genre_id]
1308
+ elif fieldname == 'modern_grouping':
1309
+ self._modern_grouping_values.append(value)
1310
+ should_set_field = False
1311
+ elif fieldname == 'legacy_grouping':
1312
+ self._legacy_grouping_values.append(value)
1313
+ should_set_field = False
1185
1314
  if should_set_field:
1186
1315
  self._set_field(fieldname, value)
1187
- elif frame_id in self._CUSTOM_FRAME_IDS:
1316
+ elif self._parse_tags and frame_id in self._SYNCED_LYRICS_FRAME_IDS:
1317
+ lyrics = self._parse_synced_lyrics(fh.read(frame_size))
1318
+ self._set_field('other.lyrics', lyrics)
1319
+ elif self._parse_tags and frame_id in self._CUSTOM_FRAME_IDS:
1188
1320
  # custom fields
1189
- if self._parse_tags:
1190
- value = self._decode_string(fh.read(frame_size))
1191
- if value:
1192
- self.__parse_custom_field(value)
1193
- elif frame_id in self._IMAGE_FRAME_IDS:
1194
- if self._load_image:
1195
- # See section 4.14: http://id3.org/id3v2.4.0-frames
1196
- content = fh.read(frame_size)
1197
- encoding = content[:1]
1198
- if frame_id == 'PIC': # ID3 v2.2:
1199
- imgformat = self._decode_string(content[1:4]).lower()
1200
- mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat)
1201
- # skip encoding (1), imgformat (3), pictype(1)
1202
- desc_start_pos = 5
1203
- else: # ID3 v2.3+
1204
- mime_end_pos = content.index(b'\x00', 1)
1205
- mime_type = self._decode_string(
1206
- content[1:mime_end_pos]).lower()
1207
- # skip mtype, pictype(1)
1208
- desc_start_pos = mime_end_pos + 2
1209
- pic_type = content[desc_start_pos - 1]
1210
- # latin1 and utf-8 are 1 byte
1211
- if encoding in {b'\x00', b'\x03'}:
1212
- desc_end_pos = content.find(b'\x00', desc_start_pos) + 1
1213
- else:
1214
- desc_end_pos = 0
1215
- for i in range(desc_start_pos, len(content), 2):
1216
- if content[i:i + 2] == b'\x00\x00':
1217
- desc_end_pos = i + 2
1218
- break
1219
- # skip stray null byte in broken file
1220
- if (desc_end_pos + 1 < len(content)
1221
- and content[desc_end_pos] == 0
1222
- and content[desc_end_pos + 1] != 0):
1223
- desc_end_pos += 1
1224
- desc = self._decode_string(
1225
- encoding + content[desc_start_pos:desc_end_pos])
1226
- field_name, image = self._create_tag_image(
1227
- content[desc_end_pos:], pic_type, mime_type, desc)
1228
- # pylint: disable=protected-access
1229
- self.images._set_field(field_name, image)
1230
- elif frame_id not in self._IGNORED_FRAME_IDS:
1321
+ value = self._decode_string(fh.read(frame_size))
1322
+ if value:
1323
+ self._parse_custom_field(value)
1324
+ elif self._parse_tags and frame_id not in self._IGNORED_FRAME_IDS:
1231
1325
  # unknown, try to add to other dict
1232
- if self._parse_tags:
1233
- value = self._decode_string(fh.read(frame_size))
1234
- if value:
1235
- self._set_field(
1236
- self._OTHER_PREFIX + frame_id.lower(), value)
1326
+ value = self._decode_string(fh.read(frame_size))
1327
+ if value:
1328
+ self._set_field(
1329
+ self._OTHER_PREFIX + frame_id.decode('latin-1').lower(),
1330
+ value)
1331
+ elif self._load_image and frame_id in self._IMAGE_FRAME_IDS:
1332
+ field_name, image = self._parse_image(
1333
+ frame_id, fh.read(frame_size))
1334
+ # pylint: disable=protected-access
1335
+ self.images._set_field(field_name, image)
1237
1336
  else: # skip frame
1238
1337
  fh.seek(frame_size, SEEK_CUR)
1239
1338
  return frame_size
1240
1339
 
1340
+ @staticmethod
1341
+ def _find_string_end_pos(content: bytes,
1342
+ encoding: bytes = b'\x00',
1343
+ start_pos: int = 0) -> int:
1344
+ # latin1 and utf-8 are 1 byte
1345
+ if encoding in {b'\x00', b'\x03'}:
1346
+ return content.find(b'\x00', start_pos) + 1
1347
+ end_pos = 0
1348
+ for i in range(start_pos, len(content), 2):
1349
+ if content[i:i + 2] == b'\x00\x00':
1350
+ end_pos = i + 2
1351
+ break
1352
+ return end_pos
1353
+
1241
1354
  def _decode_string(self, value: bytes, language: bool = False) -> str:
1242
1355
  default_encoding = 'ISO-8859-1'
1243
1356
  if self._default_encoding:
@@ -1310,6 +1423,7 @@ class _Ogg(TinyTag):
1310
1423
  'copyright': 'other.copyright',
1311
1424
  'isrc': 'other.isrc',
1312
1425
  'lyrics': 'other.lyrics',
1426
+ 'unsyncedlyrics': 'other.lyrics',
1313
1427
  'publisher': 'other.publisher',
1314
1428
  'language': 'other.language',
1315
1429
  'director': 'other.director',
@@ -1326,6 +1440,13 @@ class _Ogg(TinyTag):
1326
1440
  'license': 'other.license',
1327
1441
  'barcode': 'other.barcode',
1328
1442
  'catalognumber': 'other.catalog_number',
1443
+ 'movementname': 'other.movement_name',
1444
+ 'movement': 'other.movement',
1445
+ 'movementtotal': 'other.movement_total',
1446
+ 'showmovement': 'other.show_movement',
1447
+ 'grouping': 'other.grouping',
1448
+ 'contentgroup': 'other.grouping',
1449
+ 'work': 'other.work'
1329
1450
  }
1330
1451
 
1331
1452
  def __init__(self) -> None:
@@ -1516,6 +1637,8 @@ class _Ogg(TinyTag):
1516
1637
  else:
1517
1638
  self._audio_size += last_audio_size
1518
1639
  last_audio_size = audio_size
1640
+ if eos:
1641
+ break
1519
1642
  page_header = fh.read(header_len)
1520
1643
 
1521
1644
 
@@ -1568,7 +1691,7 @@ class _Wave(TinyTag):
1568
1691
  subchunk_size = unpack('I', chunk_header[4:])[0]
1569
1692
  # IFF chunks are padded to an even number of bytes
1570
1693
  subchunk_size += subchunk_size % 2
1571
- if subchunk_id == b'fmt ' and self._parse_duration:
1694
+ if self._parse_duration and subchunk_id == b'fmt ':
1572
1695
  chunk = fh.read(subchunk_size)
1573
1696
  _format_tag, channels, samplerate = unpack('<HHI', chunk[:8])
1574
1697
  bitdepth = unpack('<H', chunk[14:16])[0]
@@ -1579,14 +1702,14 @@ class _Wave(TinyTag):
1579
1702
  self.bitrate = samplerate * channels * bitdepth / 1000
1580
1703
  self.channels, self.samplerate, self.bitdepth = (
1581
1704
  channels, samplerate, bitdepth)
1582
- elif subchunk_id == b'data' and self._parse_duration:
1705
+ elif self._parse_duration and subchunk_id == b'data':
1583
1706
  if (self.channels is not None and self.samplerate is not None
1584
1707
  and self.bitdepth is not None):
1585
1708
  self.duration = (
1586
1709
  subchunk_size / self.channels / self.samplerate
1587
1710
  / (self.bitdepth / 8))
1588
1711
  fh.seek(subchunk_size, SEEK_CUR)
1589
- elif subchunk_id == b'LIST' and self._parse_tags:
1712
+ elif self._parse_tags and subchunk_id == b'LIST':
1590
1713
  chunk = fh.read(subchunk_size)
1591
1714
  if chunk.startswith(b'INFO'):
1592
1715
  walker = BytesIO(chunk)
@@ -1607,7 +1730,7 @@ class _Wave(TinyTag):
1607
1730
  else:
1608
1731
  self._set_field(fieldname, value)
1609
1732
  field = walker.read(4)
1610
- elif subchunk_id in {b'id3 ', b'ID3 '} and self._parse_tags:
1733
+ elif self._parse_tags and subchunk_id in {b'id3 ', b'ID3 '}:
1611
1734
  # pylint: disable=protected-access
1612
1735
  id3 = _ID3()
1613
1736
  id3._filehandler = fh
@@ -1651,7 +1774,7 @@ class _Flac(TinyTag):
1651
1774
  is_last_block = block_header[0] & 0x80
1652
1775
  size = unpack('>I', b'\x00' + block_header[1:])[0]
1653
1776
  # http://xiph.org/flac/format.html#metadata_block_streaminfo
1654
- if block_type == self._STREAMINFO and self._parse_duration:
1777
+ if self._parse_duration and block_type == self._STREAMINFO:
1655
1778
  head = fh.read(size)
1656
1779
  if len(head) < 34: # invalid streaminfo
1657
1780
  break
@@ -1681,13 +1804,13 @@ class _Flac(TinyTag):
1681
1804
  self.samplerate = sr
1682
1805
  if duration > 0:
1683
1806
  self.bitrate = self.filesize * 8 / duration / 1000
1684
- elif block_type == self._VORBIS_COMMENT and self._parse_tags:
1807
+ elif self._parse_tags and block_type == self._VORBIS_COMMENT:
1685
1808
  # pylint: disable=protected-access
1686
1809
  walker = BytesIO(fh.read(size))
1687
1810
  oggtag = _Ogg()
1688
1811
  oggtag._parse_vorbis_comment(walker)
1689
1812
  self._update(oggtag)
1690
- elif block_type == self._PICTURE and self._load_image:
1813
+ elif self._load_image and block_type == self._PICTURE:
1691
1814
  fieldname, value = self._parse_image(fh)
1692
1815
  # pylint: disable=protected-access
1693
1816
  self.images._set_field(fieldname, value)
@@ -1746,6 +1869,8 @@ class _Wma(TinyTag):
1746
1869
  'WM/Media': 'other.media',
1747
1870
  'WM/Barcode': 'other.barcode',
1748
1871
  'WM/CatalogNo': 'other.catalog_number',
1872
+ 'WM/ContentGroupDescription': 'other.grouping',
1873
+ 'WM/Work': 'other.work'
1749
1874
  }
1750
1875
  _UNPACK_FORMATS = {
1751
1876
  1: '<B',
@@ -1781,7 +1906,7 @@ class _Wma(TinyTag):
1781
1906
  if object_size == 0 or object_size > self.filesize:
1782
1907
  break # invalid object, stop parsing.
1783
1908
  object_id = object_header[:16]
1784
- if object_id == self._ASF_CONTENT_DESC and self._parse_tags:
1909
+ if self._parse_tags and object_id == self._ASF_CONTENT_DESC:
1785
1910
  walker = BytesIO(fh.read(object_size - header_len))
1786
1911
  (title_length, author_length,
1787
1912
  copyright_length, description_length,
@@ -1798,7 +1923,7 @@ class _Wma(TinyTag):
1798
1923
  walker.read(length).decode('utf-16', 'replace'))
1799
1924
  if not i_field_name.startswith('_') and value:
1800
1925
  self._set_field(i_field_name, value)
1801
- elif object_id == self._ASF_EXT_CONTENT_DESC and self._parse_tags:
1926
+ elif self._parse_tags and object_id == self._ASF_EXT_CONTENT_DESC:
1802
1927
  # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195
1803
1928
  walker = BytesIO(fh.read(object_size - header_len))
1804
1929
  descriptor_count = unpack('<H', walker.read(2))[0]
@@ -1831,13 +1956,13 @@ class _Wma(TinyTag):
1831
1956
  self._set_field(field_name, int(value))
1832
1957
  elif value:
1833
1958
  self._set_field(field_name, value)
1834
- elif object_id == self._ASF_FILE_PROP and self._parse_duration:
1959
+ elif self._parse_duration and object_id == self._ASF_FILE_PROP:
1835
1960
  data = fh.read(object_size - header_len)
1836
1961
  play_duration = unpack('<Q', data[40:48])[0] / 10000000
1837
1962
  preroll = unpack('<Q', data[56:64])[0] / 1000
1838
1963
  # subtract the preroll to get the actual duration
1839
1964
  self.duration = max(play_duration - preroll, 0.0)
1840
- elif object_id == self._ASF_STREAM_PROPS and self._parse_duration:
1965
+ elif self._parse_duration and object_id == self._ASF_STREAM_PROPS:
1841
1966
  data = fh.read(object_size - header_len)
1842
1967
  stream_type = data[:16]
1843
1968
  if stream_type == self._STREAM_TYPE_ASF_AUDIO_MEDIA:
@@ -1893,11 +2018,11 @@ class _Aiff(TinyTag):
1893
2018
  subchunk_size = unpack('>I', chunk_header[4:])[0]
1894
2019
  # IFF chunks are padded to an even number of bytes
1895
2020
  subchunk_size += subchunk_size % 2
1896
- if subchunk_id in self._AIFF_MAPPING and self._parse_tags:
2021
+ if self._parse_tags and subchunk_id in self._AIFF_MAPPING:
1897
2022
  value = self._unpad(
1898
2023
  fh.read(subchunk_size).decode('utf-8', 'replace'))
1899
2024
  self._set_field(self._AIFF_MAPPING[subchunk_id], value)
1900
- elif subchunk_id == b'COMM' and self._parse_duration:
2025
+ elif self._parse_duration and subchunk_id == b'COMM':
1901
2026
  chunk = fh.read(subchunk_size)
1902
2027
  channels, num_frames, bitdepth = unpack('>hLh', chunk[:8])
1903
2028
  self.channels, self.bitdepth = channels, bitdepth
@@ -1911,7 +2036,7 @@ class _Aiff(TinyTag):
1911
2036
  sr, duration, bitrate)
1912
2037
  except OverflowError:
1913
2038
  pass
1914
- elif subchunk_id in {b'id3 ', b'ID3 '} and self._parse_tags:
2039
+ elif self._parse_tags and subchunk_id in {b'id3 ', b'ID3 '}:
1915
2040
  # pylint: disable=protected-access
1916
2041
  id3 = _ID3()
1917
2042
  id3._filehandler = fh
File without changes
File without changes
File without changes