From b405a30c1d04b2545286a98f624383da342adeb8 Mon Sep 17 00:00:00 2001 From: Allen Wittenauer Date: Sun, 8 Aug 2021 13:38:10 -0700 Subject: [PATCH 001/305] Add support for ISRC data --- tinytag/tests/test_all.py | 4 ++-- tinytag/tests/test_cli.py | 2 +- tinytag/tinytag.py | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5c96f0c..b420983 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -49,7 +49,7 @@ ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'albumartist': None, 'disc': None, 'channels': None}), ('samples/id3v22_image.mp3', {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', 'channels': None, 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', 'comment': 'engiTunPGAP0', 'genre': 'Pop', 'title': 'Applause'}), - ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'comment': '', 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), + ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'comment': '', 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'isrc': 'USTC40852229', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), ('samples/id3_comment_utf_16_double_bom.mp3', {'extra': {'text': 'LABEL\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', 'title': 'The Embrace (Romano Alfieri Remix)', 'track': '04-johannes_heil_and_d.diggler-the_embrace_(romano_alfieri_remix)', 'year': '2012'}), ('samples/id3_genre_id_out_of_bounds.mp3', {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'track': 'Marilyn', 'year': '0'}), @@ -308,4 +308,4 @@ def test_to_str(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert str(tag) # since the dict is not ordered we cannot == 'somestring' assert repr(tag) # since the dict is not ordered we cannot == 'somestring' - assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' + assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "isrc": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 82cffd2..6b56f5a 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -14,7 +14,7 @@ tinytag_attributes = {'album', 'albumartist', 'artist', 'audio_offset', 'bitrate', 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', 'filesize', - 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year'} + 'filename', 'genre', 'isrc', 'samplerate', 'title', 'track', 'track_total', 'year'} def run_cli(args): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 26d35d4..4878f26 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -101,6 +101,7 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.duration = None self.extra = defaultdict(lambda: None) self.genre = None + self.isrc = None self.samplerate = None self.title = None self.track = None @@ -461,6 +462,7 @@ class ID3(TinyTag): 'TPOS': 'disc', 'TPE2': 'albumartist', 'TCOM': 'composer', 'WXXX': 'extra.url', + 'TSRC': 'isrc', 'TXXX': 'extra.text', 'TKEY': 'extra.initial_key', 'USLT': 'extra.lyrics', @@ -910,6 +912,7 @@ class Wave(TinyTag): b'ICMT': 'comment', b'ICRD': 'year', b'IGNR': 'genre', + b'ISRC': 'isrc', b'TRCK': 'track', b'PRT1': 'track', b'PRT2': 'track_number', From 8c4304175f38ad48fa542925809f29ec9c76f307 Mon Sep 17 00:00:00 2001 From: Allen Wittenauer Date: Thu, 27 May 2021 07:31:07 -0700 Subject: [PATCH 002/305] Handling non-latin encoding types for images --- tinytag/tests/test_all.py | 1 + tinytag/tinytag.py | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5c96f0c..2933985 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -278,6 +278,7 @@ def test_aiff_image_loading(): image_data = tag.get_image() assert image_data is not None assert 15000 < len(image_data) < 25000, 'Image is %d bytes but should be around 20kb' % len(image_data) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' @pytest.mark.parametrize("testfile,expected", [ pytest.param(testfile, expected) for testfile, expected in [ diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 26d35d4..ab92d7b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -728,11 +728,15 @@ def _parse_frame(self, fh, id3version=False): if frame_id == 'PIC': # ID3 v2.2: desc_end_pos = content.index(b'\x00', 1) + 1 else: # ID3 v2.3+ + textencoding = content[0] mimetype_end_pos = content.index(b'\x00', 1) + 1 desc_start_pos = mimetype_end_pos + 1 # jump over picture type - desc_end_pos = content.index(b'\x00', desc_start_pos) + 1 + if textencoding == 0: + desc_end_pos = content.index(b'\x00', desc_start_pos) + 1 + else: + desc_end_pos = content.index(b'\x00\x00', desc_start_pos) + 2 if content[desc_end_pos:desc_end_pos+1] == b'\x00': - desc_end_pos += 1 # the description ends with 1 or 2 null bytes + desc_end_pos += 1 # the description ends with 1 null byte self._image_data = content[desc_end_pos:] return frame_size return 0 From 3b596fa802d742a64555674f267a62bd37fcb962 Mon Sep 17 00:00:00 2001 From: Allen Wittenauer Date: Thu, 27 May 2021 08:06:55 -0700 Subject: [PATCH 003/305] Add a test case for non-latin image descriptions --- tinytag/tests/samples/image-text-encoding.mp3 | Bin 0 -> 11104 bytes tinytag/tests/test_all.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 tinytag/tests/samples/image-text-encoding.mp3 diff --git a/tinytag/tests/samples/image-text-encoding.mp3 b/tinytag/tests/samples/image-text-encoding.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d37b2f12502e7f86a23058e5e206660099e9e691 GIT binary patch literal 11104 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 uxpk-I{+yf7T6S9Qv-Et%xYKfs_IX3 Date: Sat, 28 Aug 2021 12:06:07 +0200 Subject: [PATCH 004/305] moved `isrc` data to `extra` field --- tinytag/tests/test_all.py | 4 ++-- tinytag/tests/test_cli.py | 2 +- tinytag/tinytag.py | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9595a5a..3b63463 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -49,7 +49,7 @@ ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'albumartist': None, 'disc': None, 'channels': None}), ('samples/id3v22_image.mp3', {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', 'channels': None, 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', 'comment': 'engiTunPGAP0', 'genre': 'Pop', 'title': 'Applause'}), - ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'comment': '', 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'isrc': 'USTC40852229', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), + ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {'isrc': 'USTC40852229'}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'comment': '', 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), ('samples/id3_comment_utf_16_double_bom.mp3', {'extra': {'text': 'LABEL\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', 'title': 'The Embrace (Romano Alfieri Remix)', 'track': '04-johannes_heil_and_d.diggler-the_embrace_(romano_alfieri_remix)', 'year': '2012'}), ('samples/id3_genre_id_out_of_bounds.mp3', {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'track': 'Marilyn', 'year': '0'}), ('samples/image-text-encoding.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, 'title': 'image-encoding', 'audio_offset': 6820, 'bitrate': 32.0, 'duration': 1.0438932496075353}), @@ -318,4 +318,4 @@ def test_to_str(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert str(tag) # since the dict is not ordered we cannot == 'somestring' assert repr(tag) # since the dict is not ordered we cannot == 'somestring' - assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "isrc": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' + assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 6b56f5a..82cffd2 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -14,7 +14,7 @@ tinytag_attributes = {'album', 'albumartist', 'artist', 'audio_offset', 'bitrate', 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', 'filesize', - 'filename', 'genre', 'isrc', 'samplerate', 'title', 'track', 'track_total', 'year'} + 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year'} def run_cli(args): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 1df6bd1..0d785f3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -101,7 +101,6 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.duration = None self.extra = defaultdict(lambda: None) self.genre = None - self.isrc = None self.samplerate = None self.title = None self.track = None @@ -462,7 +461,7 @@ class ID3(TinyTag): 'TPOS': 'disc', 'TPE2': 'albumartist', 'TCOM': 'composer', 'WXXX': 'extra.url', - 'TSRC': 'isrc', + 'TSRC': 'extra.isrc', 'TXXX': 'extra.text', 'TKEY': 'extra.initial_key', 'USLT': 'extra.lyrics', @@ -916,7 +915,7 @@ class Wave(TinyTag): b'ICMT': 'comment', b'ICRD': 'year', b'IGNR': 'genre', - b'ISRC': 'isrc', + b'ISRC': 'extra.isrc', b'TRCK': 'track', b'PRT1': 'track', b'PRT2': 'track_number', From 23bd79d601484856c95b07fdad927191f9203949 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Sat, 28 Aug 2021 21:33:41 +0200 Subject: [PATCH 005/305] bumped version to 1.6.0, updated changelog --- README.md | 13 +++++++++++++ tinytag/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 202105f..fb1e736 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Features: * FLAC * WMA * MP4/M4A/M4B + * AIFF/AIFF-C * pure python, no dependencies * supports python 2.7 and 3.4 or higher * high test coverage @@ -65,12 +66,24 @@ List of possible attributes you can get with TinyTag: tag.track_total # total number of tracks as string tag.year # year or data as string + # For non-common fields and fields specific to single file formats use extra + tag.extra # a dict of additional data + Additionally you can also get cover images from ID3 tags: tag = TinyTag.get('/some/music.mp3', image=True) image_data = tag.get_image() Changelog: + * 1.6.0 (2021-28-08) [aw-edition]: + - fixed handling of non-latin encoding types for images (thanks to aw-was-here) + - added support for ISRC data, available in `extra['isrc']` field (thanks to aw-was-here) + - added support for AIFF/AIFF-C (thanks to aw-was-here) + - fixed import deprecation warnings (thanks to idotobi) + - fixed exception for TinyTag misuse being different in different python versions (thanks to idotobi) + - added support for ID3 initial key tonality hint, available in `extra['initial_key']` + - added support for ID3 unsynchronized lyrics, available in `extra['lyrics']` + - added `extra` field, which may contain additional metadata not available in all file formats * 1.5.0 (2020-11-05): - fixed data type to always return str for disc, disc_total, track, track_total #97 (thanks to kostalski) - fixed package install being reported as UNKNOWN for some python/pip variations #90 (thanks to russpoutine) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 6d51173..2c00114 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,7 +4,7 @@ import sys -__version__ = '1.5.0' +__version__ = '1.6.0' if __name__ == '__main__': print(TinyTag.get(sys.argv[1])) From 309a15d26497c54c8541b304c2bb8e26d8aa570a Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Mon, 13 Dec 2021 11:31:44 +0100 Subject: [PATCH 006/305] use set_field function for id3v1 assignments, improved custom sample testing --- tinytag/tests/custom_samples/instructions.txt | 13 +++++++------ tinytag/tests/test_all.py | 6 +++++- tinytag/tinytag.py | 2 +- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tinytag/tests/custom_samples/instructions.txt b/tinytag/tests/custom_samples/instructions.txt index 23bd8c4..4f6dd56 100644 --- a/tinytag/tests/custom_samples/instructions.txt +++ b/tinytag/tests/custom_samples/instructions.txt @@ -12,9 +12,10 @@ of 44100 seconds. These are the prefixes that can be used for the expected values: - sr - samplerate - d - duration - b - bitrate - c - channels - dn - disc number - dt - disc total + sr=samplerate + d=duration + b=bitrate + c=channels + dn=disc number + dt=disc total + genre="genre string" diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 3b63463..7f4dada 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -116,6 +116,7 @@ (r'd=(\d+.?\d*)', 'duration', float), (r'b=(\d+)', 'bitrate', int), (r'c=(\d)', 'channels', int), + (r'genre="([^"]+)"', 'genre', str), ] for filename in os.listdir(custom_samples_folder): if filename == 'instructions.txt': @@ -128,6 +129,7 @@ if match: expected_values[fieldname] = _type(match[0]) if expected_values: + expected_values['_do_not_require_all_values'] = True testfiles[os.path.join('custom_samples', filename)] = expected_values else: # if there are no expected values, just try parsing the file @@ -138,7 +140,6 @@ ]) def test_file_reading(testfile, expected): filename = os.path.join(testfolder, testfile) - # print(filename) tag = TinyTag.get(filename) for key, expected_val in expected.items(): @@ -151,6 +152,9 @@ def test_file_reading(testfile, expected): if expected_val and min(result, expected_val) / max(result, expected_val) > 0.99: continue assert result == expected_val, fmt_string % fmt_values + # for custom samples, allow not specifying all values + if expected.get('_do_not_require_all_values'): + return undefined_in_fixture = {} for key, val in tag.__dict__.items(): if key.startswith('_') or val is None: diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 0d785f3..a8b59ec 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -699,7 +699,7 @@ def asciidecode(x): self._set_field('comment', comment, transfunc=asciidecode) genre_id = ord(fields[124:125]) if genre_id < len(ID3.ID3V1_GENRES): - self.genre = ID3.ID3V1_GENRES[genre_id] + self._set_field('genre', ID3.ID3V1_GENRES[genre_id]) def _parse_frame(self, fh, id3version=False): # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 From e2ccbbb8c5855077fd1596bb45aee0b0677f568e Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Mon, 13 Dec 2021 11:48:32 +0100 Subject: [PATCH 007/305] fixed ID3v1 tags overwriting ID3v2 tags, fixes #121 --- .../id3v1_does_not_overwrite_id3v2.mp3 | Bin 0 -> 1130 bytes tinytag/tests/test_all.py | 5 ++- tinytag/tinytag.py | 36 +++++++++--------- 3 files changed, 21 insertions(+), 20 deletions(-) create mode 100644 tinytag/tests/samples/id3v1_does_not_overwrite_id3v2.mp3 diff --git a/tinytag/tests/samples/id3v1_does_not_overwrite_id3v2.mp3 b/tinytag/tests/samples/id3v1_does_not_overwrite_id3v2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a725b8876697b32eb614621ac8e1a4617eb11f13 GIT binary patch literal 1130 zcmZ`&OHb5L6h0N^K|=s#9AE^g5@ZG;%nT2m7#8!kK%p7RGbUK47pQhx>~tWrAkhs* z5_bLp6B84RKR|;kWuu9SI}vv&A4eovj4NV3&+Bqd)QfBacGhoy>iG6`bmQsGXWjn9MT-#lhcHR9nktzT z)H}8;N_&|y!^Jh#AgY1dFYnF&d_DIf@w{#5miu8~(E@Ikfm5n{zd%y5LRbmm@}f+1 zhyn*`5McpikYE@xz?64CgkT5;Aq+vdVWC!R6mOxza1BvG7=Vb4GHjGkMh)P32$crm zI&J`~r^|NpibAr6tf|=llO1h$cXeT8`$2a7&9AKw7S7qy2w5}Ca{!j3f#d%#@ zl_mRCwSDz1P?$P+c*2cl_qg%C_ZP5_KjF*Vc&6 zaKsQ5ffd4-Ifo|5Il{~=iw48ynNlER;-i=^z42g3m`i5RaiE1Fkua7i@lGkjPnFuM zLyO~sE9gZmg!TCeo-^}YAunfRgupwDL71JLHS;hwF`Z;GQGp##!&sDO;{dB4@eF^W zCDM0yg)Cuk`1$Y)G>oL!6i^fe3EXhF2XSy#JDiScr_0TD@MThv~S|73dDMwD$&k{lQLuz+V!fTrL;wrW Date: Mon, 13 Dec 2021 12:48:02 +0100 Subject: [PATCH 008/305] fixed image parsing for id3v2 with images containing utf-16LE descriptions, fixes #117 --- tinytag/tests/samples/12oz.mp3 | Bin 0 -> 43062 bytes tinytag/tests/samples/id3_image_jfif.mp3 | Bin 0 -> 132000 bytes tinytag/tests/test_all.py | 9 ++++++++ tinytag/tinytag.py | 26 ++++++++++++++--------- 4 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 tinytag/tests/samples/12oz.mp3 create mode 100644 tinytag/tests/samples/id3_image_jfif.mp3 diff --git a/tinytag/tests/samples/12oz.mp3 b/tinytag/tests/samples/12oz.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5d591c3bd1b2029c8a9674f3477aee823384309a GIT binary patch literal 43062 zcmeFZcT`i&yZ60QfB*qP2raY#0YVQ=X=)ORp&B6cBE5rj5L@US=}na0i}YeaKtQS@ zAcBn|s0er?f}*0lc;0uNbJn|_XRULd^*g_Ho^|fwk4%`^*Pi+Ana{rFnpx4)R)PY@ ziTx_o%F60E!wmqC5VznEIoAszA#SdKv0Ip1ke`>oD?l|f(>bodc3i>GDa>8vq@1df zoB|#X5Gi_E#}@F%1zvtm9&Ym90d5}0HIEjKmH~vmuAVLc0s(;I@gH#X8(=%W+C@c0 zPDSzfYQ>|MfEECwr)Qw2gE25LFfziJ;3yV2Gc%kQ$-#yaKnn>9p!xYRSUCxdh>R#d zKklTY3|>K5Sy@Oz?Ubsbrks-UzZd~AGBU!M;XEuXJc`2n!ixXtbkq;9!vI&{90Y^} zz(^1T2|5}A_y7<9hWu&&za~0*2$TW*r%)LIfI(0I!T@4`(EUp}2n+$BbnHlB1$qt< zqKQ*fAWBq`{m`1Ole}f9)NY z9h-z68zj;xm~ccXIt8{q70E2y`F!*OfFBzHvqRW{Gr(VarGP07qz};H08}YpAz73B z=0u(sQrV$-3&oaA0Od9G{9bgv;9uEwGHgR?^0QpEg~e<@^Yw;M$ig&WRew_Y*2oI- z#Hf9vxG70IMQO7+mnd=moI09X$K*qO!#&AILf#m%uoj~nq|dH?$ya>2FeRC%!fIh9 zc&bi)VHM&M+PkiQU%fYE?Up9`#^aogLm%j{^c?zM+mt%#zPb9l^T~d!Bn-|H2hF~LP_AD3J4Z_So#&*c~gh20q6Zn z-<*H;2w;&=VXGrrteV!oD;fQE$#;=ape?9#?XcgAK2PZfLO_0C@r#S~w)36wH!97i z%wOBaU&`Z4e)T=(*SQ}IpG2R`RIOV-Q5&@5?QYLK|4cS>!*sWCy?8|q{?b~%dH2Pc zAg=M>Kij|0+qt#7iC=Qzd25+R?y(Y3Ae_VeV(rDhUsLy&V024}O^GDI9?mW9&Ab-h#C zU!MmJmWg_%dW$%SuJ9X%_KAdr-w)cbe6z43qhWR`k2RuO^3-AW$gpcP;w(3#0P{_W z*~H&A@Bex>r8hhmZE*QQ$)-A2_9qAH@mP^Lu@X(xSm#3RR}tset(`xW7*D@*EtaQ} zP|;U3^%>?$wCv9crL_&)GHDJ!FoiF6Qo_tuO*liqHsTyU{>rHKW4?~CdBj7fi!Q9EIcIcadyfEr##3w<7wnHT-`}!x zlAxRhfEo=Gb-a%E{t@?D{jO|DbnFM5{cJ93?ic4R@fDf(nn^u|p6FI=4evfpiH;OLyz^x(e5JWG$|LlnEXQBp)U)VvpVxMr zd+@5mX{$Q%)zTYzmi4#sp>KXx@@Jj;$&%e{XZWU5+H63Ri2_IxfGPl-0~}|A?-m+8 zJ}(jU$gR0Zc&n90yC(c(tT9qDJR>zt@%$^_Yr*+KE5oLtjP6~UqPA<#!oLihos%e9 z*zvi-Z|U)ZO3*sCBPgD*jnLH8F=&oqt-qZ3jp6L-{dt#@jFQc8K9WDu%nqHOD)xPB z#ZR7(q$et{46&0_Q$27zJ8ZFIeM9qvgtW@uh^lbk4y`KbFLogUtH%w6-{*2eK4)ba z1?je3EaRw4&B@AIOLP4~5_{YShAn8;Q^X+K{{owV;EO))QJ^q1Nf z?p}m1x#~6ZY}fSsTx9(HZt(z(0{HUszlMK!;t@(WDEZXWm6c(~NB<|tBWy6jaw~l7 zvBup{W|5zQHclU#T08-1>z~11FRT+8?%q%GF#9n}e*63W;(XDh_Nj-$bd{>)-lZw`FBfSwAq4`{zq-UtFBGe$z>Mt>*=6>7IF3 zH zm6DG3Xn!vLrM=)~{%JKo7xy1KX-7cOT-%Vnli&*t=DKE?h-AGB0ppiCHH0%e*hIe@ zk6f2!f8yxN_~PFN6??C*&l^~?pMcY-Epl4_p?CN}X6G|LEtduMNv5n);9YM+?bZds zueR2Wc@E)WGrE@*FHUeoJ;aE9#%ZWC*e;xsDs!;;(LlW_c9=~*nb%kUOel#X-LLkO zY|pbiGS;k){ZOW665r7NHJlmym&U4Zk)@{L6gcbA-0r5`!%4S<_OnKD#l?3g9Rw#g zcG|OQqDJJLS0O0Ro)lfPp$a`|$(@myG6Dg+hXqbPv0OLTh9z@SJppB)enQ$yE%KSh z=kKyi6B_@T{pzs$lH-NlmHml=m8|&)-e{%Z)wG--hNtzz1W=h%e%Baz;)1|}x&7w} zAFTe>xVa0f-5>tE2>%_F{{LCFqs4!F1t0xf{p%9~kN!@7EAY1h|6>&R8w&qp9`)ZH{absf{~`qr@{T91b=g0-#mA09qxLZqeuBst z0>E5E;Dy2f5SdCR1UU{V7yvFA7y!z6yp_uOB>37t_}+h;`@chhm2L z2>iEtWZ%Q-`2md%<$U?X!6jerGrNAjAX74C|Cu%p7$FeA@Vu4U1?#_l-M_ZBjmrKL zXFNRKRbV^hc=VP{;l_#5MSk9Q!bUkMoU$t$19+B-mdQ$0spmFH?EqcGy}D7$#7`dj z-1n~Xd*Zq#*FKbji+?Jdie|eoC35b`tAJ&VfoWXd*t=gxw>BUAI(zx&f2MhV*Zps* zz|Olc^6M8gzed`CbDwOWqoNTl@siM|uF8-Xp!%MYOKqxJsqU6XUw+rToc>+2^xw1> z|MX_nyfxefm_|iKMf91#Xrf6w2YjSNnl~jIJ&vm-E-}RsGk97)X~58z;8osU#vU4Z zukx@r6JD8q?G+2TVaqh$IGPU9*OD1d@PcRD;n5XY4sOy?f8u+_rN-x6`-A%1>@EwZ z{Csgv-B2sLlLGFXR|qWN)v?adA3i+U^ zT-mwvJG5E^sjBU4o>H+HKd`)51-_tZ_42s`&ph7t4-`o8e^w8&qnkx2%5E&rYmW$3 z#NI$A=CHdk3{>dui092z zb)8U~BUfkW@h2s^PHBB zcs>)P(}W@f^_@p6KR^d+dDQt)0-%&r1KcaWtmvmGaGlvx7g&Y>Np{9YQ$` zOqxGziwfw#amvqF1BoAp3tWn9RifqiwpN2XMfU=R>m*$s85MuRK!U99|F|f2?Vqjq zA(OP%Xdz%N7kHh~{f6QxTHOh6VnC@bFAzoaFZv+h$_L5Q?gjvJn7LSpzqqSRhLPtq z^_8Yqj5cYiV5kBRF*C4Q>t|rw_@* zD9|d?vvNcHfu{)1RhS4LpZw?_!)r9z|%Kje;c|lC&@W-1+zSlm=-_^^>f+98>g8FQf zZ>oJ22-;r>@I9PS4?X&)kCK1(sQgdgZ#A*=c?&}a)gQs++~RE5^dYCr@7WcMrvmbM zMVqR)=P1WkK*z^ljYOjbDW*b@7IW$tcRyBfSNXk;P|AmtkLJSOhKBqij7uPolgv=( z%N@mfoma=b!gpimB`m!JPT)FaCS7oEU%YEV8O|$^Zjl&(^F}Rr27~+KMwx|pcdm(l#GGoshOSu zxTg~z#66E1DLhUIaf~X}A9Tgkr0gChI48Jadu^K3Gdyn!&V)CV0ArWWxuE@LzeU&3zvWAr3nG@v~ zP?Eo8rFM0db#>=WKi$ERbKG)$0&@eVI}c`-^2U25n{uySd9A7ao8`Y7W@wpSYJ6b} z(|bX>3nAoXRsFk06)@Edg$aTuN>mTvN}tVu4x8FRVPNe$Uhhc!JWwiGIWTZ)wB0kL z1>;KF$pkV2risB&EF~~j&}{@vY!8{ouT0n}s(K|q)fGYFZETb|gLzL1gT^Uv@)N6ntfnJr!Kmg^kGJbKHM71@u&qw;p$ciyeTKNbl-_^_Hs*_3#rZNUEnNj-Sy%7v!gl@DI?-^_2T(fhSY5G_{L(V&Whye7j@DJ3wxM}b zir@8}#}E)Zu4ZUfD%VzrjMWr9YvryRUp8G!N)Z9?_dRy< zW@dd6djHEmN^!PjiRGhh>FyfNBal7kS&6fhzvSdu_K91f5k|cCKG?F z7_8&89MzG-AbF2oW~kXnE08*cnpbDa#i+$5Q}ASN(H}z<(m?hT5$C4Q$jo0p+Gb7d z+t(Gwh}*dN6n53@lcsTj9uFE8w8lWhd#x5c$3@{L#lSpc%fZUWgJHsG;aqN(JtqyV-XdAw z&@OhtEooJh2?dKUL@=i}vKCdQXqu%u!PisV?$mMn$-<1mv@DM8PvtipH-Cd1hMpx( zHiloN8(wts8t<=Iw;wZqRyGG-umxs80X>=!XZJ zq+D2G#!zVR=tm{7m#Ayy`W#0R5uXPY%(H&$8;$X}iIQP8N-z;3=8@_w@MwSMd*6X1!W(tT3uD(zO4MsgjAaiS($mNh!o`*<> zoPF6^WobR1`wfc;jd6rt3KI(0dtQ#H`5}QrXV58YaexwyOC~a7L`L=jl`&Rh`^tT) zly>hRPO3-ObU?N8^3iNKI^dgD-&X0Jhxh#NX5FEDviG`(t$ewIGp~MoTK0~y^d3P( zsq5aF{BI8SkJtCUdGh5n2p!7Em1@e?AucMaeV6B-x+OA}jAs)R{R4#;_l~zBZ&J7ewR|2i(M=w48%;*$RFrU1xgBVQqFf1gTrYC zoY9&y)V4}{TK2Lo_#RWP`PuW}mD3eotbGQGFuK<$kbeVvEV|{)hq&iwNoFj8m=6?=?#6EuoNzLeY4TZ>+lH|wckecYWYUi_QZFaXV|jClyB|qDWjg< z|ELJX0Tfyz7DY`(WCOqlI{Ba0!?u|}n%krPt%+S#{>d8ri;#C}RBY-e_7 zd#T#8r9S+cJ*mUVS667$HFTam1X=boVVnCh8Q&V)<2l|?@-p6l zlBKtLXw(zQwygX@id~7QF1VA5qr+JZb2tw$ayO|#9-fGo>17`vf#~G}#dFmz$7<`@ z?4DxNPzAwRN}C~goXEyixK#%J3!GH|hOTSwXcuND*bmEycutE+#&2DhlvreropsR? zK_t=oaJGI*R?gFLr>kTYlSJcnqgwDGcHl86V{$IkN@OsB_e_78RzbavyHRN_S7RHQ zF)Ss7_MRq8M`u5`(H~>*Iy-?*lrPI)VeMrcZbFKZx6&m3Y#k!T94_A*n5VymGi@nT zQ_TLZ&TnvLQ|F6sL&Lt7s4bb4ReZ^e^RAZLV47mylcP;Kpz-SulZzkCuKl+E^nHBk zp>yYR@%6jQ`^pN^EVIK<`5=Odm32tgwl8~*A7CANRTTmc9F2GO;gJbR`XLvn<=6t; zfqjMW@S(Kj76T@14x?{HA(yW6vc;z$-7B38M?Xx@d+sM1&$#FfZh;LMk<m8Hto*LBYf zIqg`9u$qChCTNa7-reh&sd$$)l(1*{2MYJTAA1TADz)9}io>wJji1xTqNA{f{*({h z&oZfxlEvbE=a;E3>6yUF(8u23+T0=#=DbHKnvpQwa}ljx-Crpb*E)Z z{MsX06{*%-p%^cRLc<9iD`UY3(t9uJ%R6vxmUqz(A5J|9E9sAE)qPH8Vn8YQh1{R$ zCxyBg^nftjH#)9ws{sM%hT1P{*LTJt2Jv^1VyaZ5jKHS4%W@x=wLE{^v8|707jSpF zCG_?bA(tnrA=#cdT_aKRA-r1eqlz{16bbNTE;JmJh7&UcL~sIYqeg7ci(V?udUv1C z2eEVPWKL&tp$s(_HJdFI&0`}e-&9G}$dtf|bLStN|3^9j|IXGL%`UC&=F7{NfqowF z73789`d2?Ga&GfIBVVIAbIK>^kbbkp6{HLHKK?WB#TUdxgxXx*BH|o#2cKk7)EOIR zvR~h7isg7X`K!$4{NzRv!>Mg&MK_8?x+;{>4zjh$sq2Ila^>ZJ{Xlqhp8lkMz*`8W zWqZ#%ACc@fTEL7P&$kKj zb31DrPV!8#tF1Lt3}$ft>^d=l@v)FyfOgB`W1Nr}FLF{SavbmXbI zN*R%ZN-!wPT2KdE(!>I-bu5)~wifQn9xY<4yL`)SJiWPsLjH=%=w0;Rv`;Me&(sO5 zgWjZvdFwfP(yd6e?n2VV)N&f5r1X_pLrF_A9QpPYVzbpvGYoB3wp{^hmHA?N^{EE2 zR7I&oHedLTZbWJvE1P*<6_3&jXvV$5|WZ zMO3B0XN!hxUK}31I5;2KZFcY3A-kqc$$1^|x2b0VVIIfa{)R%lRn8+Io_bN!qEK-Z zT*9d=H;yD!7K+4&DNfVq*81``ZO>{cm)#qI+y^Ln@_p*RNP* z1{xVeci3csEkt_V#eT#4dmm-}EDyf-S9;uVI^B4+-c!`z67QW}_sSufUj1W}jAS1? zih&OOf)}R8)W<+lRTQ>m;N7P)S{J)l&4f=-Pg5c=t1TZvs`vzc3isAhWc{R@y~ zQGR!+dX^d7FOwEA8J(JwP*FV=Lj$!iN-hy;!dTH*s%={SIyDo?t{iO>c`Zld4;1z` zkD+kc)T~|gip=PEWRlan#asL4rd#g;rOS2R$541cbCj7`PHT*40SxW5V7a<^we0K& zij+^o?x&M+BPQ=(f3^24wTOkib^^*D(fCmhGaiLZj)P$OGdc(wQzN_soI*%;qQvoJ zHx9_BxT=c98BxXB_m^194k$4NJM<9@QliHJXjz6pnnK*=KHd#2;60oZe@4$NYiELC z=Opk!uL^XN0q2PX1&Tk!!bIV(G==``^vqe@1ps ztUmAds&B#aiag57ss28k>c`8ma=YvflRB^>>L%@K81$Hg_x7gS(CbBYj#xRmo^HCM2 z&6;J00>jkkKl8Ea5P38TCpMZus{Y)_p=A};nT1>2Dy?LQ)g@M<3UuYpbvX!hWBy@5 zSE1DN)tL%qICR(`nnl~KHfh3d-0X^Z{vB`1(I~tr1vK;e4snACi%v3 zKDJEx`dfVI5XHG@2E!bu@`BK%TkBb58+`*Uw;&s9VEW$a#kGg3EMnAL>hCHRjyT5ZSka)@#G&j;oc(uDUxZSqoHH zGfe8Tyjr9PSD=wyb->1%s#o+hyp(!N#-tw|G?_^w0^$jhOH;OOyJb+}75Tut_o8BJ zO?%$%#3RW3h*{s#2!%=-EYaht%rG0b#DG!F95fYLD#csddbUd?O8e@MMaU4)z%gJW+<9jMQ zuozKrTym1H3|f*#Qospfo+tC%kFH?KG}P7ZH5zj$GXjGPCNu{ExFr+aarVw$3zeZ!gSg1kAnEAt@Rk~@$pnf=ihfLzA zS3$S4h}KS{P%x2}q(1RESTcFaEc=n0X~5EjLh7A!tPlb2s&@?WV0g2GoY%roe>nGp z=P6H|LQMV(sqniLHq-e+Dj5?sSXizPA2lh=z!0O#2Opd0Trlns)0(a^$>l&;5-7`= z$>6Nfde^u*1PKDLm=_j6Nf?QsO#_j?5E(sw3uVJ&NHz8%ENW~q8$@ync#ds4uk>xN zgG0)>KysLkFvShMA6TFx5E0~mKGf#736G=ScctRQU4a>IN#MSpQY)_e+S>J@LtVoe z%SkuuOQEC5w#^Nk@%D`3vp#5-PwarC`c)#P9{6%^r|h$C1Dc zjedYIbmH>B8KaHjYccR{aa!9`#;h67xgakZ*H#DWv93i9I8cExy=7`z(X$aI)zToc z=atf`fPnX7^-niRU*yeRj&LF=l{;plC?Qj?@Al7BK1adQA+yOR5bU}nDU|Gl_>i`C zrTpe-InhkPf@d~H0;fS_$`oD( zevr>Y7bvBSx%%Fdu~t5BnHz}D-+?=0G8scp0%LczqKp#s3CG8pgG%54ZMBz8xEH zv~)L7Ki&_}XdwF0$E8CR-31513^4WCE(`A&0ZxJ`Z{+jT&hEct@5q6*E;BRs@>Lan z^Dwy#;tnAGaTE#-j+r1#rD~}4L&qh@FFL*3UkbG}-hNN_GFwVYvQfNGYpq6Xv7H^2 zP_XhP+g_Ux9wzsF35}gmH#=RpCTDS8z%&ClIZHR*g^ai z*UjFmlb|eT^L+nO3@ZRLhjT@P681{Anwc3eEtqPOFsZM;1cfsr&l&0%mw5^;`2=7pu0fs|T_1KJ)rLp*icByw&MRV@8M@!_=L z`_t{%ejnM*JYo1PlBjnr1X_7}z`EZkD7nIwmnpPWV8wy2H1C4?$$?%CedW5P>Q5UE zKVaULeBJvpW(!X?d!|F87o>*&tC$cSJ=b)^{xl8~X_R?<@PqXE+}=y0YHZ8)7&B_| z)T^9I{q@T0>wAIQXK=pp!@EqSgwVtZ!i0E3kVi!Mu6K=8zJj>sG_TL6Zr(sETX>&o zt6;qQ7_X9R^u%ZBd_4WTfy{OJMKR~jyG=HL``3MhJN)CU`Z8z#IdEf3+o%w6dF094 zh)5VrDcXjaqC?wafBR!V=rkdyZURc-njLl!+VmB5Ri%qMVR_o%`p=tr-n<@>11VqY zypDJ>)`mE+e!R6PM+x}#w}|gzV_^Z97SQw98xbU1u?4$1v}Kf*p-PSws)!?=%{)k$ zqKYJaGq8At2J=UqQ$igc|03)eS{BYbVD9Lqg*PTIlNkX*ZK%$CA%VtWm9Jh%Yx1HH4y@Qv4pdvBsw_M?nB$>+;XG_ zJ^htsXn|8Q)0E{4apBWv#GQdlVu@p8eb5^g=gjf+Rb0{K4q^lug^Qp{=ZWuYAqI|Q zlZ^Z-+`5d(Qwx>HTxFl4n`c%3_UzgC z7LEgIWKxD}!83M62LjQh3^EZ=qbr%yrTJO>-C%r$qI%7Xk1n@=D7^H9?Jr)$FW1Ey zWE!+KZtFbwXn;+}!ZuoBR9ZzG57?3ze>h)wcKr_&D(@acA;R3k%(}nt(Z)wTr+4p{ zh?eJMJs7`)OKj8?qx+n^529ZNiQ`3#gU8Sh4Rd$s0a_clfcU5^0K&D(v1lg0x1p@U zh+5WZXo**Z#^l7!$<; zzFm76w$=)DG}eA=GF-|cbp^N6igp{RS3aS2+CDxf8hgQ2pT023;kEt%2WbFpcJuOg z<9Kpb%JLiLQrQISh(H7tcL34rQ2@*8dnABvfBLPy!y#2WAdZ+mu!v*1ESmrlVc*a1 zm9MjsT`8?{2^(=Kgeg^<%?8dJI|V2D$>&VhP70gdQJ>7 zsY}tvq6DJLdnv7L%d;%7&gQaWp3t~vDWK6C$`9c*9ullPQK(@k`e78?DZ@Ts+bnV^ zmNl3a1s|ujmp(HRCbyk;L`d*`l;NT=iMCDfizVM-Y$+ZTmM28vq#C$$wdVZ7)x9uT zDG^jBvoqeN&Dj?Ry=A62bz`$Xu*yD7tu!G#H&ms$H{|?gPc2A{L7Ox3eE8HJ`v!C( zu~z)!+HE(!EFNaLV%oe$A3=4(*s?z=68cm{x~d$&U(iOel8Gw@O8&k^rs^*fhusXT zZtCQ`t{vEw;=F&gNbstt7fXsuvZm=@M-6ee=b2=!^?FuWcyN*h_~5oR)qL6Hw!PCp zYBpQpReJ(Q=|@CPN&1~a@ruaoq$ZpayPB@hIrx1*BBIpC$ttPisL zOJ)FObx;fG%T4|&q!(B8DU~Lt7%iTHbmL9G?z^H$8@q{t@+rU0qo-5g^2Ysa48KeZ z%3HhyqANP?u}Y1>=r*e1PI>)>E4XBt6?T{R&1}-%cn`ul&<~l`HDHsXbL{=AghljS|%dx?8-fR>(zcCo&DXkMFsgmg0JpP zY+$z5bd)xWVn;3s$?2G9cN(ASP;QB(`>e7j$*|k7%5+suW%=!qY}LF+!X3`)EYs~n z43G0SfuVOSGu<`gx!lkAqiR`%Y2pO#CMdAQAKP*+Z7j2JYl(NE!-Y+bmltTSz@ zU+?t}(IJ+=%)&=U%VjCT&T<8MzpXnd?N_J|esk9Cc=Z4U=;8s{xO@Fs0z#9urP-~#xbRFQ=tRrZ|r8>tM{b%3=kpL>Q5rV)Ix z_GwEp#f8<2Xqs=zFu)N zL$*E@4@h*P4x6HwDEcupXYh(NMCa@V4KbnV0Apv=g-9%_-X7uyRMrKO9pV@Gh`uro znI30SU8H6_olkjXcRnhTNrYkKZL5EslO(d2R@2FHq8YJ)*e;h%_*?UH&{Vmez*|=o z*Pi$oW(g8f*@%$*an50sCuJYoesD)CM(rz(WAEGJ!&`r#P&<1J1$DDI19U%ps$R;% zgY9~Wn6-(~v8NCtwXt8!Cx@h?a{Xn4cX`Ygd_q7Ws+32x-pHASXQ=J^AP3_mv{%8W zm!~NqpXH+Y%9&6K&TiG3nmGCcPbW4XSf!KA4VlW*#&|rn*P;avP>S|A0YO}fiGg0H zqXkm3N7nvJ_;QzfQd&^ja184Hp}g}%$MJ6+zHi6Pnt4eZJ)vT;u1Sc+0=_1Md}Qe@ z(?YuNCQmIU9f)x;C*I)Can?+@oIk z)OdecrTV@4RFa!w?Mk4dCr(P`W2V1{_^g*aoVr;uU>-NgEf)eq{3cQ&3ni!$_ z7jb~(I4GynCt~VcECv2Z3xk~Qwrtbx*k{+`bPHuSca4U+HRc+mG-FewPNV^;U&U_( zp0ACxEv?n}2B#LpNFho`0jAWd0rw=3%+pn6L67gWN4bTHRXmKYT`O_sC~O+trjhAx zb+mm`RU~cNQ_MJPZO;E5c7OUecfwhkuiNu(WXI;Muiokg|Rf>P8lRpB4=hxQW5&DU2E# zWJh@>ni_@n-_NF(s~zmGP+wC9X%&W}#|`U(87vjb-;@@1I9@P_q{4f32F)nL)1{-a z{fsC$B4tmK-BAMRpdVwme;WV8OH5?}TNO7!@R5|Sx3VXj{>)pk%DRXoA!8`&L1w3) zQk9c>cB5vcnYzztS@<&zaY;3Fyj+{}#m$T}@U=`yCS7U&;f{aKyw9 z6Fv)YkJIL~k&uyU@a=T_BzFmHxM%KqZv1lM3r?bU{}6prJT4N);AJ>hWQZl5r!#J( z=dEM{qu7DeWos!tGieRHr|5(@p!E{_v74}Q8y3%@=4i!i+%5-4YBVuuTrl1Blu2Tl zMdN$)*(6;?i;J8F^AzazZr1njzu?LkGY!$-K4sT`m`2aCNopC&L4%#mwh`Tx11aU% zb4_Jt`mZbq73WA?iy8O<1gFdiih*(~F7lV5(8k!&flAqEDEQv4wVV8z$cdwUtG%<0 z*Z*z){#WG=+DbMgL{kmp=|O0+iw$x}Fxyhkbz1LH=nH?x8O%rideIj;c;!L3T98R3 zuRZRQl(wZ@G_owdYXX+@vtggdbcAZ#)dNzebjFQVrL8EK?^Zj>o8BYsN6MT?;;<6; zpL?lnUZ?c!9l9znKMCjI=yQRj!JrV!P|BJ_gA1RQsn2)=`U=CUeYh-yQRhDRJm}!T zY%6`~rX_LyO^tS9j#%ht`Y#z3sySIQixgtd7kT0x?a% zFe&Ch&MP-C7!#lh)LHTI!vtT_MUeuABom8iu>;FD*4a4G!Z}Ys(1$TzmJK}7H$;*C zIFMBpSPVSlcOy(WClMfJy}HgKZQc^;L?@sggmXor8V&E03o4nL)97x|2{N6l9Ck8Y zi@x+Mi)`ESa1kf<*%tQW2_uk)aPoQPjA%b~3UExuY3LcJN>P=+Mz3&cXI16tR-blZ z=0}m6;W|dNjn18LE=-9*5i!1_jAK}c?&1rMK!@CIs$)xWF zh&(;sA=nA^O!bLEyp%$rfZQBYXMWeWj?251-M5dPiKRs;j*+}>V%KtGxAaZr@U828 zx2N%YqwF#(-gIaI3U7|EJF!7cnt;KlefP{s%eXU!=AZdp8nBVWc)oLQMgSlnz);-Y z1SvUA2v%EW8$&ya%SWFTTOLEbLO}SU4D571%IbTm4;gpFoFj}6o>)Rz@W^}L@57bj zl(k@5wM9+gZ)!4N%PZ3A5t1SsN@s;7VBqH5bRohe0h3aud;>c!jo+-%E~51=ifR`= z@&w{q*ilH2JcjTwd)AJ%XX%BmE0WPv|Mm*0sSShFChRI{V860L#*TM)O(CE zz__FJ2MRJm$51FVUpS5_q)tiI7ae;Fw`9$Yw^qJohe{noLA>ws^K6`S)X~$cXKt}zWTuZt|V$xAq-8>>nwL0 zUbwbbILO+oJij|Qu_iS)lka84k##Xq&2J!HkvFFd#H1u2%iYm4;lxv8g%qhbZ<^d& zX5mf=9zJICou*c6EshOv(E=_3#rl7sH z$|as09ff`9N=yGo9bEbkASGFvn&q$haPCEB@~K6f&YKW4)`@6Aqq$uk)dRF$FA+zs zet``K=b|m^@6}~Cs)&L8BzlB>kIw}&{ncOqG2AgpgYbAN6KbTDOhOA*u3BX6i0HRl zmE6{TpKpKZS)NFcgW?InjLD?HrIFxXlN>qk`}56H3g-iCh`KOc853to+uz4wsk)T$ z!ErE?yKOxr7OO+3k9-k@sY=>97FQUkksg|G`*ua4ky=GBf%*l3khr{n|X0R6g`E!GI0%Jt1 zj6X#&@+@d>RN}X!_R4LX2y{f>XJf+X7)mRG!R&&(L2j4A*t>1N%;GNpl zYmF*8a%LR}DCeA9uZi@I97-%3l-)$EtaM_ka2Y4;DL61IdrI!syit&R@JTC-QH02F z5!o#NwMP^TU_yS-&_SnpXB^@g=`h6>p0_4B`zb7^ZbsExE0p$16*>>rC)JNRvQ4 z1MidDI%keLo&P}LrN}W9a!lt8tS~oB8p9M!1q!eeL=&6riXarrUfN zEJ9K>yyJA)g721xv8;8wbDSDLWD9=nHF_N+4HZp6`R?wdo`_rk7l4?X! z1-VKOXV_)yMy_EV+~_M~U|kRBzBz!^q80P5tGY?VD2Qj5uQAks=pj$#1ppS>@9^kK z(|Dd+?Lx^jZ{nJk=^e4eU71zuIt%t)JN!@pDrzr0#a-8PJm$eJ?&p_0&}xjSKy}?g z)c&AbzJPl7?v?k#*Zu=@`5#qfhuuF@_7P8(jG|r|Cs)zS(n2;Rgxo!vp+3U)iY=*goGg2eP-2QvC)k`!ko<0|%gr|lUh7v`p3n{{5eA+D}N zIiUcer%%wilchajQu)Q<+nmZRRdED$m8K4V{B62^q^rsgOW#%@Q04um^)a9R8e?DP zxe#8LkBJtji{izh0C|s@HY4Vvs8ZoAta$X|mk1JOZtggn91U^Yy!bM;dgk28YrlO; zj*fmY((42;DGx`IzI?_eg?>96MM2Khy9SI4vft+I6;1RkviTZ`BhdDCkcNe&np!gn z!>loupPaopl&w=Ip`3I!67!&2cAr1PIecdZ}@Q%sMpv*Tss}v z0YmZb-LM^IJpVyL+ytPgG`ERYUr#5uL2t#%Ty(A##$cHwrM+a#bL-kHYNI#2TJkq^ zC6P~|!qM(NvG=5-0RPtu6|U!}2zTqPH^j?of77a@#%$~) z4(|;+C@si6>`=Qt@fBVqK?7p(!l~tc~RNA}nn!YHF8)vO(ruBtJ9$J zfc|yDxdx5L#!=$vKTzm$J%+-7IaR}YKy$L5$;*YM$u$&LI&k#I-_zJLe2MR7D2`g~-z->0v}dKC~cgakrpN$5?wQWX#otfBW7I--UuRS^L} zEOe<#?lKQjbuVvX}yuJq1Gfr_84 zN1-JK48%gJcC_{wLY`8aE}RXLGRQE%j%H@4afq#+p2Y(Xz3H*Zmizec*#k=k$J?{= z(-KeJ2#2I(1tuJmbugAhZZ5Oag70ygIZs{K`TMKsMbzrRsW$cLs@p-A?_U=?gGJr1`aW$D*5(W%Sup;5MSjYE=_mFqN%T_rrlTtjX@d2nh>W zq*a@fL@X#istn1m)$EvJ87q3g*%$L;8G8(4D$b+@X*q; zuw&GG?wnbur~Sp~`k$(xh7F|>Q-;g6k)DEOeOL6JXyVfw#HK0$eGLkzJ;Rm}fB_(~ z472O9<^Z{tUduXzH@SmxhG2d~xIRZ9$)a#0!%0RjLFTkJ<(9(GvW4^B{?h-`DFA48E{>(I+r z?)~IAbvLo2r-=3`*-7VS-syGc3Qv8dCS22z08GF8)L)*?L-uu`ijh(Kc2^%xkwU#Y zFqr6}OK<-CezONXN4^C{3)p`8ouK2PhzqZbBPkLXNenIx&t)_&H}WkfC&zK9f3mkq zHm2unbiKo?4Iq(c`#=}iTd)4(uH2g+N#ymyuF!w0^G8(6_}z-WA9@*AxNYHSZZhce zFaAM+Fnj<7LHp?gLF0u<>b-yiPvN?vf6=uNrN3TB+H6L-kTt(8eWLp+dEjacAro(IHi&6D zx}a@BiaUckhj<`ZADah47p`H;La{k`@{OrHszYx{Y9CG`7QG$r-^YKLjT4W4;TnALSWViyAs27WCc8RATDg^%QTh)DzXXjm zKU2}AK21NiY-u;AG_m-c7w$~Q)u$10h$)Mj`tgbKK$jt9y-73dunn0@gTJrP-1e%b z)R#(ON;slu5t?Ae!<5k05J)@2sdB_3DN%sudB&MJ{L?nIjuy8_fiU=hg0s-WI&n?Q ze#xpif7K$&T>jnlj~sI2p`h1<$=Uk);A;bKj=cP_{NnZ! zYy9t5u8$0nfZ^p24Z1YS|2%8y$03oo=fQF%uw+=b;vW3@0T)TOF2vv`0zM!GS{hOb+7dYucE`k zgfoP@Yy~lq;_F1|fbY|*CK#W8bUEMAlZQKTfktX4UR5{oGkW-d{yu_rEmT%)Wl_O1 zlZbC#LkiXve^H2V8-D%7ZawZjYetJ!+}8)$PAMGgfs^T)#okgE$>1*!F+(Qdy|x>% zCp>FdW=gTsBSCesCwVU@+tLXY=Uv8L(Nn!E$Ti0+Eg!o0R>wvrwhTO)U;<_>!Z)d zovq)#1ZdUP*9T;1J_IuH6H~FenS?wXZkE4V2IH#Dk$}g600@u(KM%_S4%mLK&W{F= z;?NILqb3~JlN6U;1Ss)H*R<$yf|+qUoSyy`0&(h8Rux!`e!kR)|D@$r6=$l3@{#>t zqFawPKL=-*^&u?>uTxisuW9Ns`wKJr&hs5>RkpXq1|iX%3RMe?mm+>j>rywOsTP18 zLpv@-2$rKUqAoGvo+`zB8r`>bKRy4paDBuUDyuW%1{WVXZtjqR(-SvoT6?wK@?JTL zUpPinVY8w@KjrJGHo;B7vHBaXdkasrwfFsPCjbB0`hWSVKR^|IDXU?O5BPb&O`^p5 zN29G8cuj@Jghqb=VGGC|l0n1m9n_R~mLyQ+RNGTv51rC(shwG^-3Z86Bq#%W-8QPh zxt7M{#Mo#}NQ7f*G@2X%H@?&|BG4@P825?yBgIo7`DD)-KrmN}OB1OHG%ILqeS=G# zKny*xdat0T@YOowdVPD8cR|N#gJ2)Y>hF8ZtUgQzhJ3fEYi%8My=XsVeui@e>>l_$ zrdY|Wp}GbeS?zYa`2wair^wiwRDoMl1}~R%lH&nWJXU&)h}Z7Ei4WaBrErn(3GYp8 zC5zQ-hq<{@?T?ieXan^vI9J^r&jfNJ0lff%ygG}n0&^yg3+aJVZS_F<#zGT;fPE|%XstXGe3o-WXQH!{r3 z$XEnt>o`jQ?WaJ+zcr<47Lr?AyO+=YrNn(k9DVE(*q@|BFUKNdV$fH{s(H4pZ$7%& zA9+E&nm@!rn0c>J;raL2l6$OE=APMwzk3dp=@SzVa4V%=5YE(Ip4PCOy#@NVcN6JS zBqviu37lQ+%17(Bw2{Zhj-PT`N>Ty$YGsmHsPIgE$bwqu$@G zK;%4TzLa(le0YA*#(F#ma3s0zKV%Bx-P#jFHo_cos6k2ZfK{pBM@(2CNE0GROq{vj z2!)0Z&@gotIcvP!Mi#%yo^qJFr)EOxIP^rmgM26(mC%F*q1h0{SZ=k79eehh=r>xf zYs~_j>%NiZaeF z(tzLs=-r+vRzAc1Y3ryvJ!`Q^^*k$&kjMhL<6vkC?}Jw-G6v*qRg`6Mm-bQ;>fBI4 zjL-bj9Z@NeV^YD!0PWPuN-QWpR`L7abNG7`AM+Sic#bWDjciy3G3@ihVC#ltE_Dhs&t&yjVZr;Am=O6y^aPY)X6r-6$GIlfnl2|O2Ow%eb^M{dkg7Jxa zpIXnB|FMO2AO13!G?9+#jnlq&y?< zsA$jG`H1UwQ-c*ffc|@}bofAs#XAgot=uz#JHnLSwqoEGDNBlvbU%Bo(}5!kX~~^OOA^!{UtmZ7gMu*Y01Cmj z&OuHWE{yWR1_rTgh9w53^Aw{0ax22S@`ct~}Nhc~`2OS#y(r@_mF^TEqbL6v1Bp11z-pbGwxzTEJ!++o4 z|4TFM)s+_hT{jA#(MAJ;L~D6I8qD$voZz0~4${|v>&QJHE^l3zt}7m1 zKtkyAF9%ZqQ%A=<+Qie366vLQ>7@;-nHDsl?%{Xu%1vK{rZ1laBELLtz)5G)vp}UF z8^9sCfgKA4Y)N^S!;>GYr&kVMfmX@(7l^4QXfXdAr5t&)5>}%1wgL&0k}wnv(1Bu z_xBrywYjJ(7L-02U0!tvbS9nAl_PewA@{k(7iR8=eD(y9p!Macq8CmkyPQiJ$ga=n zM}_uh5S;>UIrp2=psTvGMT%WNtpfHTz>;=NVtI@0t^8OL)aP;TXVs-@6O*)3)!>0~73e)JDJza8_#ST7Z#tW2xec~@VWRGKd@ zfbaZ>?)3~QDeVmc=(_+u2qHNWX%t)JUF%xx++<# z4nsVaw+h@wHjRlVRLwWf;F(kReL2gkyOlUuH|Wq+Oub_x%#ICv__o?*y6IU>jSCZ_ z)^2KVAg8YRrD)HTTe46VUzJ?FVmc?^SudF|wZ;>KY0&AR16itoht?*XeZ2kv@ zTQUbwFrd#)i+A%)kGPcjaNl}fZf2wM8u;;ConpX2;xxkEDZeaSKEt$c>%+;ybS?4( z-S(|E>ZZJqQ%b&Axn*0N?XA~k52%d^uf^wVTLs+vD$Ge0f$MCC4WYyYro9)nI>C`Y z1~WZt65A_NdS5vfaI=zBA2}K8cDN4hNWJO3vlabzanLSKgCl=2pU$lUzE5!*7tS@N zUp+ignpa<+qgo8Z#W^Fk`nYSmMipl1#shl~XaRnrI<=@Xa69{@r7CaUSpf}P#nqdq zzbAgVjrw%2L$C1jW$Z#rjXq${X8D15FIb+zQl)11~;Qi>o zWX}KHL9>Oo%IEmhX)53w&d_HRV-n)TILnrfU@UOq1IJlW&#olk>y8z zC#-kph7EaY)Qm$eu*GluuLiG6;mpVqsqeOe`$_i`dFs1aJN8F2gO0C03Km~Kf+)V` z=JxGR+04l_V{+B;_r`2936|iNeg07jp+!TO208?YGvzCuwvwC9cY5WJG7xZeE5(W@ zF@QIfH~mjC$-y1b*Rtou$>O-+%G3C|nOz!SXL zbR?{-;=QW@2mDUSQtwO$db~k6)ER?Ey}7A<*CMLBC9ZcIpHM+5(7@^^Djikj5-79T zMon+s-PV?J=`JrfapJq8f-lt7``0>ghm{GWy_p(4eIYuBs@gip04mv7M1XU7fHH6y?j#B=(_TJQ{9PRAnjqs(Id{@p1y8(GxNdvQucwx zVR8VsTbDiSSfMbgIhs5`?_a3UCWk)Y=2vXPISLWo0`uH{aB|N@3>>yj2pH)(_`x(Vhr`#E^C2q{zD?%1T|TYR{40HSg#kZxAD2d7&m19EN&Z%O zw6rOa{|^e5lLt_sFziO1F8q3eH~5c;f!C*fn(v|Ys4*0NlH`~-gKZ()G8Dvo$5O4WilLBJc{iyC8e+`G)1jWdp67V z7nv#?@mDT**?AV(h_2{QQs{Gdl6JJuaPX6nAhK)9Te*UoD2p|^O4f%Be-vA0SUG%} zc9~CoIxqkKK;_KMQ-@gzOL~WbOTyu&E4+ER(N~R|o);J(rH#^8*_!gJUo(;ni+SXK z8;(pgRf5bf;`M+=&TRdGHl*H>`hp`CVeV#T8LnTP(ll>41MrQjRFvI|9xVrZQ%te- z=U`ce9$$oSqbb+oW>x55V%4yN9w#*w3y2cVd}?ndRlK!xQ+BNVldkjNuE67tg}FBZ zx%v){6)r_3dL`}fFV5kt$1B#jdm}}&-VKTGPOv^D7cGx#FTd~QJA3@Emm2Rcl4(Yt zDL81sId?^)&R+OW9&4G<>wT5`zQ~j{=r51P7*miA>{RWBhrH~&zTRl=Cs z)X{oqZ%=&%rRZ%JmKTWivWtzHsk@8|jPT=-4dDyfVMoBgHJjIRC9K&SB`VEO&UOK5 z{%QB@JD^l9yF(&|MXRl;IvI z@Hxkv@u5)(mFWj5Tcx%cPn>w!&vnm!o;h2#T`U4|m`@rl{nORDV0835IIRYNS!Pi- z+ZRxUuynqNMoTCS{aDQ8PjALz;H%t306%y1LA?ZhV+kv!^@r`^UbjShN~K7TsVt2b z5W)%Li;GR68*C}AvJ09lyUQ~b%`eSYlqB=tC4-uw-M4zI5#0uy8gfhbPjDAL-~o8F z*^AI`;~=!eFU{|l8p^Atc5*vMp6@aalb->L&WX50J|t?JijWkg?XFlXPgu(DNQ>$x z$5)yNd(xCa^Pxp`P{#A{Su21TK4ffAIW;q%;3o1(Dd);te!xHy~m7Ff1 z>}_OF*Rgvn9vfR`IHn91jL>$Cq4^E9Or=LzTG)i_qgA?Ra5D^ z7dm2Re$a3NclXHH>Z z6{y_tNF;alLW}wQ#1wfAA6V}GrA&ksmJUc&kaLZ_MC;#16niFwMkIHFQ2mU=5pZ1D znm5oQ8RRK}6u+P#JIwg(HfMjE1Pef2jnr`rgdbDGDf;w1ymp(5{?2;fD6zhY-0KnA zTQoY!Z@tFTDd!5ENL%?xAYrt2z^yn#yhyF);?{A}gH z9os)b5lOstDk=ONQic{Xu@jv$)?}bO6~W6cSH|Fx78b%u{OWneH5W!L-P)8Sv@Q_a zbUkm0$7dxk;FyB7&dj56uJMxHvU0;90pteV= zFXNS9`c8tH>&|s@wOe92PV-AT=F_>~i@zMI;bvaAxq(sQ`&7xaU%U5?_ z=HC?ikzahOh&ABRzA@Jv4@Pm`EFpiPVr*4gy`|2A`B(&IG35iR=JPO0W6NI1oa1}-8&*soeO*qUoQj<&L{<5i=PR`A%7d(;WS1&oX0G*Aa838va1=P|% z+TVXwjIzFXCF`6M*ug&*6nHLl{2vq^e*#?kU5;7BAs zuOaZ|<(`UK-PqM!buOyZSzVXn`CZ+Qz5~r5aZC^_UdO646_u1fR=T(?m_iWqv8{NS{VJyV^L=3bFcqDAi@9o6~B2LKIMgz5XFk{IIaS29XC56AgRNc zD~S7D{o7%UDTJh|mVYv7n+iS);(uT>O@x03mJ8vV3f}mNPhTiVH&3JySlLj zQFIe+>l{$MfAX+-gz}?x^?8c1aizCee-2PHsR7``>33RFMf+k%W8sr~a(NFU$HR%Q2C#>RNoeo45l`zKy`CdaP zZMafd)~`ARlwFtQDy-(&H%RNx7Soh&$f2SJF z&nnp}II);i(<`iH4kq@DT_wrX&8o~?%`)r{%b;rfpNx1_^CTZ1vVi2V8lCPkAzOXV z3a0CZ1h-`5wxh%bEXlJcaFRl>D!p52c*sKaauix+hKBvFx^(g}%Rg9JD zLDocP_L^rCJ9I4{Q2CabCQjYYFgLdx^}a2x-fSu%)%#3Tn&^3)RQhQdo;OY{MxTxy zNLZ%MCKkgA_E*6DFaehBj7?m8o6eVVp53v;p{?2SDxIhg0)i(H;(_sX)e^N&9VG6H z!g4M1y|2RM{=VhZ_V^H3}RGNVgv zJM+f9mU0>SxHgk(iXyekv6Ylu&XJ^JfBBXA*UKGN z18bpi5g~;pv2e-0JHVm3`QMF2L}ncLvv82J3P(n7E@aGKDe-EJ!F$T%w_^DY z)t1gu-zKa3%_3*E{U46&V-l18-0sRbcWkIT-F_HZ`u3;{7J5-W#<}ZE;ZE^mS%1;) zkHTvipl`Y>PU86AGJJ_2P7C^cC-F5)cT7(yS1y5y)73ZqWbDy_2l zZjZtcsQ5;-l`y^BwDZtn66O#`nR@cqR>rX(&G)NIaaAjYHBuM8NPSaKWZ%w@yug{5 zIF`d9uY!NZ_$G=IbET`1wWHiVzmP^cRqXXpc?~qh5|G)(eoH@(tKR#ePo@@xSb(nz z#MYotiB&_U4$W?w1ZOK>UYdsVafmJ$UaOJ>Z5mgcIuU=<>g?v(c;bUq@1gc6**Ap1 z;AvJkRMuse7U`8({^9a`c{Cy8l5OX36vcWvlPb`y;eTPo*p^364Wbi!-Bm|2MISyV z^YQpa{t=PI`>FlpFYm5@P#}M=AhJGEJ!&36)Hm6U<6nP;5#XJJrdDm_XVSdV(*DBE z!>*x!?R4hCt6^UvzVFDcFG##8w5xm6T2{DV0D``BTOq{QuP;K<7&t-^Bok|gQsULnp-T$3 z+hD&g@NBQyYS^Kfc3aXbPa8zEuxIVFmeE8T*m;XQwYT~roVoGVeRX^SYgLL<^Pb2T zQJ`3^I1w$*%JyCXx`Xi+K$%YUe`ms6Qm`2NF?lB1%t89e{UHDbvnp%?+ejHbZGgUi z20aQIT)FR&J8I#v7QSc|RTDE>uYhn*{;d303>itj5&aUo9*^ovM;t7iIyyV@)n=??eg zjgiR5>N{&M*b!3^Oz|9CF9M!j1-e8k`pIRDq2h#g(Lwcq=g{6!H^}~u5VjDVj&=+B zX~FrX$J|8|>K9fNCh1(uEijf`*wT?05|wKywObp0T#lnABh?sC!xi##!UNlQ)Bd*v z_NRQ{i^XKGE63>VUmi%jOFjCw=kgOvz47*HTYCEW`f;PXWjp`(LGXXfxaX?aDS65w zSRCJDcR#VODs-s5Uxa#4lZP(am=nNB(wL}EwcGN{87&9m13N|TgARmPkA-9GFhWvd zPN2i}M0xqtla{f5P=px4z1;<5Q~29r?uZ%9aXMRaow#;rQnBi}K4N*1LmQ$wr$rpZL=xC8VY+Ujh5cQB z|EvXyig;HE^{6WjXe?nO&?C5E=5wjX{%w0-eM)##$ma$PlzQAz=g4`DZqSj?k5Q3Z7wAiU%Z0zL zQtrQ56k_^@sv~ii@T6in2PUjt0u4`Q@5=^>y422J>6;?eDJlEy(*Vl|%eJd$OQy*tY3rq7=E94 zP^&&!vpa7){4gxWsuq1uv#4|TVmbt+zk;zK=H|<4OL;&fBn&v?Q=FH4;j+EO1S~j^ zZhuSEA;Hk%@#imU710geK}se|PlMYEKf9n>6VH!UStbemWovl!itYF4=NP1Okzxb( z5-qCLB=x!Tr3QsL%_W6hM!gv=Fl~`HiaDxOS8!A?H%k)P4bJ~h0Mt-(tr zwKTiFb5~BNzYQC?uy+wXB7S)}NI5T~X^rih-g{l-k%JD^G-Qov$4aO>V@G5@nIx;t=KVtTPAFf4gU1H@u?TEy!zJM{>b}u2%?4!GUQPA z6Ivh-iR#;Lqh{4}$1*Q%j+D;$70lFE?GeVGM|(kuD`4 zHkOlZoZ$(#0NGw?so4(zCQCpzgZw)zA?S;Mn6h<=7DP&a4pb^AV{97YrdEq$e`iRg zU*h5RHfdC;X)H1xzwF))!Kl`0ItEHvgkI11pjKxe{egYJ^GUAC{(cmc6k$AHZENGe@(MV4-dI_>rX^+Q%cWu}yRN`sr_? zNq*ca$`64mLs0pI>hUhWdxU}pQO|G_bvco{yQNgg_kb-Tw~2R-UKLM zR5+c`@56J-Nzv`Ba1N=GzIsfClI3J|yQg=0zyI9@@Ff*zCRE6!oA8I>=c{_Xwc(VE>qL8Fh;0Tj~hW)7kc z7e)gb_)iL+KG;6kTdw`-a;j1E|Lw_TZ||h^p33Y#aB#-Lkq97wBTi*%y8_Qu>oqi`c8-jMK@u7RNMBjK1L;Yb$KefoTSB8OwGjQeD1~Qh zKLrg{m3U6vCLPIWa%9w1KAb>qYA$zG>=uSyFF$wFFVI})Q2#914BQxQmu(%T#;=!- z<~=FG-Q$rO-D~R?hTUvif;ET3SzpX^ITUpv`z3xq9{J5!P8IZtwTgzGiFYrvP9DN{ zcT-KI=mUpy>fAiCoy&@aq)o!@RYk=V0`GjgFW6tVrWEvhJ?&NYM*%*){pA)=ujN}RGxf;DN!z7mLCsPDX9P|9ZYiBEbvaav*f0TqvQ88}x6TpE z1rcSf35ij(s~<QBD5D533s>B@TDxaoMT_r;ECm_?s8_NG{7CI)o4F% zZgB&z9asHyvWAQ3sLs2upMdf?#v(>@J%gb~e0iCniN*DNCYI(z5guZq>J=gs5zM=J zhRoi&ms$$z|DNH(T}XDb)E*^?41(3sl+oi6;j41Y@pC^s+ydkQ}^ms zQSmCsnuZWWpMci$&sj@K@#*htLVSes#^g;4<4&f3KPM0B)PUlh>icr_G^rH@Z#XkU z+Po(egU%67mb|yL*nV!PWSMGJ_)QNm@#qvZ18gBr)n{~==SbCR6D`V~Hjf2|u2zg! zo3tGR7rh)9YY^(s{ow@lf0bacDsS^1^~SnBEWj+;5a!D4K?@5r&F{jF02XwiP*n8K;3QKLUOX}0OMiu z?wvnH!JNDOQ30Wj4?HoLM#S7u2I(7FXs%=F%q$rDW}TAseO}dsEA-{rMZ|3`zBVS~ zQAmG*d4zbHdEjYnG@9Uz_WSE1et8ffMGpV0`Q~&=nC8>lqXpiDC+b%JISN4YgQgG7 z2S*C0yZMGvN8}IcF0o@A9j)GQ|7=mL+b&a3ja$`mQHS}^+1|V_!9M;5E7YawTwyq~ zMTS|0bF6499nTt2%a@J3%w)Xn9*Hz&1*R&!*BqSFYi<+Q!VLRMXbRUl{Fy)H}kdf9`mlTDv zGIA_=YEGwL6%Xe2;HHsZAk*$((>`{YD4u&J8p~)9UDMsUX|~s-!}uI&-Pp;&D%jax za;%>Z7c2BEPcv4VKjW3T+$G+VW}`COCLp^rMPAkl{xF%bup9i@=CruA4RU@Zd|Ea8 zf4ANL*6Yeu*w6(2@fmHjLG>BC#9?uU)C=v-z2-OsoeyK1&reqXH67rLYcStP=fY#9>!aT zLehQx)FFk0euZvrB%XcmP*Vl)10Vk7>N^qkBTg`4OQNO!Y=c#-g*BG8fK==M)TIW~ zSux&g@w@b#aeXZQ6kS97ZQP|vUznq`&7&t6Ucxbs>mJ|m&kQrR$>e3kpO!_(wJ*Z1 zX)1T|cqTO7+MnRm>L0L5Iot?UZ3M)nzS`1?+bFO6^*D)xm(v??&>&_SS{!QEa@{PK zWID3@Ca26)*C`tIsv&xjT)fW$3d`AL@7Msz^iDn$Xfe*ONEg{W%uKG3u zzAFq0&xehltoz>Y3=8j|`6?)7f&AC`i@x3be23W&@)5j&CfR$na5Y zP7%E3!d%(VKSl#a9n!g1G}O3PEHNOaIY7`!@%rTlyOjG|3HEG%ux-4#08YMXei-9r zn#3j2!xoIVnlksPGN^-3Bka!L2VvuWosAY0{G@hwC1@B!n_8unbm_yG&Gk@ahAJ+F zM#Mel6vgye9;1z9g!WK{Ny3KF3JRr`Vh$;99Ni&z-L1lKIoFOSG2Y^Exv@9`-UZcX zY2AXbsQ_8x>g-c8X!9BysUtc2l3H-i=PAnIZrvg(sxq3%UzRwU>s(+*;v7p@>xqpM z62Y{SPwYjw^40Po+=%m)hjwLh|~z|eMNXJOhpP$ zSx2FWL>P*E5NmN!RuV+Nk_5Uapm${q`uC<48cF_j_IT61dobfO@H-vrYUA6mO`vmv z22k=C^FCtjIP&TlPn6#*O%PKo2b5DO2iO2Dy|T`7mly48VnJ?J?7NScXH{<6O7NrQ?-fu%L|*A|636FPcLSt+ugb>h)9B$ThYXg64KJdBf>}4 z8YdMHxuex1?h}S#Re9*9j;)EfkrSdM#O>4;_V&962)zWwS4)5zsrXc0 z=~wY-QoUgUVPAl#L%nW#S%_%JLfmsJ^DSYa6=!z6x2eR&aJi;!+N1?vufP$DoGyNm zNH1NXgaAQw@MDCNJ~$yKg9Zq<0v+pcHq*#kWZds%l#LTu8A8vd*dfZpOpDgb1v(bHn6GgGkLgbO#Ig7{YqarBBJqFF3^}I&ML+o@Bdd z)f8b#s?!ez%bCNn3F+M^zlY>m$}c`}+vZ(0#Zp{<+S+ro4~i9a7c&yM-rgBlb;4># z(lBhnsmQv{G03pY+l5t=Y`q^A0S=Ix72i4g@_C|p8vxm!{&@L~Hx=Qx(-q}&BLO24 zStB*>U)Gsk8+_~u1uwz6vH{H-6)f$t3+!J5~(6xA_Q`z?Z#(j$eq z0CAD6Bmf(J^o+uV#lVuc!6)JyYmYvV6@9v7Vf7CRLdyr9!k+DnZA^FTRG>s!kYM(U zR2$mvNsjXeIg+0bpa6$EnZ|~VRbY#)u z)8xqjGv|@0w(#b5`y_APd2SamqmRFSPd7#MZqip#x#yeIYq<#@3`hM_10d8d_^a69 z_xQ8>pO1xB<@-qlX{#>38!XXQ{Fb45Bj(4q4C~)IHl31F#Qz!flC8t9s+BNqf;NHD z%3U#wTSfJ3wnc(L&i-3syAnRlU-k0gvI&Wvd|*>~C?PWB8VRCF*SjcRTzb69wOLjV zUXHaC4$VI8@=atLVQWRTjlKgJ445LNJK0?`o#nC}-9AVIrh4J;FaoVgR=pc`FGu!O zOl$b)Dt$a2!HOA$H4i zgS}(Wm@tPI0IT6uTWqCQL_=*BaP!Xe7N98Wl zyYFX)Jg!L+0k@{4TZnN46Xr7#x;+hpcH_}Bx4s@6Ai)}Nd$Cav!a?XpL|V0R%3bk{ zduIPtB4Bj;=*&C9lw&;6;agOtNNj~#Oa1dEiE~AZ!2By=3fNNaPh5HYl;J~bv~`Lw zyZqFmoyNgJ7cxM`H;H@BHb+2H&z4W3rdCt(_QHt9@)%0I-w%85--pAMh@fITntZkT z5XEb&7COra$Q5Z+4}@SX2ZWMYK-oU|_|&SW2s9xD)J?utHmvEX2$SA&t2wJLq{6U@ z8O|uoc{nAUw6$+A_&jwu3`7*-R4hlCQpT3ztaJ6>Rc5rTT>o}ik)*!gu&k?ze<1mo zVkoBb@}woy&s^4!P**uho{W7NYbO8{P@J_usGL`~V(pXVD()Wit&Z}Ksdm8rgTiLp zfv50YFedg=xF?hh(|~>+soWYn<^c zq*Zxm`L%pa0eabugKhBKlP&Z^Uz0n4j)pzS*weH!*Bow(R(8*_6X@NvD5a{=o~D@M z@kirerQ z1W%k@?aR4zp^KZjq6WY~M*EyDr+!W)1e73#d{Ro)G4NB}xpGcRwJ__3kpFE#K6tU> zG(9y55~;vbqAJs!Gr((3QF+volD9~9(UGyfL0$J>ZH~}~E23vCW(A^iU&X!u@Soo1 z|3?P=7I&<$4I4yhvMpA>LkA~mV)w*Zaxf9(FJ60uX{>N?3Wso@du5ORvlg@=x4;Bv z=uKzDq=_zOmtQ&ur`&c@2wvNnmG;2Vl3pY_kZH^ZDvQbNgl~i@)FCarvwceMid&L{ zifX$c>uGbz*z3eb{y@&Z_}r`vpqg`278z9}GI-NQp~bh}*a!?4hDPg>KcTNRx;n7l zsSa0;{m6IltTmUQ@D>$%ho`9|b)`L-P11Nbn_Xq*5VUb`KwSA=DgnrU8FC)^?A742kvJxn1&C12vk|C+RhihC(g!PH1=Ws z0dXEkk;7~+GM=XZ4vzniBvka%^1Z=tAMSeh!0bQ-swwH0xl#>#!bJtzC`_?~^!kx)yLl>-!DW%}vCTA| zl!x0_{61fHQnCttYaDRAdufO~#Pe1Avy7k3Y`?z7_(P4dGDu2&%Fn+;AH7eiMHX>i zZwcglO;|I0OFe(a+UUZ8u5I}SVMy?oN~=(8cPuFZAqEryb6PUI4=PO}zLk z3QHA6*5V)zuWBEMcfUQ2k&PT4-~4h2w#_l)z4r^!#^a;+rR zo24?G`5Z><2+9G+Z%B~T6c!NPj&ZKYNK{p&Joa1KZjxeS*s)^n4Abyrezq~>=Vi>q z%KZfLGlE7~VXoslP)wP7yleOSmo`f{y#h}YK;H@ literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/id3_image_jfif.mp3 b/tinytag/tests/samples/id3_image_jfif.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f724445893a1c14052f72ede336e2a65cf9460b4 GIT binary patch literal 132000 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

; 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%0D=MEScQr`&|MUm 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\)lOn$XM7#ORJI~%M7zXMw9AZu1vAaj)*}9Q&5`E z68Ts|#AiniP{NhLoQmKS2(O}GV#Zxgt}K04n5Bg*I!t`TekLp`g8LL6rvy3BB#D2l zm&iZg8BuGs7qJ3HxuZ=TO%~s8NW&~M%_+z!Dbpejc9#>|nrHR9+914CT-aoe^S9I!!{`1mWQe%YA?AI3}`G#EIk5pdBa30Z13Tvs| zkwy=rTaqTLbq}^I!NWoQihyRxXh$C&1Me1yQJh3W1dIF&v4y>91vhliV=(+Dy2B8( zn%n2n04hYQIhGiv>pvZ(-gSxgAbF__djNKC~OjzpE}ACUHA z>g<6|^m>xft2lKlC5-Z{tdz747EU{eGj^};7gAkIt3J3?X0U+&N$HJc!KohJi$@-7;tU;7(Z|APYReu85} z6e0&=hPAb*1MhjZaM76=hD{ht`!evZD z9Yg@`q4uV=lk$$|VjJH9Id!7TWLvt`P(vh{T1Uz<

qzqS6C1>O^@;jy6;iS+xbH zgJ}@!Q(IZqq0(#!oOf-g%ht_n&Vg7$IW?A>n5chg_{Sr7YZ)gNk6BY0pME9PnxpL~#@ zRn(AAXH5$EX)#!t+7F_cVKJ*I=~x7oP7pOue20~ z+$Ks$h&l@IoH^SCik9`VbrOnb&t&r^3MaSXK8VVS>c^5t-~RZUD7_{@#WH*G-D=>m z{$^k^YL27J26>6|IFM*)R$27M;g?8SRJGZ0y_e+8u3AL|moo!XAnmYAj!&zkL5ndk zcBQk6{;k*$A1Q)Xua|Zh=*1FHv)&ABwcxAYkkZ^dMaoXqMDg3>y~VsIk8&mm2&jpF zUiE~6O0qW)VEQ@Hu45!+KNxGb!gf0fV4BrtG@Pu&xgTvppMz=L9TYflK>7o>5&r;~ zZ*zskvrB2O!{0k8*t7=PA+BzNI1ep8B-aN;->Gl^0hs@-=^Duc0RR9d008&zjTi79 z@ZSyq$ZD#q!w$x;1U401X3Tm^uLN?!WuE5k+eGuj2pd?cd8c*aL#Y2aKK208pui!f`yiw3!S~09*mSWha0H1I|MhabcR-F@T%V0LFs8;?B(3X`V!!FbPXr*+6acKuFp;fIdl$**1os4RGha0qGA^7HYCzp*0# zuO(K0`a@9B2EPq`e%xjz@Cc`3Mrcn93R>U0_S;?%romzHG4K_U)zSBDJ4^2ZyLQG$ z(F10pe6{cPB4Ms#k?C;Bv@IQS5v%n(x;QH&rMB$ZNnnJ=e$T?Y&!krMc_{G%m#${J zUKR?5=U|2?KL_mFAmt*ZSE)zIw!IQw__@v$HE9)XpMuY)0zUWC=yg zt07+#4r^HsjSmH~H>(mPdC~v0Jl_hq7=Z_#4n(ZS^xda9G<8!PbVM!`=qcv)KS8FB K8_Wlct^W^-3-Krb literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index baf8a96..4b6df27 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -268,6 +268,13 @@ def test_mp3_image_loading_with_utf8_description(): assert 5700 < len(image_data) < 6000, 'Image is %d bytes but should be around 6kb' % len(image_data) assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' +def test_mp3_image_loading2(): + tag = TinyTag.get(os.path.join(testfolder, 'samples/12oz.mp3'), image=True) + image_data = tag.get_image() + assert image_data is not None + assert 2000 < len(image_data) < 2500, 'Image is %d bytes but should be around 145kb' % len(image_data) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + def test_mp3_utf_8_invalid_string_raises_exception(): with raises(TinyTagException): tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) @@ -282,12 +289,14 @@ def test_mp4_image_loading(): image_data = tag.get_image() assert image_data is not None assert 20000 < len(image_data) < 25000, 'Image is %d bytes but should be around 22kb' % len(image_data) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' def test_flac_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) image_data = tag.get_image() assert image_data is not None assert 70000 < len(image_data) < 80000, 'Image is %d bytes but should be around 75kb' % len(image_data) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' def test_aiff_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/test_with_image.aiff'), image=True) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 33e6800..697e76c 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -699,6 +699,13 @@ def asciidecode(x): if genre_id < len(ID3.ID3V1_GENRES): self._set_field('genre', ID3.ID3V1_GENRES[genre_id], overwrite=False) + @staticmethod + def index_utf16(s, search): + for i in range(0, len(s), len(search)): + if s[i:i+len(search)] == search: + return i + return -1 + def _parse_frame(self, fh, id3version=False): # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 frame_header_size = 6 if id3version == 2 else 10 @@ -725,18 +732,17 @@ def _parse_frame(self, fh, id3version=False): elif frame_id in self.IMAGE_FRAME_IDS and self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames if frame_id == 'PIC': # ID3 v2.2: - desc_end_pos = content.index(b'\x00', 1) + 1 + encoding, content = content[0], content[1:] + imgformat, content = content[:3], content[3:] else: # ID3 v2.3+ - textencoding = content[0] + encoding, content = content[0], content[1:] mimetype_end_pos = content.index(b'\x00', 1) + 1 - desc_start_pos = mimetype_end_pos + 1 # jump over picture type - if textencoding == 0: - desc_end_pos = content.index(b'\x00', desc_start_pos) + 1 - else: - desc_end_pos = content.index(b'\x00\x00', desc_start_pos) + 2 - if content[desc_end_pos:desc_end_pos+1] == b'\x00': - desc_end_pos += 1 # the description ends with 1 null byte - self._image_data = content[desc_end_pos:] + mimetype, content = content[:mimetype_end_pos], content[mimetype_end_pos:] + pictype, content = content[0], content[1:] + termination = b'\x00' if encoding in (0, 3) else b'\x00\x00' # latin1 and utf-8 are 1 byte + desc_end_pos = ID3.index_utf16(content, termination) + len(termination) + description, content = content[:desc_end_pos], content[desc_end_pos:] + self._image_data = content return frame_size return 0 From 55b8971e100739e6a0d67fa2a86c57226f75fc5c Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Mon, 13 Dec 2021 15:24:32 +0100 Subject: [PATCH 009/305] added utf-8 support for AIFF files, closes #123 --- tinytag/tinytag.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 697e76c..7e349c3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1215,6 +1215,7 @@ def _parse_tag(self, fh): else: fh.seek(object_size - 24, os.SEEK_CUR) # read over onknown object ids + class Aiff(ID3): # # AIFF is part of the IFF family of file formats. That means it has a _wide_ @@ -1283,15 +1284,15 @@ def _parse_tag(self, fh): chunkname = chunk.getname() if chunkname == b'NAME': # "Name Chunk text contains the name of the sampled sound." - self.title = self._unpad(chunk.read().decode('ascii')) + self.title = self._unpad(chunk.read().decode('utf-8')) elif chunkname == b'AUTH': # "Author Chunk text contains one or more author names. An author in # this case is the creator of a sampled sound." - self.artist = self._unpad(chunk.read().decode('ascii')) + self.artist = self._unpad(chunk.read().decode('utf-8')) elif chunkname == b'ANNO': # "Annotation Chunk text contains a comment. Use of this chunk is # discouraged within FORM AIFC." Some tools: "hold my beer" - self._set_field('comment', self._unpad(chunk.read().decode('ascii'))) + self._set_field('comment', self._unpad(chunk.read().decode('utf-8'))) elif chunkname == b'(c) ': # "The Copyright Chunk contains a copyright notice for the sound. text # contains a date followed by the copyright owner. The chunk ID '[c] ' From 39aabc44bde68b4ae8dcf2d952989e678115705d Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Mon, 13 Dec 2021 15:38:17 +0100 Subject: [PATCH 010/305] fixed calculation of bitrate for very short mp3 files, closes #99 --- tinytag/tests/samples/nicotinetestdata.mp3 | Bin 0 -> 80919 bytes tinytag/tests/test_all.py | 3 ++- tinytag/tinytag.py | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 tinytag/tests/samples/nicotinetestdata.mp3 diff --git a/tinytag/tests/samples/nicotinetestdata.mp3 b/tinytag/tests/samples/nicotinetestdata.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..bbc9739be5481d8eda3aa5717be6cffb8abc408f GIT binary patch literal 80919 zcmeI)VWggA0LJlWT8vDQh{RY)WVW`Ylx*5!*_hT5kyL9bS`s6Xh(y#>L?SXGk%%Hu zQX&zFBoT>{R1#TQ?^(w4A@BcN|C{}f*}2cTIIeU3-sk;vJ9~C*T|2FQY`p2ff!&MG zXHU}^dv8B-X#16$F5kRq%jV4|(|`XvaeV*mtN%XkIeh5;MY8xY)BDq!tACu%ICI@u z>(4p&yz?)(aKpxnFS+!xO)kT{^r~7 zzW?E;pMU-Rk3awVd$q};^PP*%Teq)%aq8ouwLhIaE_(d7;H0PcH{L{a9wd zaYC~(hCC)TB+IN+LbEZ3JSH?G%dAvFvoVG|CNw0=tW-j?F@`)QG$hNcR6?^chCC)T zB+IN+LbEZ3JSH?G%dAvFvoVG|CNw0=tW-j?F@`)QG$hNcR6?^chCC)TB+IN+LbEZ3 zJSH?G%dAvFvoVG|CNw0=tW-j?F-APk8h65l#u1nMzdGqcU1$XAgkT^&PDm$! z0tvxDdYq6>00k0)f%G^bod60X1Ow@DLOKByNC*bf2X3j0Tf6G2GZk%bOI=l z5DcWp3F!n-AR!n?j}y`fpg=+}kRB(b6F`B4U?4qCNGE^-3Bf>moRCfc1rmaR^f)1% zV0i%@s0)qDfx6HL`}nc`40Ra`UIYojKzf{zP5=cGf`RlnA)NpUBm@KLaY8x)6i5gL z(&L150w|CW45Y^i=>$+9As9%H6VeHwKteE(9w(#|K!Jo{AU#e$+9As9%H6VeHwKteE(9w(#|K!Jo{AU#e2X3j!SVt+P!}4P1NHa+U>`r$pP?>e!HXav7)Xy3 z(g~nILNJgXC!`ZVfrMZnJx)j`fC34@Kzf{zP5=cGf`RlnA)NpUBm@KLaY8x)6i5gL z(&L150w|CW45Y^i=>$+9As9%H6VeHwKteE(9w(#|K!Jo{AUoE9x-JJV0bQueSP8)b zqsKi%3Bdxr%fUcBLokpYC!`ZVfrMZnJx)j`fC34@Kzf{zP5=cGf`RlnA)NpUBm@KL zaY8x)6i5gL(&L150w|CW45Y^i=>$+9As9%H6VeHwKteE(9w(#|K!Jo{AU#e> 4) & 0x0F # biterate id From acf80460a57bd8d1f76d7ecbf7bf94ea243a5c23 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Tue, 14 Dec 2021 11:19:11 +0100 Subject: [PATCH 011/305] allow overriding default encoding for ID3 tags, closes #75 --- tinytag/tests/samples/chinese_id3.mp3 | Bin 0 -> 1000 bytes tinytag/tests/test_all.py | 7 +++++++ tinytag/tinytag.py | 13 ++++++++++--- 3 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 tinytag/tests/samples/chinese_id3.mp3 diff --git a/tinytag/tests/samples/chinese_id3.mp3 b/tinytag/tests/samples/chinese_id3.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..b81b0c14fcc5544d10b39949743d263f048aecbf GIT binary patch literal 1000 zcmeZtF=l1}0_L(1&k!RZgA<7N9zXQt+PWPtLV}#Vfm|jaHVkq0_k&7bxO@J>r7s7r zg*f^+L4{yC0)jllfI{jF4B@`|zNsmhiOxBR#l-kmD;OLKzc4Vfad7kU3yVrf$;d0JsB7xz85)~eTH84| zxp{c|`UizXM8(7>rle)& Date: Tue, 14 Dec 2021 11:20:40 +0100 Subject: [PATCH 012/305] also allow overriding the default encoding of ID3v1 tags --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 6552215..5f0987a 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -690,7 +690,7 @@ def _parse_id3v2(self, fh): def _parse_id3v1(self, fh): if fh.read(3) == b'TAG': # check if this is an ID3 v1 tag def asciidecode(x): - return self._unpad(codecs.decode(x, 'latin1')) + return self._unpad(codecs.decode(x, self._default_encoding or 'latin1')) fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) self._set_field('title', fields[:30], transfunc=asciidecode, overwrite=False) self._set_field('artist', fields[30:60], transfunc=asciidecode, overwrite=False) From 13302ca7f4be708b71a24d85a3e9eb4f2532f8af Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Tue, 14 Dec 2021 11:50:04 +0100 Subject: [PATCH 013/305] fixed of by one error when stripping BOM of UTF-16le encoded ID3v2 strings, fixes #106 --- tinytag/tests/samples/cut_off_titles.mp3 | Bin 0 -> 1000 bytes tinytag/tests/test_all.py | 3 ++- tinytag/tinytag.py | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 tinytag/tests/samples/cut_off_titles.mp3 diff --git a/tinytag/tests/samples/cut_off_titles.mp3 b/tinytag/tests/samples/cut_off_titles.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c4432b4e5173bd0f2df506f9cd112cbd43cf36ea GIT binary patch literal 1000 zcmeZtF=l1}0!E7vM;|93gB{3rWe8$$Vh9OvH3SNng9Qp0G8vK?6o3+m34B0?d7(+0S z36|z3vIizZQphE7}I;#pZY9J6jhpiV(LbQ&Wj97 ze=%P4mUBQaCpNz`?C3N z1@C;eZY%qHbG^Ux@>7A8hXdRCD?5&CdXsxQJ;%wHcW1%h^u#;UvW`h4{{MA;*J1wqS>Erj^mvqIv1B#pWMuDG}#yeTtVVh z-+$-r{kc@HQaCO9!-Iq8X9Z2Je)NCQanr5eX9B^kvU{Jse690;dU>78t^RTFzy$XH zzc;0v=6Qgc;#6y&rG@-q;Ci~t_f*dT#*2Ln93rYL2Q0!OTBh-yd@ID#?#U%QbN-D7 z6MWQ<6{rYnc?+Fr|K-ZbR`CEpuWF$H literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 82604e4..eb6a97a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -32,7 +32,7 @@ testfiles = OrderedDict([ # MP3 - ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': '\ufeff\ufeffRipped by THSLIVE', 'composer': '', 'bitrate': 125}), + ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125}), ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', 'comment': 'Ripped by THSLIVE'}), # the output of the lame encoder was 185.4 bitrate, but this is good enough for now ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), @@ -56,6 +56,7 @@ ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATINGL'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), ('samples/nicotinetestdata.mp3', {'filesize': 80919, 'audio_offset': 45, 'channels': 2, 'duration': 5.067755102040817, 'extra': {}, 'samplerate': 44100, 'bitrate': 127}), ('samples/chinese_id3.mp3', {'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': '1'}), + ('samples/cut_off_titles.mp3', {'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'audio_offset': 194, 'bitrate': 192, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), # OGG diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5f0987a..1a25a4a 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -763,18 +763,18 @@ def _decode_string(self, bytestr): encoding = default_encoding elif first_byte == b'\x01': # UTF-16 with BOM bytestr = bytestr[1:] - if bytestr[:5] == b'eng\xff\xfe': - bytestr = bytestr[3:] # remove language (but leave BOM) - if bytestr[:5] == b'eng\xfe\xff': - bytestr = bytestr[3:] # remove language (but leave BOM) + # remove language (but leave BOM) + if re.match(b'...(\xff\xfe|\xfe\xff)', bytestr): + bytestr = bytestr[3:] if bytestr[:4] == b'eng\x00': bytestr = bytestr[4:] # remove language if bytestr[:1] == b'\x00': bytestr = bytestr[1:] # strip optional additional null byte # read byte order mark to determine endianess encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' - # strip the bom and optional null bytes - bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] + # strip the bom if it exists + if bytestr[:2] in (b'\xfe\xff', b'\xff\xfe'): + bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] # remove ADDITIONAL EXTRA BOM :facepalm: if bytestr[:4] == b'\x00\x00\xff\xfe': bytestr = bytestr[4:] From ff46964088c417fc1ca4ec2c10989429f66ade52 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Tue, 14 Dec 2021 12:15:00 +0100 Subject: [PATCH 014/305] updated version to 1.7.0, updated readme and changelog --- README.md | 25 ++++++++++++++++++++++--- tinytag/__init__.py | 2 +- tinytag/tinytag.py | 4 ++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fb1e736..47a99cf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ tinytag is a library for reading music meta data of MP3, OGG, OPUS, MP4, M4A, FL [![Build Status](https://travis-ci.org/devsnd/tinytag.png?branch=master)](https://travis-ci.org/devsnd/tinytag) [![Build status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) [![Coverage Status](https://coveralls.io/repos/devsnd/tinytag/badge.png)](https://coveralls.io/r/devsnd/tinytag) +[![PyPI version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) Install ------- @@ -66,16 +67,34 @@ List of possible attributes you can get with TinyTag: tag.track_total # total number of tracks as string tag.year # year or data as string - # For non-common fields and fields specific to single file formats use extra +For non-common fields and fields specific to single file formats use extra + tag.extra # a dict of additional data -Additionally you can also get cover images from ID3 tags: +The `extra` dict currently *may* contain the following data: + `url`, `isrc`, `text`, `initial_key`, `lyrics`, `copyright` + +Aditionally you can also get cover images from ID3 tags: tag = TinyTag.get('/some/music.mp3', image=True) image_data = tag.get_image() +To open files using a specific encoding, you can use the `encoding` parameter. +This parameter is however only used for formats where the encoding isn't explicitly +specified. + + TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') + Changelog: - * 1.6.0 (2021-28-08) [aw-edition]: + * 1.7.0. (2021-12-14) + - fixed rare occasion of ID3v2 tags missing their first character, #106 + - allow overriding the default encoding of ID3 tags (e.g. `TinyTag.get(..., encoding='gbk'))`) + - fixed calculation of bitrate for very short mp3 files, #99 + - utf-8 support for AIFF files, #123 + - fixed image parsing for id3v2 with images containing utf-16LE descriptions, #117 + - fixed ID3v1 tags overwriting ID3v2 tags, #121 + - Set correct file position if tag reading is disabled for ID3 (thanks to mathiascode) + * 1.6.0 (2021-08-28) [aw-edition]: - fixed handling of non-latin encoding types for images (thanks to aw-was-here) - added support for ISRC data, available in `extra['isrc']` field (thanks to aw-was-here) - added support for AIFF/AIFF-C (thanks to aw-was-here) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 2c00114..b663927 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,7 +4,7 @@ import sys -__version__ = '1.6.0' +__version__ = '1.7.0' if __name__ == '__main__': print(TinyTag.get(sys.argv[1])) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 1a25a4a..365105c 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -2,14 +2,14 @@ # -*- coding: utf-8 -*- # tinytag - an audio meta info reader -# Copyright (c) 2014-2018 Tom Wallroth +# Copyright (c) 2014-2021 Tom Wallroth # # Sources on github: # http://github.com/devsnd/tinytag/ # MIT License -# Copyright (c) 2014-2019 Tom Wallroth +# Copyright (c) 2014-2021 Tom Wallroth # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal From 2d6d51b2797565293d17b0029ad03107bceef6a0 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Wed, 15 Dec 2021 11:10:52 +0100 Subject: [PATCH 015/305] Added Useless badges to the readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 47a99cf..cd973e3 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ tinytag is a library for reading music meta data of MP3, OGG, OPUS, MP4, M4A, FL [![Build Status](https://travis-ci.org/devsnd/tinytag.png?branch=master)](https://travis-ci.org/devsnd/tinytag) [![Build status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) -[![Coverage Status](https://coveralls.io/repos/devsnd/tinytag/badge.png)](https://coveralls.io/r/devsnd/tinytag) +[![Coverage Status](https://coveralls.io/repos/devsnd/tinytag/badge.svg)](https://coveralls.io/r/devsnd/tinytag) [![PyPI version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) +[![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag.svg)](https://pypistats.org/packages/tinytag) Install ------- From d3660f7f520a36e9bbbb47336af852a25a667eb0 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 03:23:47 +0200 Subject: [PATCH 016/305] Extend list of supported file extensions --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 365105c..00a5f6b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -125,12 +125,12 @@ def get_image(self): @classmethod def _get_parser_for_filename(cls, filename): mapping = { - (b'.mp3',): ID3, + (b'.mp1', b'.mp2', b'.mp3'): ID3, (b'.oga', b'.ogg', b'.opus'): Ogg, (b'.wav',): Wave, (b'.flac',): Flac, (b'.wma',): Wma, - (b'.m4b', b'.m4a', b'.mp4'): MP4, + (b'.m4b', b'.m4a', b'.m4r', b'.mp4'): MP4, (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, } if not isinstance(filename, bytes): # convert filename to binary From 562bfa694f34d1ecb4a7c791b53359bf8d2a619f Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 04:42:38 +0200 Subject: [PATCH 017/305] Fix invalid duration for .wma files --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index eb6a97a..e732982 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -94,7 +94,7 @@ ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.479655337482049, 'channels': 2, 'disc': '1', 'disc_total': '1', 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'title': 'intro', 'track': '01', 'track_total': '8'}), # WMA - ('samples/test2.wma', {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 86.406, 'track_total': None, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), + ('samples/test2.wma', {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'track_total': None, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), # M4A/MP4 ('samples/test.m4a', {'extra': {}, 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 365105c..84659b2 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1195,7 +1195,9 @@ def _parse_tag(self, fh): ('maximum_data_packet_size', 4, True), ('maximum_bitrate', 4, False), ]) - self.duration = blocks.get('play_duration') / float(10000000) + # According to the specification, we need to subtract the preroll from play_duration + # to get the actual duration of the file + self.duration = max(blocks.get('play_duration') / float(10000000) - blocks.get('preroll') / float(1000), 0.0) elif object_id == Wma.ASF_STREAM_PROPERTIES_OBJECT: blocks = self.read_blocks(fh, [ ('stream_type', 16, False), From 7554af0afb020ba14d159a5a14b536e95c5f6ade Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 05:24:13 +0200 Subject: [PATCH 018/305] Fix minor inconsistencies in audio bitrates --- tinytag/tests/test_all.py | 54 +++++++++++++++++++-------------------- tinytag/tinytag.py | 8 +++--- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index eb6a97a..4411e56 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -60,38 +60,38 @@ # OGG - ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 109.375, 'samplerate': 44100}), - ('samples/multipagecomment.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 109.375, 'samplerate': 44100}), - ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 156.25, 'samplerate': 44100}), - ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 156.25, 'samplerate': 44100, 'comment': 'hello!'}), - ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 78.125, 'duration': 2.132358276643991, 'samplerate': 44100}), - ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 109.375, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), + ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), + ('samples/multipagecomment.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), + ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160, 'samplerate': 44100}), + ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160, 'samplerate': 44100, 'comment': 'hello!'}), + ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80, 'duration': 2.132358276643991, 'samplerate': 44100}), + ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 112, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), # OPUS ('samples/test.opus', {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', 'comment': 'ARCD0018 - Lovelight'}), ('samples/8khz_5s.opus', {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), # WAV - ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1378.125, 'samplerate': 44100, 'audio_offest': 36}), - ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 689.0625, 'duration': 3.0, 'samplerate': 44100, 'audio_offest': 36}), - ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1378.125, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': '66', 'audio_offest': 36, 'comment': 'hello', 'year': '2014'}), - ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, 'artist': 'theartisst', 'bitrate': 1378.125, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': None, 'audio_offest': 36, 'comment': 'hello', 'year': '2014'}), - ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 344.53125, 'samplerate': 22050, 'audio_offest': 4088}), - ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 344.53125, 'samplerate': 22050, 'audio_offest': 122, 'artist': 'Purpley', 'title': 'Test000', 'track': '17'}), + ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offest': 36}), + ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, 'duration': 3.0, 'samplerate': 44100, 'audio_offest': 36}), + ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': '66', 'audio_offest': 36, 'comment': 'hello', 'year': '2014'}), + ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': None, 'audio_offest': 36, 'comment': 'hello', 'year': '2014'}), + ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offest': 4088}), + ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offest': 122, 'artist': 'Purpley', 'title': 'Test000', 'track': '17'}), # FLAC - ('samples/flac1sMono.flac', {'extra': {}, 'genre': 'Avantgarde', 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, 'filesize': 26632, 'bitrate': 208.0625, 'samplerate': 44100}), - ('samples/flac453sStereo.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, 'duration': 453.51473922902494, 'title': None, 'track': None, 'artist': None, 'filesize': 84236, 'bitrate': 1.45109671875, 'samplerate': 44100}), - ('samples/flac1.5sStereo.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', 'filesize': 59868, 'bitrate': 311.9115195300095, 'genre': 'Avantgarde', 'samplerate': 44100}), - ('samples/flac_application.flac', {'extra': {}, 'channels': 2, 'track_total': '11', 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', 'filesize': 13000, 'bitrate': 0.37115370559859673, 'samplerate': 44100}), - ('samples/no-tags.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, 'duration': 3.684716553287982, 'title': None, 'track': None, 'artist': None, 'filesize': 4692, 'bitrate': 9.94818718614612, 'samplerate': 44100}), - ('samples/variable-block.flac', {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.3057169061449098, 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'composer': 'Boom Boom Satellites (Lyrics)', 'disc_total': '2'}), + ('samples/flac1sMono.flac', {'extra': {}, 'genre': 'Avantgarde', 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100}), + ('samples/flac453sStereo.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, 'duration': 453.51473922902494, 'title': None, 'track': None, 'artist': None, 'filesize': 84236, 'bitrate': 1.4859230399999999, 'samplerate': 44100}), + ('samples/flac1.5sStereo.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', 'samplerate': 44100}), + ('samples/flac_application.flac', {'extra': {}, 'channels': 2, 'track_total': '11', 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100}), + ('samples/no-tags.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, 'duration': 3.684716553287982, 'title': None, 'track': None, 'artist': None, 'filesize': 4692, 'bitrate': 10.186943678613627, 'samplerate': 44100}), + ('samples/variable-block.flac', {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'composer': 'Boom Boom Satellites (Lyrics)', 'disc_total': '2'}), ('samples/106-invalid-streaminfo.flac', {'extra': {}, 'filesize': 4692}), - ('samples/106-short-picture-block-size.flac', {'extra': {}, 'filesize': 4692, 'bitrate': 9.94818718614612, 'channels': 2, 'duration': 3.68, 'samplerate': 44100}), - ('samples/with_id3_header.flac', {'extra': {}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1116.9186328125, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'year': '2018'}), - ('samples/with_padded_id3_header.flac', {'extra': {}, 'filesize': 16070, 'album': 'album', 'albumartist': None, 'artist': 'artist', 'audio_offset': None, 'bitrate': 276.830859375, 'channels': 1, 'comment': None, 'disc': None, 'disc_total': None, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'title', 'track': '1', 'track_total': None, 'year': '2018'}), - ('samples/with_padded_id3_header2.flac', {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'albumartist': None, 'artist': 'Unbekannter Künstler', 'audio_offset': None, 'bitrate': 336.29695312499996, 'channels': 1, 'comment': None, 'disc': '1', 'disc_total': '1', 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018'}), - ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.479655337482049, 'channels': 2, 'disc': '1', 'disc_total': '1', 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'title': 'intro', 'track': '01', 'track_total': '8'}), + ('samples/106-short-picture-block-size.flac', {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.68, 'samplerate': 44100}), + ('samples/with_id3_header.flac', {'extra': {}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'year': '2018'}), + ('samples/with_padded_id3_header.flac', {'extra': {}, 'filesize': 16070, 'album': 'album', 'albumartist': None, 'artist': 'artist', 'audio_offset': None, 'bitrate': 283.4748, 'channels': 1, 'comment': None, 'disc': None, 'disc_total': None, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'title', 'track': '1', 'track_total': None, 'year': '2018'}), + ('samples/with_padded_id3_header2.flac', {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'albumartist': None, 'artist': 'Unbekannter Künstler', 'audio_offset': None, 'bitrate': 344.36807999999996, 'channels': 1, 'comment': None, 'disc': '1', 'disc_total': '1', 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018'}), + ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': '1', 'disc_total': '1', 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'title': 'intro', 'track': '01', 'track_total': '8'}), # WMA ('samples/test2.wma', {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 86.406, 'track_total': None, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), @@ -102,10 +102,10 @@ ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M�)', 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 303040.001, 'comment': '? 2016 Mad Decent'}), # AIFF - ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1378.125, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), - ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'artist': None, 'bitrate': 1378.125, 'genre': None, 'samplerate': 44100, 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), - ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 344.53125, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), - ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, 'title': None, 'album': None, 'bitrate': 250, 'samplerate': 8000, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio', }), + ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), + ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'artist': None, 'bitrate': 1411.2, 'genre': None, 'samplerate': 44100, 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), + ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 352.8, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), + ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, 'title': None, 'album': None, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio', }), ]) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 365105c..f84fc45 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -834,7 +834,7 @@ def _parse_tag(self, fh): (channels, self.samplerate, max_bitrate, bitrate, min_bitrate) = struct.unpack(" 0: - self.bitrate = self.filesize / self.duration * 8 / 1024 + self.bitrate = self.filesize / self.duration * 8 / 1000 elif block_type == Flac.METADATA_VORBIS_COMMENT and not skip_tags: oggtag = Ogg(fh, 0) oggtag._parse_vorbis_comment(fh) @@ -1271,7 +1271,7 @@ def _determine_duration(self, fh): self.channels = aiffobj.getnchannels() self.samplerate = aiffobj.getframerate() self.duration = float(aiffobj.getnframes()) / float(self.samplerate) - self.bitrate = self.samplerate * self.channels * 16.0 / 1024.0 + self.bitrate = self.samplerate * self.channels * 16.0 / 1000.0 def _parse_tag(self, fh): fh.seek(0, 0) From 1d8d4f45ebb71d7d4af8f8b7df076c81f8a8c906 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 17:10:46 +0200 Subject: [PATCH 019/305] Don't hardcode AIFF sample size --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9430e7b..9458665 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -104,7 +104,7 @@ # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'artist': None, 'bitrate': 1411.2, 'genre': None, 'samplerate': 44100, 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), - ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 352.8, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), + ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, 'title': None, 'album': None, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio', }), ]) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ad6dcb6..af6476f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1273,7 +1273,7 @@ def _determine_duration(self, fh): self.channels = aiffobj.getnchannels() self.samplerate = aiffobj.getframerate() self.duration = float(aiffobj.getnframes()) / float(self.samplerate) - self.bitrate = self.samplerate * self.channels * 16.0 / 1000.0 + self.bitrate = self.samplerate * self.channels * aiffobj.getsampwidth() * 8 / 1000.0 def _parse_tag(self, fh): fh.seek(0, 0) From 49bbde47a6115b8b5e70e42290b0bc184a8b744d Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 21:10:47 +0200 Subject: [PATCH 020/305] Add support for ALAC audio files --- README.md | 6 +++--- tinytag/tests/samples/alac_file.m4a | Bin 0 -> 20000 bytes tinytag/tests/test_all.py | 3 ++- tinytag/tinytag.py | 19 ++++++++++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 tinytag/tests/samples/alac_file.m4a diff --git a/README.md b/README.md index cd973e3..4fa6e62 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,20 @@ Features: * Read tags, length and cover images of audio files * supported formats - * MP3 (ID3 v1, v1.1, v2.2, v2.3+) + * MP1/MP2/MP3 (ID3 v1, v1.1, v2.2, v2.3+) * Wave/RIFF * OGG * OPUS * FLAC * WMA - * MP4/M4A/M4B + * MP4/M4A/M4B/M4R/ALAC * AIFF/AIFF-C * pure python, no dependencies * supports python 2.7 and 3.4 or higher * high test coverage * Just a few hundred lines of code (just include it in your project!) -tinytag only provides the minimum needed for _reading_ MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave meta-data. +tinytag only provides the minimum needed for _reading_ meta-data. It can determine track number, total tracks, title, artist, album, year, duration and more. from tinytag import TinyTag diff --git a/tinytag/tests/samples/alac_file.m4a b/tinytag/tests/samples/alac_file.m4a new file mode 100644 index 0000000000000000000000000000000000000000..fd4ea5e9315f6f3a418e0bb38354445d2517de9a GIT binary patch literal 20000 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', esds_atom.read(4))[0] / 1000.0 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} + @classmethod + def parse_audio_sample_entry_alac(cls, data): + # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt + alac_atom_size = struct.unpack('>I', data[28:32])[0] + alac_atom = BytesIO(data[36:36 + alac_atom_size]) + alac_atom.seek(13, os.SEEK_CUR) + channels = struct.unpack('b', alac_atom.read(1))[0] + alac_atom.seek(6, os.SEEK_CUR) + avg_br = struct.unpack('>I', alac_atom.read(4))[0] / 1000.0 # kbit/s + sr = struct.unpack('>I', alac_atom.read(4))[0] + return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} + @classmethod def parse_mvhd(cls, data): # http://stackoverflow.com/a/3639993/1191373 @@ -390,8 +402,9 @@ def debug_atom(cls, data): AUDIO_DATA_TREE = { b'moov': { b'mvhd': Parser.parse_mvhd, - b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": {b'mp4a': - Parser.parse_audio_sample_entry + b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": { + b'mp4a': Parser.parse_audio_sample_entry_mp4a, + b'alac': Parser.parse_audio_sample_entry_alac }}}}} } } From 4f76a44343698343694a61e98c9c6dd40654bad0 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 22:06:52 +0200 Subject: [PATCH 021/305] Stop truncating MP3 bitrates --- tinytag/tests/test_all.py | 24 ++++++++++++------------ tinytag/tinytag.py | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9430e7b..768f276 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -32,11 +32,11 @@ testfiles = OrderedDict([ # MP3 - ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125}), + ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', 'comment': 'Ripped by THSLIVE'}), # the output of the lame encoder was 185.4 bitrate, but this is good enough for now - ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), - ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), + ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), + ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': '11', 'duration': 0.138, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', 'artist': 'Anais Mitchell', 'track': '3', 'filesize': 5120, 'audio_offset': 2225, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), ('samples/silence-44-s-v1.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'track_total': None, 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', 'title': 'Silence', 'artist': 'piman', 'track': '2', 'filesize': 15070, 'audio_offset': 0, 'bitrate': 32.0, 'comment': ''}), ('samples/id3v1-latin1.mp3', {'extra': {}, 'channels': None, 'samplerate': 44100, 'genre': 'Rock', 'samplerate': None, 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', 'artist': 'Björk', 'track_total': None, 'year': '1993', 'comment': ' '}), @@ -54,18 +54,18 @@ ('samples/id3_genre_id_out_of_bounds.mp3', {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'track': 'Marilyn', 'year': '0'}), ('samples/image-text-encoding.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, 'title': 'image-encoding', 'audio_offset': 6820, 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATINGL'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), - ('samples/nicotinetestdata.mp3', {'filesize': 80919, 'audio_offset': 45, 'channels': 2, 'duration': 5.067755102040817, 'extra': {}, 'samplerate': 44100, 'bitrate': 127}), - ('samples/chinese_id3.mp3', {'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': '1'}), - ('samples/cut_off_titles.mp3', {'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'audio_offset': 194, 'bitrate': 192, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), + ('samples/nicotinetestdata.mp3', {'filesize': 80919, 'audio_offset': 45, 'channels': 2, 'duration': 5.067755102040817, 'extra': {}, 'samplerate': 44100, 'bitrate': 127.6701030927835}), + ('samples/chinese_id3.mp3', {'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128.0, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': '1'}), + ('samples/cut_off_titles.mp3', {'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'audio_offset': 194, 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), # OGG - ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), + ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, 'samplerate': 44100}), ('samples/multipagecomment.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), - ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160, 'samplerate': 44100}), - ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160, 'samplerate': 44100, 'comment': 'hello!'}), - ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80, 'duration': 2.132358276643991, 'samplerate': 44100}), - ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 112, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), + ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100}), + ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'hello!'}), + ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80.0, 'duration': 2.132358276643991, 'samplerate': 44100}), + ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 112.0, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), # OPUS ('samples/test.opus', {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', 'comment': 'ARCD0018 - Lovelight'}), @@ -343,4 +343,4 @@ def test_to_str(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert str(tag) # since the dict is not ordered we cannot == 'somestring' assert repr(tag) # since the dict is not ordered we cannot == 'somestring' - assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' + assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160.0, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ad6dcb6..331603b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -586,7 +586,7 @@ def _determine_duration(self, fh): b = fh.peek(4) if len(b) < 4: if frames: - self.bitrate = int(bitrate_accu / frames) + self.bitrate = bitrate_accu / frames break # EOF sync, conf, bitrate_freq, rest = struct.unpack('BBBB', b[0:4]) br_id = (bitrate_freq >> 4) & 0x0F # biterate id @@ -618,7 +618,7 @@ def _determine_duration(self, fh): xframes, byte_count, toc, vbr_scale = ID3._parse_xing_header(fh) if xframes and xframes != 0 and byte_count: self.duration = xframes * ID3.samples_per_frame / float(self.samplerate) / self.channels - self.bitrate = int(byte_count * 8 / self.duration / 1000) + self.bitrate = byte_count * 8 / self.duration / 1000 self.audio_offset = fh.tell() return continue @@ -643,7 +643,7 @@ def _determine_duration(self, fh): est_frame_count = audio_stream_size / (frame_size_accu / float(frames)) samples = est_frame_count * ID3.samples_per_frame self.duration = samples / float(self.samplerate) - self.bitrate = int(bitrate_accu / frames) + self.bitrate = bitrate_accu / frames return if frame_length > 1: # jump over current frame body From 4a073da4b334bc650c253a9b11a1bd89d2e396a6 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 18 Dec 2021 01:22:59 +0200 Subject: [PATCH 022/305] Fix invalid bitrates in certain m4a files --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9430e7b..fb03410 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -99,7 +99,7 @@ # M4A/MP4 ('samples/test.m4a', {'extra': {}, 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), ('samples/test2.m4a', {'extra': {}, 'bitrate': 256.0, 'track': '1', 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': '1', 'title': 'Go Out and Get Some', 'comment': "Millie Jackson - Get It Out 'cha System - 1978", 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), - ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M�)', 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 303040.001, 'comment': '? 2016 Mad Decent'}), + ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M�)', 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ad6dcb6..8328127 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -327,22 +327,37 @@ def parse_id3v1_genre(cls, data_atom): return {'genre': ID3.ID3V1_GENRES[idx]} return {'genre': None} + @classmethod + def read_extended_descriptor(cls, esds_atom): + for i in range(4): + if esds_atom.read(1) != b'\x80': + break + @classmethod def parse_audio_sample_entry(cls, data): # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt + # http://sasperger.tistory.com/103 datafh = BytesIO(data) datafh.seek(16, os.SEEK_CUR) # jump over version and flags channels = struct.unpack('>H', datafh.read(2))[0] datafh.seek(2, os.SEEK_CUR) # jump over bit_depth datafh.seek(2, os.SEEK_CUR) # jump over QT compr id & pkt size sr = struct.unpack('>I', datafh.read(4))[0] + + # ES Description Atom esds_atom_size = struct.unpack('>I', data[28:32])[0] esds_atom = BytesIO(data[36:36 + esds_atom_size]) - # http://sasperger.tistory.com/103 - esds_atom.seek(22, os.SEEK_CUR) # jump over most data... - esds_atom.seek(4, os.SEEK_CUR) # jump over max bitrate + esds_atom.seek(5, os.SEEK_CUR) # jump over version, flags and tag + + # ES Descriptor + cls.read_extended_descriptor(esds_atom) + esds_atom.seek(4, os.SEEK_CUR) # jump over ES id, flags and tag + + # Decoder Config Descriptor + cls.read_extended_descriptor(esds_atom) + esds_atom.seek(9, os.SEEK_CUR) avg_br = struct.unpack('>I', esds_atom.read(4))[0] / 1000.0 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} From 78f945e3f7582e7fea083d64a7db0f44d5be0b93 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 18 Dec 2021 05:49:38 +0200 Subject: [PATCH 023/305] Fix metadata parsing for certain wav files In certain cases, the fmt chunk contains more data past the bit depth value, which rendered our position incorrect when reading the next chunk header. http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html --- tinytag/tests/samples/adpcm.wav | Bin 0 -> 268686 bytes tinytag/tests/test_all.py | 13 +++++++------ tinytag/tinytag.py | 13 ++++++++++--- 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 tinytag/tests/samples/adpcm.wav diff --git a/tinytag/tests/samples/adpcm.wav b/tinytag/tests/samples/adpcm.wav new file mode 100644 index 0000000000000000000000000000000000000000..d6a90085b9f9c4947536221a3c99fca292cb06eb GIT binary patch literal 268686 zcmeI*&2Cg>9LMofQWPW6x)OKYxaVa>+)!IE6KEmB1QT}!iXowxz<2{JjMw4Xi|_)x z7#FS`&ofH(9=@MR(9WSyexKh-{&~*ZEEo6g{q)7|^zFfuyD!ey`!}a)x-{)fN54+f z(g`Tbud6({IzA>5uvUzwcb%{Je8*^WyZ`dUt;R*Z!61`RV#} zx;DSt8{g?gfB*pk1PG)A%>Pu~!V(}rfB=E5fcbAnAV7cs0RlaNG5d1PBla3z+}m@IEl2fB=E8fcYOTUvvTl2oT5$ znE%=81t&m&0D-W8`5!J{bOHnj5XcIQ`9Gc+=tY140RjXFbOiS1>F@skPT!IeAV7cs zfg#}dzx{v!0RjXF^aRZRUgr`MAV7cs0rTHJK!5-N0t9*j=6|nqi3t!OK!AYxZyz8) zfB*pkJpuE-*SW+52oNAZV0`|M`~U3+1PBlyK%gaH{om?aS^@+J5FjuF%zyg<0RjXF z5a#1PBlyK%gby_Bg6w009C72qXnu|Cg*?XaWQX5Qqzy|M49_d;$ar5J(D` z|H;~gCP07yfw+MAAKwAQCqRGzfuz8g|E~W_RxdOG0t5(z1+4$W<%>>$009D7fpP!8 z^?$a0!3hu`Kp-k${zr=!n*ad<1kwVY|Cg>`cmf0n5Qqvm{~s-0Yyt!b5J(F+{-3U2 zcmf0n5Qqv`|3`}#n*ad<1kwWLf4Y9*2@oJaASy8Czx)59<%>;#009D70rNjwz2F20 z5Fij1aQ|Ppe9;LIAV451;QW8Kdcg@0AV452VE%{87o7kB0tB)G=6|+&!3hu`Kp-sO z{C~K7(FqVBKp-n%{hzH~Z~_Df5C{wG&3FHAHemqK2@oJafIv{d`af8@$OH%wAdnXr z^WXYEzZ(D`K!5;&n1JNoNFFgSQ1PC+*%>QQZQWGFR zfIwTo{BQR!Jplp)2s8ye|F7A*)C33+AkY@D{%`j$Jplp)2s8!E|7Pz}6CglNoNFFgSQ1PC+*JpZrRyVL{-5FpSN80-JdrfncS0RjXF z5U2@^`M*;OEiVBA1PBm_3poED-vPuYK!5;&q=5B*vUZ^f5FkJxE@1s1-vPuYK!5;& zq=5OKtX*gV1PBm_3z+}$9YA~n1PBmF3Yh=N+Jz=SfB=EGfcYQa0mLUjfB=D{zG3r&Ck0RmwG&;JdVFFFAN1PEjW#{BpE-)#MY6CglR;9DQ+e@8RO);P~qN_4@4XdVl?L{pxJq{%Co0vF-HL(;v_0 zZ3oNMf7{-yU%p*Wi^Jp9C-494vmegS&t9+R?>t-{eK2pmar^f5`Q0}s%Zoqv{n_g` z{ z`0tNd9X>pnH+}qm^Y(9E9W4*%4VUL%x=K-rdlmlH~+tJ T8-Mla?u~il#lQLVpR9ia7E7k_ literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9430e7b..aacdcd4 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -72,12 +72,13 @@ ('samples/8khz_5s.opus', {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), # WAV - ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offest': 36}), - ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, 'duration': 3.0, 'samplerate': 44100, 'audio_offest': 36}), - ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': '66', 'audio_offest': 36, 'comment': 'hello', 'year': '2014'}), - ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': None, 'audio_offest': 36, 'comment': 'hello', 'year': '2014'}), - ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offest': 4088}), - ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offest': 122, 'artist': 'Purpley', 'title': 'Test000', 'track': '17'}), + ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offset': 36}), + ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, 'duration': 3.0, 'samplerate': 44100, 'audio_offset': 36}), + ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': '66', 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), + ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': None, 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), + ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offset': 4088}), + ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offset': 122, 'artist': 'Purpley', 'title': 'Test000', 'track': '17'}), + ('samples/adpcm.wav', {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, 'bitrate': 176.4, 'samplerate': 44100, 'audio_offset': 82, 'artist': 'test artist', 'title': 'test title', 'track': '1', 'album': 'test album', 'comment': 'test comment', 'genre': 'test genre', 'year': '1990'}), # FLAC ('samples/flac1sMono.flac', {'extra': {}, 'genre': 'Avantgarde', 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100}), diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ad6dcb6..b68ce26 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -941,7 +941,7 @@ def __init__(self, filehandler, filesize, *args, **kwargs): self._duration_parsed = False def _determine_duration(self, fh): - # see: https://ccrma.stanford.edu/courses/422/projects/WaveFormat/ + # see: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html # and: https://en.wikipedia.org/wiki/WAV riff, size, fformat = struct.unpack('4sI4s', fh.read(12)) if riff != b'RIFF' or fformat != b'WAVE': @@ -953,10 +953,17 @@ def _determine_duration(self, fh): if subchunkid == b'fmt ': _, self.channels, self.samplerate = struct.unpack('HHI', fh.read(8)) _, _, bitdepth = struct.unpack(' 0: + fh.seek(remaining_size, 1) # skip remaining data in chunk elif subchunkid == b'data': - self.duration = float(subchunksize)/self.channels/self.samplerate/(bitdepth/8) - self.audio_offest = fh.tell() - 8 # rewind to data header + self.duration = float(subchunksize) / self.channels / self.samplerate / (bitdepth / 8) + self.audio_offset = fh.tell() - 8 # rewind to data header fh.seek(subchunksize, 1) elif subchunkid == b'LIST': is_info = fh.read(4) # check INFO header From bffde61abb9e5c8170fec5a63be37694c11d80e3 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 26 Feb 2022 09:10:35 +0200 Subject: [PATCH 024/305] ID3: Check for every language in COMM and USLT frames --- tinytag/tests/samples/id3_xxx_lang.mp3 | Bin 0 -> 6943 bytes tinytag/tests/test_all.py | 1 + tinytag/tinytag.py | 6 +++--- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 tinytag/tests/samples/id3_xxx_lang.mp3 diff --git a/tinytag/tests/samples/id3_xxx_lang.mp3 b/tinytag/tests/samples/id3_xxx_lang.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..921f6bd5e7c7da7a6e4e6bbfe1d4cf6aee15c00d GIT binary patch literal 6943 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 Date: Sat, 26 Feb 2022 09:24:34 +0200 Subject: [PATCH 025/305] Don't use regex when parsing genre Minor performance improvement. --- tinytag/tinytag.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ad6dcb6..b5b1193 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -231,9 +231,8 @@ def _set_field(self, fieldname, bytestring, transfunc=None, overwrite=True): if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field genre_id = int(value) else: # funkier: the TCO may contain genres in parens, e.g. '(13)' - genre_in_parens = re.match('^\\((\\d+)\\)$', value) - if genre_in_parens: - genre_id = int(genre_in_parens.group(1)) + if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): + genre_id = int(value[1:-1]) if 0 <= genre_id < len(ID3.ID3V1_GENRES): value = ID3.ID3V1_GENRES[genre_id] if fieldname in ("track", "disc", "track_total", "disc_total"): From 5433e6258de33bc1c4f51fb940c5b20047155902 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 26 Feb 2022 14:25:22 +0200 Subject: [PATCH 026/305] Disable tag parsing for all formats when requested --- tinytag/tinytag.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ad6dcb6..b227d80 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -108,6 +108,7 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.track = None self.track_total = None self.year = None + self._parse_tags = True self._load_image = False self._image_data = None self._ignore_errors = ignore_errors @@ -201,6 +202,7 @@ def __repr__(self): return str(self) def load(self, tags, duration, image=False): + self._parse_tags = tags self._load_image = image if tags: self._parse_tag(self._filehandler) @@ -836,7 +838,7 @@ def _parse_tag(self, fh): if not self.audio_offset: self.bitrate = bitrate / 1000.0 self.audio_offset = page_start_pos - elif packet[0:7] == b"\x03vorbis": + elif packet[0:7] == b"\x03vorbis" and self._parse_tags: walker.seek(7, os.SEEK_CUR) # jump over header name self._parse_vorbis_comment(walker) elif packet[0:8] == b'OpusHead': # parse opus header @@ -847,7 +849,7 @@ def _parse_tag(self, fh): if (version & 0xF0) == 0: # only major version 0 supported self.channels = ch self.samplerate = 48000 # internally opus always uses 48khz - elif packet[0:8] == b'OpusTags': # parse opus metadata: + elif packet[0:8] == b'OpusTags' and self._parse_tags: # parse opus metadata: walker.seek(8, os.SEEK_CUR) # jump over header name self._parse_vorbis_comment(walker) else: @@ -958,7 +960,7 @@ def _determine_duration(self, fh): self.duration = float(subchunksize)/self.channels/self.samplerate/(bitdepth/8) self.audio_offest = fh.tell() - 8 # rewind to data header fh.seek(subchunksize, 1) - elif subchunkid == b'LIST': + elif subchunkid == b'LIST' and self._parse_tags: is_info = fh.read(4) # check INFO header if is_info != b'INFO': # jump over non-INFO sections fh.seek(subchunksize - 4, os.SEEK_CUR) @@ -973,7 +975,7 @@ def _determine_duration(self, fh): if fieldname: self._set_field(fieldname, data) field = sub_fh.read(4) - elif subchunkid == b'id3 ' or subchunkid == b'ID3 ': + elif subchunkid in (b'id3 ', b'ID3 ') and self._parse_tags: id3 = ID3(fh, 0) id3._parse_id3v2(fh) self.update(id3) @@ -997,6 +999,7 @@ class Flac(TinyTag): METADATA_PICTURE = 6 def load(self, tags, duration, image=False): + self._parse_tags = tags self._load_image = image header = self._filehandler.peek(4) if header[:3] == b'ID3': # parse ID3 header if it exists @@ -1007,9 +1010,9 @@ def load(self, tags, duration, image=False): if header[:4] != b'fLaC': raise TinyTagException('Invalid flac header') self._filehandler.seek(4, os.SEEK_CUR) - self._determine_duration(self._filehandler, skip_tags=not tags) + self._determine_duration(self._filehandler) - def _determine_duration(self, fh, skip_tags=False): + def _determine_duration(self, fh): # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data): @@ -1051,7 +1054,7 @@ def _determine_duration(self, fh, skip_tags=False): self.duration = float(total_samples) / self.samplerate if self.duration > 0: self.bitrate = self.filesize / self.duration * 8 / 1000 - elif block_type == Flac.METADATA_VORBIS_COMMENT and not skip_tags: + elif block_type == Flac.METADATA_VORBIS_COMMENT and self._parse_tags: oggtag = Ogg(fh, 0) oggtag._parse_vorbis_comment(fh) self.update(oggtag) @@ -1141,7 +1144,7 @@ def _parse_tag(self, fh): object_size = _bytes_to_int_le(fh.read(8)) if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. - if object_id == Wma.ASF_CONTENT_DESCRIPTION_OBJECT: + if object_id == Wma.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: len_blocks = self.read_blocks(fh, [ ('title_length', 2, True), ('author_length', 2, True), @@ -1159,7 +1162,7 @@ def _parse_tag(self, fh): for field_name, bytestring in data_blocks.items(): if field_name: self._set_field(field_name, bytestring, self.__decode_string) - elif object_id == Wma.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT: + elif object_id == Wma.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: mapping = { 'WM/TrackNumber': 'track', 'WM/PartOfSet': 'disc', From 77512690e9dbbca48b86fdb1afdea1ffcfee4633 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 4 Mar 2022 11:59:04 +0100 Subject: [PATCH 027/305] fixed a problem introduced while merging --- tinytag/tinytag.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 1bc2696..c45d837 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -334,6 +334,7 @@ def read_extended_descriptor(cls, esds_atom): if esds_atom.read(1) != b'\x80': break + @classmethod def parse_audio_sample_entry_mp4a(cls, data): # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html From 1cfccb852a234aa4b643b158817fa8921fa7e837 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 18 Dec 2021 01:22:59 +0200 Subject: [PATCH 028/305] Fix invalid bitrates in certain m4a files --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 21 ++++++++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index aacdcd4..46bcc1a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -100,7 +100,7 @@ # M4A/MP4 ('samples/test.m4a', {'extra': {}, 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), ('samples/test2.m4a', {'extra': {}, 'bitrate': 256.0, 'track': '1', 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': '1', 'title': 'Go Out and Get Some', 'comment': "Millie Jackson - Get It Out 'cha System - 1978", 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), - ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M�)', 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 303040.001, 'comment': '? 2016 Mad Decent'}), + ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M�)', 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b68ce26..a130809 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -327,22 +327,37 @@ def parse_id3v1_genre(cls, data_atom): return {'genre': ID3.ID3V1_GENRES[idx]} return {'genre': None} + @classmethod + def read_extended_descriptor(cls, esds_atom): + for i in range(4): + if esds_atom.read(1) != b'\x80': + break + @classmethod def parse_audio_sample_entry(cls, data): # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt + # http://sasperger.tistory.com/103 datafh = BytesIO(data) datafh.seek(16, os.SEEK_CUR) # jump over version and flags channels = struct.unpack('>H', datafh.read(2))[0] datafh.seek(2, os.SEEK_CUR) # jump over bit_depth datafh.seek(2, os.SEEK_CUR) # jump over QT compr id & pkt size sr = struct.unpack('>I', datafh.read(4))[0] + + # ES Description Atom esds_atom_size = struct.unpack('>I', data[28:32])[0] esds_atom = BytesIO(data[36:36 + esds_atom_size]) - # http://sasperger.tistory.com/103 - esds_atom.seek(22, os.SEEK_CUR) # jump over most data... - esds_atom.seek(4, os.SEEK_CUR) # jump over max bitrate + esds_atom.seek(5, os.SEEK_CUR) # jump over version, flags and tag + + # ES Descriptor + cls.read_extended_descriptor(esds_atom) + esds_atom.seek(4, os.SEEK_CUR) # jump over ES id, flags and tag + + # Decoder Config Descriptor + cls.read_extended_descriptor(esds_atom) + esds_atom.seek(9, os.SEEK_CUR) avg_br = struct.unpack('>I', esds_atom.read(4))[0] / 1000.0 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} From be164130830a30e6f88ef5cecf6c9823e46c3099 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 22:06:52 +0200 Subject: [PATCH 029/305] Stop truncating MP3 bitrates --- tinytag/tests/test_all.py | 24 ++++++++++++------------ tinytag/tinytag.py | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 46bcc1a..3837bd3 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -32,11 +32,11 @@ testfiles = OrderedDict([ # MP3 - ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125}), + ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', 'comment': 'Ripped by THSLIVE'}), # the output of the lame encoder was 185.4 bitrate, but this is good enough for now - ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), - ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), + ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), + ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': '11', 'duration': 0.138, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', 'artist': 'Anais Mitchell', 'track': '3', 'filesize': 5120, 'audio_offset': 2225, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), ('samples/silence-44-s-v1.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'track_total': None, 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', 'title': 'Silence', 'artist': 'piman', 'track': '2', 'filesize': 15070, 'audio_offset': 0, 'bitrate': 32.0, 'comment': ''}), ('samples/id3v1-latin1.mp3', {'extra': {}, 'channels': None, 'samplerate': 44100, 'genre': 'Rock', 'samplerate': None, 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', 'artist': 'Björk', 'track_total': None, 'year': '1993', 'comment': ' '}), @@ -54,18 +54,18 @@ ('samples/id3_genre_id_out_of_bounds.mp3', {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'track': 'Marilyn', 'year': '0'}), ('samples/image-text-encoding.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, 'title': 'image-encoding', 'audio_offset': 6820, 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATINGL'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), - ('samples/nicotinetestdata.mp3', {'filesize': 80919, 'audio_offset': 45, 'channels': 2, 'duration': 5.067755102040817, 'extra': {}, 'samplerate': 44100, 'bitrate': 127}), - ('samples/chinese_id3.mp3', {'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': '1'}), - ('samples/cut_off_titles.mp3', {'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'audio_offset': 194, 'bitrate': 192, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), + ('samples/nicotinetestdata.mp3', {'filesize': 80919, 'audio_offset': 45, 'channels': 2, 'duration': 5.067755102040817, 'extra': {}, 'samplerate': 44100, 'bitrate': 127.6701030927835}), + ('samples/chinese_id3.mp3', {'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128.0, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': '1'}), + ('samples/cut_off_titles.mp3', {'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'audio_offset': 194, 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), # OGG - ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), + ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, 'samplerate': 44100}), ('samples/multipagecomment.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), - ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160, 'samplerate': 44100}), - ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160, 'samplerate': 44100, 'comment': 'hello!'}), - ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80, 'duration': 2.132358276643991, 'samplerate': 44100}), - ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 112, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), + ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100}), + ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'hello!'}), + ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80.0, 'duration': 2.132358276643991, 'samplerate': 44100}), + ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 112.0, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), # OPUS ('samples/test.opus', {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', 'comment': 'ARCD0018 - Lovelight'}), @@ -344,4 +344,4 @@ def test_to_str(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert str(tag) # since the dict is not ordered we cannot == 'somestring' assert repr(tag) # since the dict is not ordered we cannot == 'somestring' - assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' + assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160.0, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a130809..8a415df 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -601,7 +601,7 @@ def _determine_duration(self, fh): b = fh.peek(4) if len(b) < 4: if frames: - self.bitrate = int(bitrate_accu / frames) + self.bitrate = bitrate_accu / frames break # EOF sync, conf, bitrate_freq, rest = struct.unpack('BBBB', b[0:4]) br_id = (bitrate_freq >> 4) & 0x0F # biterate id @@ -633,7 +633,7 @@ def _determine_duration(self, fh): xframes, byte_count, toc, vbr_scale = ID3._parse_xing_header(fh) if xframes and xframes != 0 and byte_count: self.duration = xframes * ID3.samples_per_frame / float(self.samplerate) / self.channels - self.bitrate = int(byte_count * 8 / self.duration / 1000) + self.bitrate = byte_count * 8 / self.duration / 1000 self.audio_offset = fh.tell() return continue @@ -658,7 +658,7 @@ def _determine_duration(self, fh): est_frame_count = audio_stream_size / (frame_size_accu / float(frames)) samples = est_frame_count * ID3.samples_per_frame self.duration = samples / float(self.samplerate) - self.bitrate = int(bitrate_accu / frames) + self.bitrate = bitrate_accu / frames return if frame_length > 1: # jump over current frame body From 5cde59a57592d40eaa2526e3ef89422e06db4473 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 26 Feb 2022 14:25:22 +0200 Subject: [PATCH 030/305] Disable tag parsing for all formats when requested --- tinytag/tinytag.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 8a415df..b4a83cd 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -108,6 +108,7 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.track = None self.track_total = None self.year = None + self._parse_tags = True self._load_image = False self._image_data = None self._ignore_errors = ignore_errors @@ -201,6 +202,7 @@ def __repr__(self): return str(self) def load(self, tags, duration, image=False): + self._parse_tags = tags self._load_image = image if tags: self._parse_tag(self._filehandler) @@ -851,7 +853,7 @@ def _parse_tag(self, fh): if not self.audio_offset: self.bitrate = bitrate / 1000.0 self.audio_offset = page_start_pos - elif packet[0:7] == b"\x03vorbis": + elif packet[0:7] == b"\x03vorbis" and self._parse_tags: walker.seek(7, os.SEEK_CUR) # jump over header name self._parse_vorbis_comment(walker) elif packet[0:8] == b'OpusHead': # parse opus header @@ -862,7 +864,7 @@ def _parse_tag(self, fh): if (version & 0xF0) == 0: # only major version 0 supported self.channels = ch self.samplerate = 48000 # internally opus always uses 48khz - elif packet[0:8] == b'OpusTags': # parse opus metadata: + elif packet[0:8] == b'OpusTags' and self._parse_tags: # parse opus metadata: walker.seek(8, os.SEEK_CUR) # jump over header name self._parse_vorbis_comment(walker) else: @@ -980,7 +982,7 @@ def _determine_duration(self, fh): self.duration = float(subchunksize) / self.channels / self.samplerate / (bitdepth / 8) self.audio_offset = fh.tell() - 8 # rewind to data header fh.seek(subchunksize, 1) - elif subchunkid == b'LIST': + elif subchunkid == b'LIST' and self._parse_tags: is_info = fh.read(4) # check INFO header if is_info != b'INFO': # jump over non-INFO sections fh.seek(subchunksize - 4, os.SEEK_CUR) @@ -995,7 +997,7 @@ def _determine_duration(self, fh): if fieldname: self._set_field(fieldname, data) field = sub_fh.read(4) - elif subchunkid == b'id3 ' or subchunkid == b'ID3 ': + elif subchunkid in (b'id3 ', b'ID3 ') and self._parse_tags: id3 = ID3(fh, 0) id3._parse_id3v2(fh) self.update(id3) @@ -1019,6 +1021,7 @@ class Flac(TinyTag): METADATA_PICTURE = 6 def load(self, tags, duration, image=False): + self._parse_tags = tags self._load_image = image header = self._filehandler.peek(4) if header[:3] == b'ID3': # parse ID3 header if it exists @@ -1029,9 +1032,9 @@ def load(self, tags, duration, image=False): if header[:4] != b'fLaC': raise TinyTagException('Invalid flac header') self._filehandler.seek(4, os.SEEK_CUR) - self._determine_duration(self._filehandler, skip_tags=not tags) + self._determine_duration(self._filehandler) - def _determine_duration(self, fh, skip_tags=False): + def _determine_duration(self, fh): # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data): @@ -1073,7 +1076,7 @@ def _determine_duration(self, fh, skip_tags=False): self.duration = float(total_samples) / self.samplerate if self.duration > 0: self.bitrate = self.filesize / self.duration * 8 / 1000 - elif block_type == Flac.METADATA_VORBIS_COMMENT and not skip_tags: + elif block_type == Flac.METADATA_VORBIS_COMMENT and self._parse_tags: oggtag = Ogg(fh, 0) oggtag._parse_vorbis_comment(fh) self.update(oggtag) @@ -1163,7 +1166,7 @@ def _parse_tag(self, fh): object_size = _bytes_to_int_le(fh.read(8)) if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. - if object_id == Wma.ASF_CONTENT_DESCRIPTION_OBJECT: + if object_id == Wma.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: len_blocks = self.read_blocks(fh, [ ('title_length', 2, True), ('author_length', 2, True), @@ -1181,7 +1184,7 @@ def _parse_tag(self, fh): for field_name, bytestring in data_blocks.items(): if field_name: self._set_field(field_name, bytestring, self.__decode_string) - elif object_id == Wma.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT: + elif object_id == Wma.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: mapping = { 'WM/TrackNumber': 'track', 'WM/PartOfSet': 'disc', From 5c46ec15a2642a8839ff6aed489f3ed29a766143 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 26 Feb 2022 09:24:34 +0200 Subject: [PATCH 031/305] Don't use regex when parsing genre Minor performance improvement. --- tinytag/tinytag.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b4a83cd..bcc10ea 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -233,9 +233,8 @@ def _set_field(self, fieldname, bytestring, transfunc=None, overwrite=True): if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field genre_id = int(value) else: # funkier: the TCO may contain genres in parens, e.g. '(13)' - genre_in_parens = re.match('^\\((\\d+)\\)$', value) - if genre_in_parens: - genre_id = int(genre_in_parens.group(1)) + if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): + genre_id = int(value[1:-1]) if 0 <= genre_id < len(ID3.ID3V1_GENRES): value = ID3.ID3V1_GENRES[genre_id] if fieldname in ("track", "disc", "track_total", "disc_total"): From fb32a54a820dee29f22b95215c71791710f9e48e Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 26 Feb 2022 09:10:35 +0200 Subject: [PATCH 032/305] ID3: Check for every language in COMM and USLT frames --- tinytag/tests/samples/id3_xxx_lang.mp3 | Bin 0 -> 6943 bytes tinytag/tests/test_all.py | 1 + tinytag/tinytag.py | 6 +++--- 3 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 tinytag/tests/samples/id3_xxx_lang.mp3 diff --git a/tinytag/tests/samples/id3_xxx_lang.mp3 b/tinytag/tests/samples/id3_xxx_lang.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..921f6bd5e7c7da7a6e4e6bbfe1d4cf6aee15c00d GIT binary patch literal 6943 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 Date: Fri, 17 Dec 2021 21:10:47 +0200 Subject: [PATCH 033/305] Add support for ALAC audio files --- README.md | 6 +++--- tinytag/tests/samples/alac_file.m4a | Bin 0 -> 20000 bytes tinytag/tests/test_all.py | 3 ++- tinytag/tinytag.py | 17 +++++++++++++++-- 4 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 tinytag/tests/samples/alac_file.m4a diff --git a/README.md b/README.md index cd973e3..4fa6e62 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,20 @@ Features: * Read tags, length and cover images of audio files * supported formats - * MP3 (ID3 v1, v1.1, v2.2, v2.3+) + * MP1/MP2/MP3 (ID3 v1, v1.1, v2.2, v2.3+) * Wave/RIFF * OGG * OPUS * FLAC * WMA - * MP4/M4A/M4B + * MP4/M4A/M4B/M4R/ALAC * AIFF/AIFF-C * pure python, no dependencies * supports python 2.7 and 3.4 or higher * high test coverage * Just a few hundred lines of code (just include it in your project!) -tinytag only provides the minimum needed for _reading_ MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave meta-data. +tinytag only provides the minimum needed for _reading_ meta-data. It can determine track number, total tracks, title, artist, album, year, duration and more. from tinytag import TinyTag diff --git a/tinytag/tests/samples/alac_file.m4a b/tinytag/tests/samples/alac_file.m4a new file mode 100644 index 0000000000000000000000000000000000000000..fd4ea5e9315f6f3a418e0bb38354445d2517de9a GIT binary patch literal 20000 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', esds_atom.read(4))[0] / 1000.0 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} + @classmethod + def parse_audio_sample_entry_alac(cls, data): + # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt + alac_atom_size = struct.unpack('>I', data[28:32])[0] + alac_atom = BytesIO(data[36:36 + alac_atom_size]) + alac_atom.seek(13, os.SEEK_CUR) + channels = struct.unpack('b', alac_atom.read(1))[0] + alac_atom.seek(6, os.SEEK_CUR) + avg_br = struct.unpack('>I', alac_atom.read(4))[0] / 1000.0 # kbit/s + sr = struct.unpack('>I', alac_atom.read(4))[0] + return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} + @classmethod def parse_mvhd(cls, data): # http://stackoverflow.com/a/3639993/1191373 @@ -406,8 +418,9 @@ def debug_atom(cls, data): AUDIO_DATA_TREE = { b'moov': { b'mvhd': Parser.parse_mvhd, - b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": {b'mp4a': - Parser.parse_audio_sample_entry + b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": { + b'mp4a': Parser.parse_audio_sample_entry_mp4a, + b'alac': Parser.parse_audio_sample_entry_alac }}}}} } } From 85d30ea1313c7216df9ed32224dbc5840286e70c Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 17 Dec 2021 17:10:46 +0200 Subject: [PATCH 034/305] Don't hardcode AIFF sample size --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index c61a0f8..3967b4b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -107,7 +107,7 @@ # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'artist': None, 'bitrate': 1411.2, 'genre': None, 'samplerate': 44100, 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), - ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 352.8, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), + ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, 'title': None, 'album': None, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio', }), ]) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index edaebd8..b275efe 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1310,7 +1310,7 @@ def _determine_duration(self, fh): self.channels = aiffobj.getnchannels() self.samplerate = aiffobj.getframerate() self.duration = float(aiffobj.getnframes()) / float(self.samplerate) - self.bitrate = self.samplerate * self.channels * 16.0 / 1000.0 + self.bitrate = self.samplerate * self.channels * aiffobj.getsampwidth() * 8 / 1000.0 def _parse_tag(self, fh): fh.seek(0, 0) From 04e53bddfed0734bca113cd0e0609b3c4cd2b10d Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 4 Mar 2022 11:59:04 +0100 Subject: [PATCH 035/305] fixed a problem introduced while merging --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b275efe..674891a 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -335,7 +335,7 @@ def read_extended_descriptor(cls, esds_atom): break @classmethod - def parse_audio_sample_entry(cls, data): + def parse_audio_sample_entry_mp4a(cls, data): # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt From 82779dfcbbf8cd28071395942694759281d1a8bd Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Sat, 5 Mar 2022 19:42:16 +0100 Subject: [PATCH 036/305] bumped version to 1.8.0, added changelog --- README.md | 17 +++++++++++++---- tinytag/__init__.py | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4fa6e62..48e155d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ tinytag ======= -tinytag is a library for reading music meta data of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files with python +tinytag is a library for reading music meta data of most common audio files in pure python [![Build Status](https://travis-ci.org/devsnd/tinytag.png?branch=master)](https://travis-ci.org/devsnd/tinytag) [![Build status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) @@ -20,7 +20,7 @@ Features: * Read tags, length and cover images of audio files * supported formats - * MP1/MP2/MP3 (ID3 v1, v1.1, v2.2, v2.3+) + * MP3/MP2/MP1 (ID3 v1, v1.1, v2.2, v2.3+) * Wave/RIFF * OGG * OPUS @@ -34,7 +34,7 @@ Features: * Just a few hundred lines of code (just include it in your project!) tinytag only provides the minimum needed for _reading_ meta-data. -It can determine track number, total tracks, title, artist, album, year, duration and more. +It can determine track number, total tracks, title, artist, album, year, duration and any more. from tinytag import TinyTag tag = TinyTag.get('/some/music.mp3') @@ -46,7 +46,7 @@ Alternatively you can use tinytag directly on the command line: $ python -m tinytag --format csv /some/music.mp3 > {"filename": "/some/music.mp3", "filesize": 30212227, "album": "Album", "albumartist": "Artist", "artist": "Artist", "audio_offset": null, "bitrate": 256, "channels": 2, "comment": null, "composer": null, "disc": "1", "disc_total": null, "duration": 10, "genre": null, "samplerate": 44100, "title": "Title", "track": "5", "track_total": null, "year": "2012"} -Check `python -m tinytag --help` for all CLI options, for example other output formats` +Check `python -m tinytag --help` for all CLI options, for example other output formats List of possible attributes you can get with TinyTag: @@ -87,6 +87,15 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') Changelog: + * 1.8.0 (2022-03-05) [mathiascode-edition] + - Add support for ALAC audio files #130 (thanks to mathiascode) + - AIFF: Fixed bitrate calculation for certain files #129 (thanks to mathiascode) + - MP3: Do not round MP3 bitrates #131 (thanks to mathiascode) + - MP3 ID3: Support any language in COMM and USLT frames #135 (thanks to mathiascode) + - Performance: Don't use regex when parsing genre #136 (thanks to mathiascode) + - Disable tag parsing for all formats when requested #137 (thanks to mathiascode) + - M4A: Fix invalid bitrates in certain files #132 (thanks to mathiascode) + - WAV: Fix metadata parsing for certain files #133 (thanks to mathiascode) * 1.7.0. (2021-12-14) - fixed rare occasion of ID3v2 tags missing their first character, #106 - allow overriding the default encoding of ID3 tags (e.g. `TinyTag.get(..., encoding='gbk'))`) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index b663927..b07df12 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,7 +4,7 @@ import sys -__version__ = '1.7.0' +__version__ = '1.8.0' if __name__ == '__main__': print(TinyTag.get(sys.argv[1])) From a730ae9b5f1458cae366497ad50b5b1d372cd8f6 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Fri, 11 Mar 2022 03:48:34 +0200 Subject: [PATCH 037/305] Add flake8 linting --- .github/workflows/tests.yml | 34 +++ setup.cfg | 4 +- setup.py | 8 +- tinytag/__init__.py | 3 +- tinytag/__main__.py | 23 +- tinytag/tests/test_all.py | 459 ++++++++++++++++++++++++++++-------- tinytag/tests/test_cli.py | 9 +- tinytag/tinytag.py | 129 +++++----- 8 files changed, 493 insertions(+), 176 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7edf5bc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: [push, pull_request] + +jobs: + + tests: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8'] + exclude: + - os: macos-latest + python: 'pypy-3.6' # Not installable + steps: + - name: Checkout code + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python }} + + - name: Install dependencies + run: python -m pip install flake8 pytest pytest-cov + + - name: Flake8 linter + run: python -m flake8 + + - name: Unit tests + run: python -m pytest --cov diff --git a/setup.cfg b/setup.cfg index 89a86ea..7d202e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,16 +42,14 @@ install_requires = tests = pytest pytest-cov - coveralls flake8 [options.entry_points] console_scripts = [flake8] -max-line-length = 132 +max-line-length = 100 exclude = .git,__pycache__,.eggs/,doc/,docs/,build/,dist/,archive/,src/ -ignore = E501 [coverage:run] cover_pylib = false diff --git a/setup.py b/setup.py index 812ab51..1c4046d 100755 --- a/setup.py +++ b/setup.py @@ -9,8 +9,6 @@ def get_version(): return version_line.split("=")[1].strip().strip("\"'") -setup( - name="tinytag", - version=get_version(), - packages=find_packages(), - ) +setup(name="tinytag", + version=get_version(), + packages=find_packages()) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index b07df12..ddb41e7 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,10 +1,11 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac import sys +from .tinytag import TinyTag __version__ = '1.8.0' + if __name__ == '__main__': print(TinyTag.get(sys.argv[1])) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 83ce9a1..0a6c73b 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,26 +1,29 @@ +from __future__ import absolute_import +from os.path import splitext import os import json import sys -from os.path import splitext -from tinytag import TinyTag, TinyTagException +from tinytag.tinytag import TinyTag, TinyTagException + def usage(): print('''tinytag [options] - + -h, --help Display help - + -i, --save-image Save the cover art to a file - + -f, --format json|csv|tsv|tabularcsv Specify how the output should be formatted - + -s, --skip-unsupported - Skip files that do not have a file extension supported by tinytag - - ''') + Skip files that do not have a file extension supported by tinytag + +''') + def pop_param(name, _default): if name in sys.argv: @@ -29,6 +32,7 @@ def pop_param(name, _default): return sys.argv.pop(idx) return _default + def pop_switch(name, _default): if name in sys.argv: idx = sys.argv.index(name) @@ -36,6 +40,7 @@ def pop_switch(name, _default): return True return False + try: display_help = pop_switch('--help', False) or pop_switch('-h', False) if display_help: diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 7f48433..0bf3104 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -12,17 +12,14 @@ import io import os +import re import shutil import sys -import tempfile import pytest -import re - from pytest import raises -from tinytag import TinyTagException, TinyTag, ID3, Ogg, Wave, Flac -from tinytag.tinytag import Wma, MP4, Aiff +from tinytag.tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac, Wma, MP4, Aiff try: from collections import OrderedDict @@ -32,83 +29,307 @@ testfiles = OrderedDict([ # MP3 - ('samples/vbri.mp3', {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), - ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', 'comment': 'Ripped by THSLIVE'}), + ('samples/vbri.mp3', + {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, + 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', + 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', + 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', + 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), + ('samples/cbr.mp3', + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.49, + 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', + 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', + 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', + 'comment': 'Ripped by THSLIVE'}), # the output of the lame encoder was 185.4 bitrate, but this is good enough for now - ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), - ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), - ('samples/id3v22-test.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': '11', 'duration': 0.138, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', 'artist': 'Anais Mitchell', 'track': '3', 'filesize': 5120, 'audio_offset': 2225, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), - ('samples/silence-44-s-v1.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'track_total': None, 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', 'title': 'Silence', 'artist': 'piman', 'track': '2', 'filesize': 15070, 'audio_offset': 0, 'bitrate': 32.0, 'comment': ''}), - ('samples/id3v1-latin1.mp3', {'extra': {}, 'channels': None, 'samplerate': 44100, 'genre': 'Rock', 'samplerate': None, 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', 'artist': 'Björk', 'track_total': None, 'year': '1993', 'comment': ' '}), - ('samples/UTF16.mp3', {'extra': {'text': 'MusicBrainz Artist Id664c3e0e-42d8-48c1-b209-1efca19c0325', 'url': 'WIKIPEDIA_RELEASEhttp://en.wikipedia.org/wiki/High_Violet'}, 'channels': None, 'samplerate': None, 'track_total': '11', 'track': '07', 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), - ('samples/utf-8-id3v2.mp3', {'extra': {}, 'channels': None, 'samplerate': 44100, 'genre': 'Acustico', 'track_total': '21', 'track': '01', 'filesize': 2119, 'title': 'Gran día', 'artist': 'Paso a paso', 'album': 'S/T', 'year': None, 'samplerate': None, 'disc': '', 'disc_total': '0'}), - ('samples/empty_file.mp3', {'extra': {}, 'channels': None, 'samplerate': None, 'track_total': None, 'album': None, 'year': None, 'title': None, 'track': None, 'artist': None, 'filesize': 0}), - ('samples/silence-44khz-56k-mono-1s.mp3', {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.018, 'samplerate': 44100, 'filesize': 7280, 'audio_offset': 0, 'bitrate': 56.0}), - ('samples/silence-22khz-mono-1s.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, 'audio_offset': 0, 'bitrate': 32.0, 'duration': 1.0438932496075353}), - ('samples/id3v24-long-title.mp3', {'extra': {}, 'track': '1', 'disc_total': '1', 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, 'channels': None, 'track_total': '12', 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', 'albumartist': 'Courtney Barnett', 'samplerate': None, 'year': None, 'disc': '1', 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett'}), - ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'albumartist': None, 'disc': None, 'channels': None}), - ('samples/id3v22_image.mp3', {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', 'channels': None, 'genre': '.'}), - ('samples/id3v22.TCO.genre.mp3', {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', 'comment': 'engiTunPGAP0', 'genre': 'Pop', 'title': 'Applause'}), - ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {'isrc': 'USTC40852229'}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'comment': '', 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), - ('samples/id3_comment_utf_16_double_bom.mp3', {'extra': {'text': 'LABEL\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', 'title': 'The Embrace (Romano Alfieri Remix)', 'track': '04-johannes_heil_and_d.diggler-the_embrace_(romano_alfieri_remix)', 'year': '2012'}), - ('samples/id3_genre_id_out_of_bounds.mp3', {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'track': 'Marilyn', 'year': '0'}), - ('samples/image-text-encoding.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, 'title': 'image-encoding', 'audio_offset': 6820, 'bitrate': 32.0, 'duration': 1.0438932496075353}), - ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATINGL'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), - ('samples/nicotinetestdata.mp3', {'filesize': 80919, 'audio_offset': 45, 'channels': 2, 'duration': 5.067755102040817, 'extra': {}, 'samplerate': 44100, 'bitrate': 127.6701030927835}), - ('samples/chinese_id3.mp3', {'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128.0, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': '1'}), - ('samples/cut_off_titles.mp3', {'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'audio_offset': 194, 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'extra': {}, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), - ('samples/id3_xxx_lang.mp3', {'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', 'artist': 'A Perfect Circle', 'audio_offset': 3647, 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'extra': {'isrc': 'USVI20400513', 'lyrics': "Don't fret, precious", 'text': 'SCRIPT\ufeffLatn'}, 'genre': 'Rock', 'samplerate': 44100, 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', 'track': '10', 'comment': ' ', 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': '1', 'disc_total': '1', 'track_total': '12', 'year': '2004'}), + ('samples/vbr_xing_header.mp3', + {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, + 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), + ('samples/vbr_xing_header_2channel.mp3', + {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", + 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46.276128290848305, + 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, + 'title': 'Lochaber No More', 'year': '1992'}), + ('samples/id3v22-test.mp3', + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': '11', 'duration': 0.138, + 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', + 'artist': 'Anais Mitchell', 'track': '3', 'filesize': 5120, 'audio_offset': 2225, + 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), + ('samples/silence-44-s-v1.mp3', + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'track_total': None, + 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', + 'title': 'Silence', 'artist': 'piman', 'track': '2', 'filesize': 15070, 'audio_offset': 0, + 'bitrate': 32.0, 'comment': ''}), + ('samples/id3v1-latin1.mp3', + {'extra': {}, 'channels': None, 'samplerate': None, 'genre': 'Rock', + 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', + 'artist': 'Björk', 'track_total': None, 'year': '1993', + 'comment': ' '}), + ('samples/UTF16.mp3', + {'extra': {'text': 'MusicBrainz Artist Id664c3e0e-42d8-48c1-b209-1efca19c0325', + 'url': 'WIKIPEDIA_RELEASEhttp://en.wikipedia.org/wiki/High_Violet'}, 'channels': None, + 'samplerate': None, 'track_total': '11', 'track': '07', 'artist': 'The National', + 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, + 'genre': 'Indie', 'comment': 'Track 7'}), + ('samples/utf-8-id3v2.mp3', + {'extra': {}, 'channels': None, 'samplerate': None, 'genre': 'Acustico', + 'track_total': '21', 'track': '01', 'filesize': 2119, 'title': 'Gran día', + 'artist': 'Paso a paso', 'album': 'S/T', 'year': None, 'disc': '', 'disc_total': '0'}), + ('samples/empty_file.mp3', + {'extra': {}, 'channels': None, 'samplerate': None, 'track_total': None, 'album': None, + 'year': None, 'title': None, 'track': None, 'artist': None, 'filesize': 0}), + ('samples/silence-44khz-56k-mono-1s.mp3', + {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.018, 'filesize': 7280, + 'audio_offset': 0, 'bitrate': 56.0}), + ('samples/silence-22khz-mono-1s.mp3', + {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, 'audio_offset': 0, + 'bitrate': 32.0, 'duration': 1.0438932496075353}), + ('samples/id3v24-long-title.mp3', + {'extra': {}, 'track': '1', 'disc_total': '1', + 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, + 'channels': None, 'track_total': '12', 'genre': 'AlternRock', + 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', + 'albumartist': 'Courtney Barnett', 'samplerate': None, 'year': None, 'disc': '1', + 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett'}), + ('samples/utf16be.mp3', + {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', + 'artist': 'The B52s', 'genre': 'Rock', 'albumartist': None, 'disc': None, + 'channels': None}), + ('samples/id3v22_image.mp3', + {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, + 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', + 'channels': None, 'genre': '.'}), + ('samples/id3v22.TCO.genre.mp3', + {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', + 'comment': 'engiTunPGAP0', 'genre': 'Pop', 'title': 'Applause'}), + ('samples/id3_comment_utf_16_with_bom.mp3', + {'extra': {'isrc': 'USTC40852229'}, 'filesize': 19980, 'album': 'Ghosts I-IV', + 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'disc': '1', + 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', + 'year': '2008', 'comment': '3/4 time'}), + ('samples/id3_comment_utf_16_double_bom.mp3', + {'extra': {'text': 'LABEL\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', + 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', + 'title': 'The Embrace (Romano Alfieri Remix)', + 'track': '04-johannes_heil_and_d.diggler-the_embrace_(romano_alfieri_remix)', + 'year': '2012'}), + ('samples/id3_genre_id_out_of_bounds.mp3', + {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', + 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'track': 'Marilyn', + 'year': '0'}), + ('samples/image-text-encoding.mp3', + {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, + 'title': 'image-encoding', 'audio_offset': 6820, 'bitrate': 32.0, + 'duration': 1.0438932496075353}), + ('samples/id3v1_does_not_overwrite_id3v2.mp3', + {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', + 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATINGL'}, + 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), + ('samples/nicotinetestdata.mp3', + {'extra': {}, 'filesize': 80919, 'audio_offset': 45, 'channels': 2, + 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), + ('samples/chinese_id3.mp3', + {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', + 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128.0, 'channels': 2, + 'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, + 'title': '½ÇÂäÖ®¸è', 'track': '1'}), + ('samples/cut_off_titles.mp3', + {'extra': {}, 'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', + 'audio_offset': 194, 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, + 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), + ('samples/id3_xxx_lang.mp3', + {'extra': {'isrc': 'USVI20400513', 'lyrics': "Don't fret, precious", + 'text': 'SCRIPT\ufeffLatn'}, + 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', + 'artist': 'A Perfect Circle', 'audio_offset': 3647, 'bitrate': 192.0, 'channels': 2, + 'duration': 0.13198711063372717, 'genre': 'Rock', + 'samplerate': 44100, 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', + 'track': '10', 'comment': ' ', + 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': '1', 'disc_total': '1', + 'track_total': '12', 'year': '2004'}), # OGG - ('samples/empty.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, 'samplerate': 44100}), - ('samples/multipagecomment.ogg', {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), - ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100}), - ('samples/test.ogg', {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'hello!'}), - ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80.0, 'duration': 2.132358276643991, 'samplerate': 44100}), - ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', 'audio_offset': 0, 'bitrate': 112.0, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer'}), + ('samples/empty.ogg', + {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, + '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, + '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, + 'samplerate': 44100}), + ('samples/multipagecomment.ogg', + {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, + '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, + '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, + 'samplerate': 44100}), + ('samples/multipage-setup.ogg', + {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, + 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', + '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, + 'samplerate': 44100}), + ('samples/test.ogg', + {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', + 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, + 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, + 'comment': 'hello!'}), + ('samples/corrupt_metadata.ogg', + {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80.0, + 'duration': 2.132358276643991, 'samplerate': 44100}), + ('samples/composer.ogg', + {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', + 'audio_offset': 0, 'bitrate': 112.0, 'duration': 3.684716553287982, + 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', + 'year': '2007', 'composer': 'some composer'}), # OPUS - ('samples/test.opus', {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', 'comment': 'ARCD0018 - Lovelight'}), - ('samples/8khz_5s.opus', {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), + ('samples/test.opus', + {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, + 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', + 'filesize': 10000, 'artist': 'nomico', + 'album': 'Exserens - A selection of Alstroemeria Records', + 'comment': 'ARCD0018 - Lovelight'}), + ('samples/8khz_5s.opus', + {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), # WAV - ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offset': 36}), - ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, 'duration': 3.0, 'samplerate': 44100, 'audio_offset': 36}), - ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': '66', 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), - ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'title': 'thetitle', 'track': None, 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), - ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offset': 4088}), - ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'audio_offset': 122, 'artist': 'Purpley', 'title': 'Test000', 'track': '17'}), - ('samples/adpcm.wav', {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, 'bitrate': 176.4, 'samplerate': 44100, 'audio_offset': 82, 'artist': 'test artist', 'title': 'test title', 'track': '1', 'album': 'test album', 'comment': 'test comment', 'genre': 'test genre', 'year': '1990'}), + ('samples/test.wav', + {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, + 'samplerate': 44100, 'audio_offset': 36}), + ('samples/test3sMono.wav', + {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, + 'samplerate': 44100, 'audio_offset': 36}), + ('samples/test-tagged.wav', + {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', + 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, + 'title': 'thetitle', 'track': '66', 'audio_offset': 36, 'comment': 'hello', + 'year': '2014'}), + ('samples/test-riff-tags.wav', + {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, + 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, + 'title': 'thetitle', 'track': None, 'audio_offset': 36, 'comment': 'hello', + 'year': '2014'}), + ('samples/silence-22khz-mono-1s.wav', + {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, + 'samplerate': 22050, 'audio_offset': 4088}), + ('samples/id3_header_with_a_zero_byte.wav', + {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, + 'samplerate': 22050, 'audio_offset': 122, 'artist': 'Purpley', 'title': 'Test000', + 'track': '17'}), + ('samples/adpcm.wav', + {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, + 'bitrate': 176.4, 'samplerate': 44100, 'audio_offset': 82, 'artist': 'test artist', + 'title': 'test title', 'track': '1', 'album': 'test album', 'comment': 'test comment', + 'genre': 'test genre', 'year': '1990'}), # FLAC - ('samples/flac1sMono.flac', {'extra': {}, 'genre': 'Avantgarde', 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100}), - ('samples/flac453sStereo.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, 'duration': 453.51473922902494, 'title': None, 'track': None, 'artist': None, 'filesize': 84236, 'bitrate': 1.4859230399999999, 'samplerate': 44100}), - ('samples/flac1.5sStereo.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': 'alb', 'year': '2014', 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', 'samplerate': 44100}), - ('samples/flac_application.flac', {'extra': {}, 'channels': 2, 'track_total': '11', 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100}), - ('samples/no-tags.flac', {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, 'duration': 3.684716553287982, 'title': None, 'track': None, 'artist': None, 'filesize': 4692, 'bitrate': 10.186943678613627, 'samplerate': 44100}), - ('samples/variable-block.flac', {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'composer': 'Boom Boom Satellites (Lyrics)', 'disc_total': '2'}), - ('samples/106-invalid-streaminfo.flac', {'extra': {}, 'filesize': 4692}), - ('samples/106-short-picture-block-size.flac', {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.68, 'samplerate': 44100}), - ('samples/with_id3_header.flac', {'extra': {}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'year': '2018'}), - ('samples/with_padded_id3_header.flac', {'extra': {}, 'filesize': 16070, 'album': 'album', 'albumartist': None, 'artist': 'artist', 'audio_offset': None, 'bitrate': 283.4748, 'channels': 1, 'comment': None, 'disc': None, 'disc_total': None, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'title', 'track': '1', 'track_total': None, 'year': '2018'}), - ('samples/with_padded_id3_header2.flac', {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'albumartist': None, 'artist': 'Unbekannter Künstler', 'audio_offset': None, 'bitrate': 344.36807999999996, 'channels': 1, 'comment': None, 'disc': '1', 'disc_total': '1', 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018'}), - ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': '1', 'disc_total': '1', 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'title': 'intro', 'track': '01', 'track_total': '8'}), + ('samples/flac1sMono.flac', + {'extra': {}, 'genre': 'Avantgarde', 'track_total': None, 'album': 'alb', 'year': '2014', + 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, + 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100}), + ('samples/flac453sStereo.flac', + {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, + 'duration': 453.51473922902494, 'title': None, 'track': None, 'artist': None, + 'filesize': 84236, 'bitrate': 1.4859230399999999, 'samplerate': 44100}), + ('samples/flac1.5sStereo.flac', + {'extra': {}, 'channels': 2, 'track_total': None, 'album': 'alb', 'year': '2014', + 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', + 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', + 'samplerate': 44100}), + ('samples/flac_application.flac', + {'extra': {}, 'channels': 2, 'track_total': '11', + 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, + 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', + 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100}), + ('samples/no-tags.flac', + {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, + 'duration': 3.684716553287982, 'title': None, 'track': None, 'artist': None, + 'filesize': 4692, 'bitrate': 10.186943678613627, 'samplerate': 44100}), + ('samples/variable-block.flac', + {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', + 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', + 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, + 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, + 'composer': 'Boom Boom Satellites (Lyrics)', 'disc_total': '2'}), + ('samples/106-invalid-streaminfo.flac', + {'extra': {}, 'filesize': 4692}), + ('samples/106-short-picture-block-size.flac', + {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, + 'duration': 3.68, 'samplerate': 44100}), + ('samples/with_id3_header.flac', + {'extra': {}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', + 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, + 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'year': '2018'}), + ('samples/with_padded_id3_header.flac', + {'extra': {}, 'filesize': 16070, 'album': 'album', 'albumartist': None, 'artist': 'artist', + 'audio_offset': None, 'bitrate': 283.4748, 'channels': 1, 'comment': None, 'disc': None, + 'disc_total': None, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, + 'title': 'title', 'track': '1', 'track_total': None, 'year': '2018'}), + ('samples/with_padded_id3_header2.flac', + {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'albumartist': None, + 'artist': 'Unbekannter Künstler', 'audio_offset': None, 'bitrate': 344.36807999999996, + 'channels': 1, 'comment': None, 'disc': '1', 'disc_total': '1', + 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'Track01', + 'track': '01', 'track_total': '05', 'year': '2018'}), + ('samples/flac_with_image.flac', + {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', + 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': '1', 'disc_total': '1', + 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'title': 'intro', 'track': '01', + 'track_total': '8'}), # WMA - ('samples/test2.wma', {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'track_total': None, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), + ('samples/test2.wma', + {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', + 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', + 'artist': 'Foo Fighters', 'duration': 83.406, 'track_total': None, 'year': '1997', + 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), # ALAC/M4A/MP4 - ('samples/test.m4a', {'extra': {}, 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), - ('samples/test2.m4a', {'extra': {}, 'bitrate': 256.0, 'track': '1', 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': '1', 'title': 'Go Out and Get Some', 'comment': "Millie Jackson - Get It Out 'cha System - 1978", 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), - ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M�)', 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), - ('samples/alac_file.m4a', {'extra': {}, 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', 'filesize': 20000, 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': '14', 'track_total': '27', 'disc': '1', 'disc_total': '1', 'samplerate': 44100, 'duration': 166.62639455782312, 'genre': 'Classical', 'albumartist': 'Howard Shelley', 'channels': 2, 'bitrate': 436.743}), + ('samples/test.m4a', + {'extra': {}, 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, + 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', + 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), + ('samples/test2.m4a', + {'extra': {}, 'bitrate': 256.0, 'track': '1', + 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", + 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', + 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', + 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': '1', + 'title': 'Go Out and Get Some', + 'comment': "Millie Jackson - Get It Out 'cha System - 1978", + 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), + ('samples/iso8859_with_image.m4a', + {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, + 'title': 'Cold Water (feat. Justin Bieber & M�)', + 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', + 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', + 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, + 'comment': '? 2016 Mad Decent'}), + ('samples/alac_file.m4a', + {'extra': {}, 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', + 'filesize': 20000, + 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', + 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': '14', + 'track_total': '27', 'disc': '1', 'disc_total': '1', 'samplerate': 44100, + 'duration': 166.62639455782312, 'genre': 'Classical', 'albumartist': 'Howard Shelley', + 'channels': 2, 'bitrate': 436.743}), # AIFF - ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014', }), - ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'artist': None, 'bitrate': 1411.2, 'genre': None, 'samplerate': 44100, 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), - ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', }), - ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, 'title': None, 'album': None, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio', }), + ('samples/test-tagged.aiff', + {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', + 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', + 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014'}), + ('samples/test.aiff', + {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, + 'filesize': 164, 'artist': None, 'bitrate': 1411.2, 'genre': None, 'samplerate': 44100, + 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, + 'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}), + ('samples/pluck-pcm8.aiff', + {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, + 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', + 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, + 'comment': 'Audacity Pluck + Wahwah'}), + ('samples/M1F1-mulawC-AFsp.afc', + {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, + 'title': None, 'album': None, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, + 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio'}), ]) @@ -142,6 +363,7 @@ # if there are no expected values, just try parsing the file testfiles[os.path.join('custom_samples', filename)] = {} + @pytest.mark.parametrize("testfile,expected", [ pytest.param(testfile, expected) for testfile, expected in testfiles.items() ]) @@ -184,7 +406,8 @@ def test_pathlib_compatibility(): return testfile = next(iter(testfiles.keys())) filename = pathlib.Path(testfolder) / testfile - tag = TinyTag.get(filename) + TinyTag.get(filename) + @pytest.mark.skipif(sys.platform == "win32", reason='Windows does not support binary paths') def test_binary_path_compatibility(): @@ -202,120 +425,161 @@ def test_unsupported_extension(): bogus_file = os.path.join(testfolder, 'samples/there_is_no_such_ext.bogus') TinyTag.get(bogus_file) + def test_override_encoding(): chinese_id3 = os.path.join(testfolder, 'samples/chinese_id3.mp3') tag = TinyTag.get(chinese_id3, encoding='gbk') assert tag.artist == '苏云' assert tag.album == '角落之歌' + @pytest.mark.xfail(raises=NotImplementedError) def test_unsubclassed_tinytag_duration(): tag = TinyTag(None, 0) tag._determine_duration(None) + @pytest.mark.xfail(raises=NotImplementedError) def test_unsubclassed_tinytag_parse_tag(): tag = TinyTag(None, 0) tag._parse_tag(None) + def test_mp3_length_estimation(): ID3.set_estimation_precision(0.7) tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) assert 3.5 < tag.duration < 4.0 + @pytest.mark.xfail(raises=TinyTagException) def test_unexpected_eof(): - tag = ID3.get(os.path.join(testfolder, 'samples/incomplete.mp3')) + ID3.get(os.path.join(testfolder, 'samples/incomplete.mp3')) + @pytest.mark.xfail(raises=TinyTagException) def test_invalid_flac_file(): - tag = Flac.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) + Flac.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) + @pytest.mark.xfail(raises=TinyTagException) def test_invalid_mp3_file(): - tag = ID3.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + ID3.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + @pytest.mark.xfail(raises=TinyTagException) def test_invalid_ogg_file(): - tag = Ogg.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + Ogg.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + @pytest.mark.xfail(raises=TinyTagException) def test_invalid_wave_file(): - tag = Wave.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + Wave.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + @pytest.mark.xfail(raises=TinyTagException) def test_invalid_aiff_file(): - tag = Aiff.get(os.path.join(testfolder, 'samples/ilbm.aiff')) + Aiff.get(os.path.join(testfolder, 'samples/ilbm.aiff')) + def test_unpad(): # make sure that unpad only removes trailing 0-bytes assert TinyTag._unpad('foo\x00') == 'foo' assert TinyTag._unpad('foo\x00bar\x00') == 'foobar' + def test_mp3_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/cover_img.mp3'), image=True) image_data = tag.get_image() assert image_data is not None - assert 140000 < len(image_data) < 150000, 'Image is %d bytes but should be around 145kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 140000 < len(image_data) < 150000, ('Image is %d bytes but should be around 145kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_mp3_id3v22_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22_image.mp3'), image=True) image_data = tag.get_image() assert image_data is not None - assert 18000 < len(image_data) < 19000, 'Image is %d bytes but should be around 18.1kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 18000 < len(image_data) < 19000, ('Image is %d bytes but should be around 18.1kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_mp3_image_loading_without_description(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/id3image_without_description.mp3'), image=True) + tag = TinyTag.get(os.path.join(testfolder, 'samples/id3image_without_description.mp3'), + image=True) image_data = tag.get_image() assert image_data is not None - assert 28600 < len(image_data) < 28700, 'Image is %d bytes but should be around 28.6kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 28600 < len(image_data) < 28700, ('Image is %d bytes but should be around 28.6kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_mp3_image_loading_with_utf8_description(): tag = TinyTag.get(os.path.join(testfolder, 'samples/image-text-encoding.mp3'), image=True) image_data = tag.get_image() assert image_data is not None - assert 5700 < len(image_data) < 6000, 'Image is %d bytes but should be around 6kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 5700 < len(image_data) < 6000, ('Image is %d bytes but should be around 6kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_mp3_image_loading2(): tag = TinyTag.get(os.path.join(testfolder, 'samples/12oz.mp3'), image=True) image_data = tag.get_image() assert image_data is not None - assert 2000 < len(image_data) < 2500, 'Image is %d bytes but should be around 145kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 2000 < len(image_data) < 2500, ('Image is %d bytes but should be around 145kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_mp3_utf_8_invalid_string_raises_exception(): with raises(TinyTagException): - tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) + TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) + def test_mp3_utf_8_invalid_string_can_be_ignored(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3'), ignore_errors=True) - # the title used to be Gran dia, but I replaced the first byte with 0xFF, which should be ignored here + tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3'), + ignore_errors=True) + # the title used to be Gran dia, but I replaced the first byte with 0xFF, + # which should be ignored here assert tag.title == 'ran día' + def test_mp4_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/iso8859_with_image.m4a'), image=True) image_data = tag.get_image() assert image_data is not None - assert 20000 < len(image_data) < 25000, 'Image is %d bytes but should be around 22kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 20000 < len(image_data) < 25000, ('Image is %d bytes but should be around 22kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_flac_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) image_data = tag.get_image() assert image_data is not None - assert 70000 < len(image_data) < 80000, 'Image is %d bytes but should be around 75kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 70000 < len(image_data) < 80000, ('Image is %d bytes but should be around 75kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + def test_aiff_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/test_with_image.aiff'), image=True) image_data = tag.get_image() assert image_data is not None - assert 15000 < len(image_data) < 25000, 'Image is %d bytes but should be around 20kb' % len(image_data) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), 'The image data must start with a jpeg header' + assert 15000 < len(image_data) < 25000, ('Image is %d bytes but should be around 20kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + @pytest.mark.parametrize("testfile,expected", [ pytest.param(testfile, expected) for testfile, expected in [ @@ -335,6 +599,7 @@ def test_detect_magic_headers(testfile, expected): parser = TinyTag.get_parser_class(filename, fh) assert parser == expected + def test_show_hint_for_wrong_usage(): with pytest.raises(Exception) as exc_info: TinyTag('filename.mp3', 0) @@ -346,4 +611,10 @@ def test_to_str(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert str(tag) # since the dict is not ordered we cannot == 'somestring' assert repr(tag) # since the dict is not ordered we cannot == 'somestring' - assert str(tag) == '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", "audio_offset": 2225, "bitrate": 160.0, "channels": 2, "comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", "year": "2004"}' + assert str(tag) == ( + '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", ' + '"audio_offset": 2225, "bitrate": 160.0, "channels": 2, "comment": "Waterbug Records, ' + 'www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, ' + '"duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, ' + '"samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", ' + '"year": "2004"}') diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 82cffd2..af4b3c8 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -36,7 +36,8 @@ def test_print_help(): assert 'tinytag [options] 0 -@pytest.mark.skipif(sys.platform == "win32", reason="NamedTemporaryFile cant be reopened on windows") +@pytest.mark.skipif(sys.platform == "win32", + reason="NamedTemporaryFile cant be reopened on windows") def test_save_image_bulk(): temp_file = NamedTemporaryFile(suffix='.jpg') temp_file_no_ext = temp_file.name[:-4] diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 674891a..0ab7a4d 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -30,25 +30,24 @@ # SOFTWARE. -from __future__ import print_function - -import aifc -import json -import operator +from __future__ import division, print_function from chunk import Chunk from collections import OrderedDict, defaultdict try: from collections.abc import MutableMapping except ImportError: from collections import MutableMapping -import codecs from functools import reduce -import struct -import os -import io -import sys from io import BytesIO +import aifc +import codecs +import io +import json +import operator +import os import re +import struct +import sys DEBUG = os.environ.get('DEBUG', False) # some of the parsers can print debug info @@ -174,7 +173,8 @@ def get_parser_class(cls, filename, filehandle): raise TinyTagException('No tag reader found to support filetype! ') @classmethod - def get(cls, filename, tags=True, duration=True, image=False, ignore_errors=False, encoding=None): + def get(cls, filename, tags=True, duration=True, image=False, ignore_errors=False, + encoding=None): try: # cast pathlib.Path to str import pathlib if isinstance(filename, pathlib.Path): @@ -270,7 +270,8 @@ def _unpad(s): class MP4(TinyTag): - # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html + # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/ + # Metadata.html # and: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html class Parser: @@ -359,7 +360,7 @@ def parse_audio_sample_entry_mp4a(cls, data): # Decoder Config Descriptor cls.read_extended_descriptor(esds_atom) esds_atom.seek(9, os.SEEK_CUR) - avg_br = struct.unpack('>I', esds_atom.read(4))[0] / 1000.0 # kbit/s + avg_br = struct.unpack('>I', esds_atom.read(4))[0] / 1000 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} @classmethod @@ -370,7 +371,7 @@ def parse_audio_sample_entry_alac(cls, data): alac_atom.seek(13, os.SEEK_CUR) channels = struct.unpack('b', alac_atom.read(1))[0] alac_atom.seek(6, os.SEEK_CUR) - avg_br = struct.unpack('>I', alac_atom.read(4))[0] / 1000.0 # kbit/s + avg_br = struct.unpack('>I', alac_atom.read(4))[0] / 1000 # kbit/s sr = struct.unpack('>I', alac_atom.read(4))[0] return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} @@ -388,7 +389,7 @@ def parse_mvhd(cls, data): walker.seek(16, os.SEEK_CUR) # jump over create & mod times time_scale = struct.unpack('>I', walker.read(4))[0] duration = struct.unpack('>q', walker.read(8))[0] - return {'duration': float(duration) / time_scale} + return {'duration': duration / time_scale} @classmethod def debug_atom(cls, data): @@ -402,16 +403,16 @@ def debug_atom(cls, data): # see: http://atomicparsley.sourceforge.net/mpeg-4files.html b'\xa9alb': {b'data': Parser.make_data_atom_parser('album')}, b'\xa9ART': {b'data': Parser.make_data_atom_parser('artist')}, - b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, + b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, # b'cpil': {b'data': Parser.make_data_atom_parser('compilation')}, b'\xa9cmt': {b'data': Parser.make_data_atom_parser('comment')}, - b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, + b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, b'\xa9wrt': {b'data': Parser.make_data_atom_parser('composer')}, b'\xa9day': {b'data': Parser.make_data_atom_parser('year')}, b'\xa9gen': {b'data': Parser.make_data_atom_parser('genre')}, - b'gnre': {b'data': Parser.parse_id3v1_genre}, + b'gnre': {b'data': Parser.parse_id3v1_genre}, b'\xa9nam': {b'data': Parser.make_data_atom_parser('title')}, - b'trkn': {b'data': Parser.make_number_parser('track', 'track_total')}, + b'trkn': {b'data': Parser.make_number_parser('track', 'track_total')}, }}}}} # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html @@ -453,7 +454,9 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): atom_header = fh.read(header_size) continue if DEBUG: - stderr('%s pos: %d atom: %s len: %d' % (' ' * 4 * len(curr_path), fh.tell() - header_size, atom_type, atom_size + header_size)) + stderr('%s pos: %d atom: %s len: %d' % + (' ' * 4 * len(curr_path), fh.tell() - header_size, atom_type, + atom_size + header_size)) if atom_type in self.VERSIONED_ATOMS: # jump atom version for now fh.seek(4, os.SEEK_CUR) if atom_type in self.FLAGGED_ATOMS: # jump atom flags for now @@ -483,12 +486,12 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): class ID3(TinyTag): FRAME_ID_TO_FIELD = { # Mapping from Frame ID to a field of the TinyTag 'COMM': 'comment', 'COM': 'comment', - 'TRCK': 'track', 'TRK': 'track', - 'TYER': 'year', 'TYE': 'year', - 'TALB': 'album', 'TAL': 'album', + 'TRCK': 'track', 'TRK': 'track', + 'TYER': 'year', 'TYE': 'year', + 'TALB': 'album', 'TAL': 'album', 'TPE1': 'artist', 'TP1': 'artist', - 'TIT2': 'title', 'TT2': 'title', - 'TCON': 'genre', 'TCO': 'genre', + 'TIT2': 'title', 'TT2': 'title', + 'TCON': 'genre', 'TCO': 'genre', 'TPOS': 'disc', 'TPE2': 'albumartist', 'TCOM': 'composer', 'WXXX': 'extra.url', @@ -560,7 +563,7 @@ def set_estimation_precision(cls, estimation_in_seconds): # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm samplerates = [ - [11025, 12000, 8000], # MPEG 2.5 + [11025, 12000, 8000], # MPEG 2.5 [], # reserved [22050, 24000, 16000], # MPEG 2 [44100, 48000, 32000], # MPEG 1 @@ -625,7 +628,8 @@ def _determine_duration(self, fh): layer_id = (conf >> 1) & 0x03 channel_mode = (rest >> 6) & 0x03 # check for eleven 1s, validate bitrate and sample rate - if not b[:2] > b'\xFF\xE0' or br_id > 14 or br_id == 0 or sr_id == 3 or layer_id == 0 or mpeg_id == 1: + if (not b[:2] > b'\xFF\xE0' or br_id > 14 or br_id == 0 or sr_id == 3 + or layer_id == 0 or mpeg_id == 1): # noqa idx = b.find(b'\xFF', 1) # invalid frame, find next sync header if idx == -1: idx = len(b) # not found: jump over the current peek buffer @@ -646,7 +650,8 @@ def _determine_duration(self, fh): fh.seek(xing_header_offset, os.SEEK_CUR) xframes, byte_count, toc, vbr_scale = ID3._parse_xing_header(fh) if xframes and xframes != 0 and byte_count: - self.duration = xframes * ID3.samples_per_frame / float(self.samplerate) / self.channels + self.duration = (xframes * ID3.samples_per_frame / self.samplerate + / self.channels) # noqa self.bitrate = byte_count * 8 / self.duration / 1000 self.audio_offset = fh.tell() return @@ -663,22 +668,21 @@ def _determine_duration(self, fh): frame_length = (144000 * frame_bitrate) // self.samplerate + padding frame_size_accu += frame_length # if bitrate does not change over time its probably CBR - is_cbr = (frames == ID3._CBR_DETECTION_FRAME_COUNT and - len(set(last_bitrates)) == 1) + is_cbr = (frames == ID3._CBR_DETECTION_FRAME_COUNT and len(set(last_bitrates)) == 1) if frames == max_estimation_frames or is_cbr: # try to estimate duration fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) audio_stream_size = fh.tell() - self.audio_offset - est_frame_count = audio_stream_size / (frame_size_accu / float(frames)) + est_frame_count = audio_stream_size / (frame_size_accu / frames) samples = est_frame_count * ID3.samples_per_frame - self.duration = samples / float(self.samplerate) + self.duration = samples / self.samplerate self.bitrate = bitrate_accu / frames return if frame_length > 1: # jump over current frame body fh.seek(frame_length - header_bytes, os.SEEK_CUR) if self.samplerate: - self.duration = frames * ID3.samples_per_frame / float(self.samplerate) + self.duration = frames * ID3.samples_per_frame / self.samplerate def _parse_tag(self, fh): self._parse_id3v2(fh) @@ -737,7 +741,7 @@ def asciidecode(x): @staticmethod def index_utf16(s, search): for i in range(0, len(s), len(search)): - if s[i:i+len(search)] == search: + if s[i:i + len(search)] == search: return i return -1 @@ -752,9 +756,10 @@ def _parse_frame(self, fh, id3version=False): return 0 frame = struct.unpack(binformat, frame_header_data) frame_id = self._decode_string(frame[0]) - frame_size = self._calc_size(frame[1:1+frame_size_bytes], bits_per_byte) + frame_size = self._calc_size(frame[1:1 + frame_size_bytes], bits_per_byte) if DEBUG: - stderr('Found id3 Frame %s at %d-%d of %d' % (frame_id, fh.tell(), fh.tell() + frame_size, self.filesize)) + stderr('Found id3 Frame %s at %d-%d of %d' % + (frame_id, fh.tell(), fh.tell() + frame_size, self.filesize)) if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. if frame_id not in ID3.PARSABLE_FRAME_IDS: # jump over unparsable frames @@ -766,18 +771,16 @@ def _parse_frame(self, fh, id3version=False): self._set_field(fieldname, content, self._decode_string) elif frame_id in self.IMAGE_FRAME_IDS and self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames + encoding = content[0:1] if frame_id == 'PIC': # ID3 v2.2: - encoding, content = content[0], content[1:] - imgformat, content = content[:3], content[3:] + desc_start_pos = 1 + 3 + 1 # skip encoding (1), imgformat (3), pictype(1) else: # ID3 v2.3+ - encoding, content = content[0], content[1:] - mimetype_end_pos = content.index(b'\x00', 1) + 1 - mimetype, content = content[:mimetype_end_pos], content[mimetype_end_pos:] - pictype, content = content[0], content[1:] - termination = b'\x00' if encoding in (0, 3) else b'\x00\x00' # latin1 and utf-8 are 1 byte - desc_end_pos = ID3.index_utf16(content, termination) + len(termination) - description, content = content[:desc_end_pos], content[desc_end_pos:] - self._image_data = content + desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mimetype, pictype(1) + # latin1 and utf-8 are 1 byte + termination = b'\x00' if encoding in (b'\x00', b'\x03') else b'\x00\x00' + desc_length = ID3.index_utf16(content[desc_start_pos:], termination) + desc_end_pos = desc_start_pos + desc_length + len(termination) + self._image_data = content[desc_end_pos:] return frame_size return 0 @@ -849,7 +852,7 @@ def _determine_duration(self, fh): if b[:4] == b'OggS': # look for an ogg header for _ in self._parse_pages(fh): pass # parse all remaining pages - self.duration = self._max_samplenum / float(self.samplerate) + self.duration = self._max_samplenum / self.samplerate else: idx = b.find(b'OggS') # try to find header in peeked data seekpos = idx if idx != -1 else len(b) - 3 @@ -863,7 +866,7 @@ def _parse_tag(self, fh): (channels, self.samplerate, max_bitrate, bitrate, min_bitrate) = struct.unpack(" 0: fh.seek(remaining_size, 1) # skip remaining data in chunk elif subchunkid == b'data': - self.duration = float(subchunksize) / self.channels / self.samplerate / (bitdepth / 8) + self.duration = subchunksize / self.channels / self.samplerate / (bitdepth / 8) self.audio_offset = fh.tell() - 8 # rewind to data header fh.seek(subchunksize, 1) elif subchunkid == b'LIST' and self._parse_tags: @@ -1085,7 +1088,7 @@ def _determine_duration(self, fh): # bit_depth = (bit_depth + 1) total_sample_bytes = [(header[7] & 0x0F)] + list(header[8:12]) total_samples = _bytes_to_int(total_sample_bytes) - self.duration = float(total_samples) / self.samplerate + self.duration = total_samples / self.samplerate if self.duration > 0: self.bitrate = self.filesize / self.duration * 8 / 1000 elif block_type == Flac.METADATA_VORBIS_COMMENT and self._parse_tags: @@ -1095,9 +1098,9 @@ def _determine_duration(self, fh): elif block_type == Flac.METADATA_PICTURE and self._load_image: # https://xiph.org/flac/format.html#metadata_block_picture pic_type, mime_len = struct.unpack('>2I', fh.read(8)) - mime = fh.read(mime_len) + fh.read(mime_len) description_len = struct.unpack('>I', fh.read(4))[0] - description = fh.read(description_len) + fh.read(description_len) width, height, depth, colors, pic_len = struct.unpack('>5I', fh.read(20)) self._image_data = fh.read(pic_len) elif block_type >= 127: @@ -1114,7 +1117,8 @@ def _determine_duration(self, fh): class Wma(TinyTag): ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' - ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00\xa0\xc9^\xa8P' + ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' + b'\xa0\xc9^\xa8P') STREAM_BITRATE_PROPERTIES_OBJECT = b'\xceu\xf8{\x8dF\xd1\x11\x8d\x82\x00`\x97\xc9\xa2\xb2' ASF_FILE_PROPERTY_OBJECT = b'\xa1\xdc\xab\x8cG\xa9\xcf\x11\x8e\xe4\x00\xc0\x0c Se' ASF_STREAM_PROPERTIES_OBJECT = b'\x91\x07\xdc\xb7\xb7\xa9\xcf\x11\x8e\xe6\x00\xc0\x0c Se' @@ -1167,7 +1171,8 @@ def _parse_tag(self, fh): self.__tag_parsed = True guid = fh.read(16) # 128 bit GUID if guid != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel': - return # not a valid ASF container! see: http://www.garykessler.net/library/file_sigs.html + # not a valid ASF container! see: http://www.garykessler.net/library/file_sigs.html + return struct.unpack('Q', fh.read(8))[0] # size struct.unpack('I', fh.read(4))[0] # obj_count if fh.read(2) != b'\x01\x02': @@ -1206,7 +1211,8 @@ def _parse_tag(self, fh): 'WM/AlbumTitle': 'album', 'WM/Composer': 'composer', } - # see: http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 + # see: http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/ + # library/bb643323.aspx#_Toc509555195 descriptor_count = _bytes_to_int_le(fh.read(2)) for _ in range(descriptor_count): name_len = _bytes_to_int_le(fh.read(2)) @@ -1234,7 +1240,8 @@ def _parse_tag(self, fh): ]) # According to the specification, we need to subtract the preroll from play_duration # to get the actual duration of the file - self.duration = max(blocks.get('play_duration') / float(10000000) - blocks.get('preroll') / float(1000), 0.0) + preroll = blocks.get('preroll') / 1000 + self.duration = max(blocks.get('play_duration') / 10000000 - preroll, 0.0) elif object_id == Wma.ASF_STREAM_PROPERTIES_OBJECT: blocks = self.read_blocks(fh, [ ('stream_type', 16, False), @@ -1256,7 +1263,7 @@ def _parse_tag(self, fh): ('bits_per_sample', 2, True), ]) self.samplerate = stream_info['samples_per_second'] - self.bitrate = stream_info['avg_bytes_per_second'] * 8 / float(1000) + self.bitrate = stream_info['avg_bytes_per_second'] * 8 / 1000 already_read = 16 fh.seek(blocks['type_specific_data_length'] - already_read, os.SEEK_CUR) fh.seek(blocks['error_correction_data_length'], os.SEEK_CUR) @@ -1309,8 +1316,8 @@ def _determine_duration(self, fh): aiffobj = aifc.open(fh, 'rb') self.channels = aiffobj.getnchannels() self.samplerate = aiffobj.getframerate() - self.duration = float(aiffobj.getnframes()) / float(self.samplerate) - self.bitrate = self.samplerate * self.channels * aiffobj.getsampwidth() * 8 / 1000.0 + self.duration = aiffobj.getnframes() / self.samplerate + self.bitrate = self.samplerate * self.channels * aiffobj.getsampwidth() * 8 / 1000 def _parse_tag(self, fh): fh.seek(0, 0) From c466db1ec95c65aba158ef2437b27cfd87b41275 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Sat, 12 Mar 2022 18:10:54 +0100 Subject: [PATCH 038/305] Fix incorrect calculation of duration for VBR encoded MP3s #128 --- tinytag/tests/samples/mp3/vbr/vbr11.mp3 | Bin 0 -> 9360 bytes tinytag/tests/samples/mp3/vbr/vbr11stereo.mp3 | Bin 0 -> 9360 bytes tinytag/tests/samples/mp3/vbr/vbr16.mp3 | Bin 0 -> 9432 bytes tinytag/tests/samples/mp3/vbr/vbr16stereo.mp3 | Bin 0 -> 9432 bytes tinytag/tests/samples/mp3/vbr/vbr22.mp3 | Bin 0 -> 9282 bytes tinytag/tests/samples/mp3/vbr/vbr22stereo.mp3 | Bin 0 -> 9282 bytes tinytag/tests/samples/mp3/vbr/vbr32.mp3 | Bin 0 -> 37008 bytes tinytag/tests/samples/mp3/vbr/vbr32stereo.mp3 | Bin 0 -> 37008 bytes tinytag/tests/samples/mp3/vbr/vbr44.mp3 | Bin 0 -> 36609 bytes tinytag/tests/samples/mp3/vbr/vbr44stereo.mp3 | Bin 0 -> 36609 bytes tinytag/tests/samples/mp3/vbr/vbr48.mp3 | Bin 0 -> 36672 bytes tinytag/tests/samples/mp3/vbr/vbr48stereo.mp3 | Bin 0 -> 36672 bytes tinytag/tests/samples/mp3/vbr/vbr8.mp3 | Bin 0 -> 9504 bytes tinytag/tests/samples/mp3/vbr/vbr8stereo.mp3 | Bin 0 -> 9504 bytes tinytag/tests/test_all.py | 43 +++++++++++++++++- tinytag/tinytag.py | 7 ++- 16 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 tinytag/tests/samples/mp3/vbr/vbr11.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr11stereo.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr16.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr16stereo.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr22.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr22stereo.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr32.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr32stereo.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr44.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr44stereo.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr48.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr48stereo.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr8.mp3 create mode 100644 tinytag/tests/samples/mp3/vbr/vbr8stereo.mp3 diff --git a/tinytag/tests/samples/mp3/vbr/vbr11.mp3 b/tinytag/tests/samples/mp3/vbr/vbr11.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d8939480f37adf252d2b34028093df9c6139b5fc GIT binary patch literal 9360 zcmezW*x?8R3`At+r32agK)iv0L1h916DtQduYi!KxRk7dqKdkfww|GhsfD$jqm!$L zw{JjDXn0g?LSjmKR(4)tNohrOU1L*gM|bapNmHlKn!8}(lI5#buiLm~>yF*~4jwvs z;`G@Im#$vFb@#!;C(mEKe)sXq*B`(C0qsiharAXH)-yCPU@?a|MU+E@B~Ouo@e9Zu zf1Yoj1(P5^|9>n1@(;5INFChVP#C}z0m^{%sKfNYR3d1cdi==HQ%Z)OIb`TLOopBt zqk4{vhJnCn7y!$I(R45x2B3OjG#!kF!Du>ww2?;h!f0L?%?qP>0n{}bO$Vc4Fq#e^ zg9M{_VKgs{=7rI`011K7yfB&Ubk_})*ZX|9XxdO z#ObpaE?vET>+XYxPoBSe{qEzJuRnhO1KO41Ioz+w(_k|~D@OP($R;}?)S z+LHQL!6b0d{~rs0{KL2eBG2prqT%L-q5`NmNRK?a9s>hZwJ;u%9y>Dh#FC+>h73Kk z$w^oK|D!f0L?%?qP>ArxusX|%sE+Fuy07e?!a(RyLDUKp(xfRi($b>nClfad5% r)4^yMjHUxn2#ltK(J%lfgV8dP%)9^zy-=+EET|kVYE(}sS>Xl%`A3a( literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr16.mp3 b/tinytag/tests/samples/mp3/vbr/vbr16.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..93b3fe0ba6af77d28e73c8d9272352b5871b8ece GIT binary patch literal 9432 zcmezWx#I`}3`At+r32agK>Uw^LFEPmGaCmtuYi!KxRi{%l8U;fj-H{hnT55jgR`rL zm#=?NNO)vSTw+REW_DgdacMw?j` zV6-k6tqVr$g3-EQv@RH}3r6dL(Yj!?E+D2Z2n_|T6@izT$jJtYJ*oz}-Uca5keS4U F4*&&(=Ue~) literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr16stereo.mp3 b/tinytag/tests/samples/mp3/vbr/vbr16stereo.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..f473e2567952d1ea31c6623e738fb217de645339 GIT binary patch literal 9432 zcmezWxg&)E9Ykd2r31zIf%qQ-gUSsCW;PCPUI8IdaVZ&jB^7l|9X&&1GYe~52WM9g zFJJ$lknqTuxWtsS%Ioz!Cs+k|~D@i-Rr$;}?)S z>=zrofl1Jb{{LA56h4egAoekPfM~coL%{%~1{hWZ)yNT{MwbXRHbkiLCPGaV5o$7t zP*XvKnsy@8Od~?gQXw?j`V6-k61a$#qI15fm8 zGz>uX+-N!&4TI5iU}!KJ2BTpB>R61XgV8V;O$U%p_Gn%h%?qP>VKgrwkA98z7e@OF zqxHgQy)arYjMfXI^#bb5&}iK_S~rf?jiYtrXx%tkH;&egkUsazv6s8uKKFUvcf04_L%k06bE$W_n#Io)j7b_8 z)0VIpnLI9Dk)TRUN>-<6(lRpj22)O6eqm9GxvaucT`SaCZT3b-le5Lu+ScCL)zjP8 zKQK5nJTf{yF*QBwo?lp6UR~SR+}_#q>>nI@k9^1elfY^4?BeqJ<}UR3{Q4$-pQEbS zVm9dwSy@t7C>ki~P;^j?P;#LZKq-b&3Z)WC4HN;26^b2-*a+avIiZM+2exU0(gCF#N*|N~ zC__+2po~G8gfatV4$2~w6)5XawxH}n@jy9%;)UXa;)fD|5`=ONB{1yCPQ4fx$4^#Rp@`XKzzh{ghq1sV%979vkdC=VzPC=VzPC=b4#9#bY#CQ>F+ xCQ>F+CVrn)r0c>zxh|NZrazys{+wO@)dvD!qHaUN7}bDkz~9cGKKRo$_y9he3Z4J} literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr32.mp3 b/tinytag/tests/samples/mp3/vbr/vbr32.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..893800c585533d9753fb2e40b46d569922598208 GIT binary patch literal 37008 zcmeIyVPw<+9Eb7WxigWJm?one6OpyqBr;Y^%#tW3CT7gYNF*kX8Hs5UH6~?~UPNN1 zM9#WM$(V>lB9TZ`q?7BvRO8$|dULrK&oSHXd)xhd_E@QSr4Z$hfzh#%$B&5V z=}0%MTfbq`=Ef~so42*JZr`!9t)p}Ip1s{Y`+E=c^&dKXWZ>A~@uA_7(Gz1QPn|w9 zel|BTdG7qwg^QOi&&*!EK6m5ht=o4N7Vq7Ekbn5-@snrEE6-oNeD(Ux+js9jeEjtJ z>$mU8`^0enzJtA;Z5{3HnQO_nzaEX*)`p&zNPStoWAN#&l|O@@>q4MZY$`-LAF27| zoNPo_E+X^y?|8Jfj|%UzdQbO6**&$rV(qtAxo5oGXYD6y`&E^D?$(*lQl0sD&+ja& z^rMeH|MZW`ewaxgeOQ23`_YF5c(osWSb$gi(T4?iwI6+0fLHs`hXr`GAAMMWSNqY2 z1$ea|eOQ23`_YF5c(osWSb$gi(T4?iwI6+0fLHs`hXr`GAAMMWSNqY21$ea|eOQ23 z`_YF5c(osWSb$gi(T4?iwI6+0fLHs`hXr`GAAMMWSNqY21$ea|eOQ23`_YF5c(osW zSb$gi(T4?iwI6+0fLHs`hXr`GAAMMWSNqY21$ea|eOQ23`_YF5c(osWSb$gi(T4?i zwI6+0fLHs`hXr`GAAMMWSNqY21$ea|eOQ23`_YF5c(osWSb$gi(T4?iwI6+0fLHs` zhXr`GAAMMWSNqY21$ea|eOQ23`_YF5c(osWSb$gi(T4?iwI6+0fLHs`hXr`GAAMMW zSNqY21$ea|eOQ23`_YF5c(osWSb*1h>!-rCrD9WIDV>khd_-g;x^fYj3a|TLPk9eZ F{{(MOB literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr32stereo.mp3 b/tinytag/tests/samples/mp3/vbr/vbr32stereo.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5e59008f261c0c03958101db3bf78981416a2188 GIT binary patch literal 37008 zcmeIxVMLs90LSrvN{-|vQfBNTQ*61^5*alTDKi!sGv>&Vn3ypmW@O~fEH!3o%j`wW zn2|9fV;d8RDPzWr857%>W$L-8SM@!awtMkCrLOOF&)?_y-TK;OD&%?er-ufjm&Bv* zKOtnZp|~_wR(_zOvg+WW!!@->>W?-wHnp_2ooGMV+11^1>hziZv**rVNDZV1hb|6Z zx_ss8$hGUEH^wqIZ%y33b9egwgNL(^9zS_H_w4zL`IoQWyj@sadbjfa!^cmbzkL0+ z`u*px-_dnKs;8s3v#FsmnJk%#o^C#lCu&REkA^%1h>^!eC zyq|x4zdXNz*tcYAi;^M3qnZ1nPAnf}_}b@cG~&uUAlw3pIT zDRbs6Sh%Qm@sefBSFBvMdQHRH#^!bFH*DOrc}v^YZ98`E+OzLK`@usUon1$c9y@;G zg)oa&#Z`|zbzjgc0-Fx>RJbX0p_{q~}&xc;TeD(Ux+js9jeEjtJ z%h&HeMt_c<*Rj2IN87r_=BB2ak;#v=w0`ycldYxlZQ^r`^j_&C*O$`LT}sVwsJ*j!;aw)qX*f6SoY}nl)ZflHXZQ5`OtrK5ZpJyMt8}0KD&1$O zO7|JA(tSK9dfaC`*ShI8@&DTAz~{i{AbJKSBL9!ZR0wqgM?7?ksSxT0j(F%6Qz6t1 z9P!XCrb4J2IO3sOOodQ4aKuBmmKe#Z(A&14le` zi>VOm299{>7E>YA4IJ^%Ev7=K8#v;jTTF#eH*my5x0ni{Zs3TAZZQ=?-M|qK-C`<) zx`87ey2VrobpuB{bc?AF>IRN@=oV8U)D0Z*&@HAys2e!qp<7IaP&aVIL${a;p>E)a zhi)+yLfya-58Yxagt~zv9=gR;2z3KTJamhx5b6ewc<2^WA=C{V@z5=%LZ}-!;-Oni zg-|ze#6!233ZZV`h=*=56++#>5f9yBDulX$BObcNR0wqgM?7?ksSxT0j(F%6Qz6t1 z9P!XCrb4J2IO3sOOodQ4aKuBmmKe#Z(A&14le` zi>VOm299{>7E>YA4IJ^%Ev7=K8#v;jTTF#eH*my5x0ni{Zs3TAZZQ=?-M|qK-C`<) zx`87ey2VrobpuB{bc?AF>IRN@=oV8U)D0Z*&@HAys2e!qp<7IaP&aVI<6m>b#m7eL d28ZVkluBPIrM{Gw?ow({{GH)Bf96;%w_lB^eOmwk literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr44stereo.mp3 b/tinytag/tests/samples/mp3/vbr/vbr44stereo.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..e40c3f9d75146cb413b9f88ec11a3f3ab3d5552d GIT binary patch literal 36609 zcmeI$e`M5g9LMp`Wy^_klaZL*W@2PER}(X~*)i#ibZSh**YYJtVvdP3#u@XS88c>V zCYD5vL?RJMGbR#U#6%>QlK9H?y+2-!+rD>~@%`gG&+)xq?tS;Z*Z1M&ite0cs$8zx z=^NflqQ->AjB!HdNMra|BznC0#L1RZt?j2f&UALid(QXv^$#Y7FC<4UUA{6pcI~<~ zZr@Dbx;>GZypx@p&dtm^^9zeh%PXtvcQ@`oc)0oa$~P1cK>+utcwH5M7*=RtE(ZePk1iYel(Lbp`WGCVaFb=>4~~M7TqSxL<`2) z-d7A86N!00gLhr`?s_-RW{qk1x2TlQrRob(snVRbDsz0^eedBK=L`ijXFQ-exq#-} z3uw-pfac_#%DLVPmd~vk^TwRA{js+L9WC3^{=4jry_Hfy4#)vH@a?1f;VgTE^M~_? z^M~_CEZ_ki-~k@sLB;?1v>x9hj^Zee;;4YY13bV3Jir5bl`?<_c%UrM59kMYfCqR$ zKTrnn01uP}`T_j_5AXmF=m*LG9^iqpKtG@#-~k@s0sTN3zymx`7U&1`13bV3JfI&a z19*T3$^!j>et-vffCuygWdINGKv|$4&=2qc5Ac9~pbX#v9w-a+1Ns3T-~k@c50n8s zzyoE0en3CK13bV3`hhZl2Y8??&=2Sbcz_3ZKtE6h@Bj~#1^NN~01xm059kNV03P6h zvOqteAK(EV-~s(W8NdTPP!{M1^aDJ=13aJ~C<{9 literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr48.mp3 b/tinytag/tests/samples/mp3/vbr/vbr48.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..153edfc926edacc03b3afcc54bc0a12449c5cc90 GIT binary patch literal 36672 zcmeIxe`M5g9LMp`+9HXOB@r#P5t(I4B9=^4lPD#Ln0|;UiIi9+k~A_UA}J9`L{idd ziAW-15|KotRK#TMV%hWgS5x;-y}p0k-Sd0jd*A!}a5p^EH&A?R+}XW-UAcPg`i+~nZr{1r|KQ=H$4{OpqUIG_7%NZU5m?^QE%K{ifzqqi_5p&%;9v1EsdV z)Lb69p~g}c_LNfh%VB-xVEsJx`wx}*O-D7KAOGX|tGxeTp1-@w`=6=u{&%aq|6rB( z&wT!%$5!S46`!#pd>tQpxZ-0Gc6{jJijPIu@u7z+J{DoehaRr@ScDxPdbr|a5q5m& z;fjw%*zuu+-?4WDt%^TnBO5q7nGegIKC BZ07&~ literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr48stereo.mp3 b/tinytag/tests/samples/mp3/vbr/vbr48stereo.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3505c17eb4e32cd3fe3a5e6ab51fdb9271a8d90e GIT binary patch literal 36672 zcmeI*aYWo<7{Kwj?IN=riHzBrCPs2oOJ<`aBg>4=PBP-O5;2KWM#h-g9H~u2(nQRR zO%f4F#7r46iDqU*HWhJF?{B&uPrbh@=J&_=p5vb9{hjCT9`Akb`Q80-d#V1oIpGp( z>v&}E5;i8Q+n8jPIg_1p?tC~ezu>~f%U6nuua%TW$|@>v-ma>ty;E0z?|#FB#-@kO z@s`&1j>nx{iJm9DPoF)1@iN)h|7LJ#cw}^J{N4KxA15ZKre|j7<`+JHS^T>EZDn=s z$IoBu8-F&pcT&6d@#1%@>ubwOuSX)G-|1&~qwwn4nO7!j%X=QNf!O!-f&cY(FO?rR zVf*^p>?_DICb!Ub*K7`;QEaL*G~jo z|8>CiKLuQWDd75R$;|s-Hlrgt#r~wehp3}>cFFUB{?H%y?y#%8ze2vf@ea@*`orfJ zpD%t$-~&G313ut`@An&it@*o-pZJNN`00lPKHvjB-~&D|Upe*w-*2aXM0GUBF3}(Q zHBhzz2NLDoBP8_<#@iAR_1pAMgPm@Ik8}89v|xKH!6hpd)<1 z2YkQ>t%79ufDib94e9$ULh7b6F5BMM==m;P10Uz)|s~{OZ-~&G3gNUFbe8}vB?$)vFpZ`DN TSVg@+ecg|v8T~oh5uNV?h*gTg literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/mp3/vbr/vbr8.mp3 b/tinytag/tests/samples/mp3/vbr/vbr8.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..89b983bd0f6f61c1211d8bbbeb03369c172759fa GIT binary patch literal 9504 zcmezW*y9KT3`At+r32agK-|EiG3X;%h_#g!a7UpR9UlqptuxJ3eU|`Cj!t9{S!1x8^ z4zs4_zc2|p(f=PyfWn7y3B*2T4-gG^XDHDCWR5YF%t@n?IWwtb&Y4kjaHa!LypM(t z&fp0h4IkWL1CkjHpV9CE);JW^JEP?f?ld%-zi@{QNMc{J~5Eqxk|eOprxJ z!v{GuFxjKwgBd2sBBSAh92%JH(eS|x6J(Lm@Iej@O!jE_V1@~@$Y}T=hXy8lG<-0_ z1X*M>e2_x}lRX+fm|=n}G8#U}p@GRB4Ij)fK^7SeALP)$WRHdqW|$z0jD`<#XkfBO w!v`}=kVQtr2RSq_*`wiu879agqv3-b8klUFh7YE71VymAhoCM@1%u5T09C$DivR!s literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0bf3104..3a6bc7b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -147,7 +147,48 @@ 'track': '10', 'comment': ' ', 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': '1', 'disc_total': '1', 'track_total': '12', 'year': '2004'}), - + ('samples/mp3/vbr/vbr8.mp3', + {'filesize': 9504, 'audio_offset': 433, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, + 'extra': {}, 'samplerate': 8000}), + ('samples/mp3/vbr/vbr8stereo.mp3', + {'filesize': 9504, 'audio_offset': 441, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, + 'extra': {}, 'samplerate': 8000}), + ('samples/mp3/vbr/vbr11.mp3', + {'filesize': 9360, 'audio_offset': 433, 'bitrate': 8.143465909090908, 'channels': 1, + 'duration': 9.2, 'extra': {}, 'samplerate': 11025}), + ('samples/mp3/vbr/vbr11stereo.mp3', + {'filesize': 9360, 'audio_offset': 441, 'bitrate': 8.143465909090908, 'channels': 2, + 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), + ('samples/mp3/vbr/vbr16.mp3', + {'filesize': 9432, 'audio_offset': 433, 'bitrate': 8.251968503937007, 'channels': 1, + 'duration': 9.2, 'extra': {}, 'samplerate': 16000}), + ('samples/mp3/vbr/vbr16stereo.mp3', + {'filesize': 9432, 'audio_offset': 441, 'bitrate': 8.251968503937007, 'channels': 2, + 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), + ('samples/mp3/vbr/vbr22.mp3', + {'filesize': 9282, 'audio_offset': 433, 'bitrate': 8.145021489971347, 'channels': 1, + 'duration': 9.2, 'extra': {}, 'samplerate': 22050}), + ('samples/mp3/vbr/vbr22stereo.mp3', + {'filesize': 9282, 'audio_offset': 441, 'bitrate': 8.145021489971347, 'channels': 2, + 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), + ('samples/mp3/vbr/vbr32.mp3', + {'filesize': 37008, 'audio_offset': 441, 'bitrate': 32.50592885375494, 'channels': 1, + 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), + ('samples/mp3/vbr/vbr32stereo.mp3', + {'filesize': 37008, 'audio_offset': 456, 'bitrate': 32.50592885375494, 'channels': 2, + 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), + ('samples/mp3/vbr/vbr44.mp3', + {'filesize': 36609, 'audio_offset': 441, 'bitrate': 32.21697198275862, 'channels': 1, + 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), + ('samples/mp3/vbr/vbr44stereo.mp3', + {'filesize': 36609, 'audio_offset': 456, 'bitrate': 32.21697198275862, 'channels': 2, + 'duration': 9.0, 'extra': {}, 'samplerate': 44100}), + ('samples/mp3/vbr/vbr48.mp3', + {'filesize': 36672, 'audio_offset': 441, 'bitrate': 32.33862433862434, 'channels': 1, + 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), + ('samples/mp3/vbr/vbr48stereo.mp3', + {'filesize': 36672, 'audio_offset': 456, 'bitrate': 32.33862433862434, 'channels': 2, + 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), # OGG ('samples/empty.ogg', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 0ab7a4d..53c0dfa 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -650,8 +650,11 @@ def _determine_duration(self, fh): fh.seek(xing_header_offset, os.SEEK_CUR) xframes, byte_count, toc, vbr_scale = ID3._parse_xing_header(fh) if xframes and xframes != 0 and byte_count: - self.duration = (xframes * ID3.samples_per_frame / self.samplerate - / self.channels) # noqa + # MPEG-2 Audio Layer III uses 576 samples per frame + samples_per_frame = 576 if mpeg_id <= 2 else ID3.samples_per_frame + self.duration = xframes * samples_per_frame / float(self.samplerate) + # self.duration = (xframes * ID3.samples_per_frame / self.samplerate + # / self.channels) # noqa self.bitrate = byte_count * 8 / self.duration / 1000 self.audio_offset = fh.tell() return From 80364287265a8e9f99c04cc791c0922777e72bbe Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Sat, 12 Mar 2022 18:41:40 +0100 Subject: [PATCH 039/305] ID3: Set correct file position if tag reading is disabled #119 (reimplemented) --- tinytag/tinytag.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 53c0dfa..ded5b70 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -554,7 +554,7 @@ class ID3(TinyTag): def __init__(self, filehandler, filesize, *args, **kwargs): TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) # save position after the ID3 tag for duration mesurement speedup - self._bytepos_after_id3v2 = 0 + self._bytepos_after_id3v2 = None @classmethod def set_estimation_precision(cls, estimation_in_seconds): @@ -605,6 +605,10 @@ def _parse_xing_header(fh): return frames, byte_count, toc, vbr_scale def _determine_duration(self, fh): + # if tag reading was disabled, find start position of audio data + if self._bytepos_after_id3v2 is None: + self._parse_id3v2_header(fh) + max_estimation_frames = (ID3._MAX_ESTIMATION_SEC * 44100) // ID3.samples_per_frame frame_size_accu = 0 header_bytes = 4 @@ -695,7 +699,8 @@ def _parse_tag(self, fh): fh.seek(-128, os.SEEK_END) # try parsing id3v1 in last 128 bytes self._parse_id3v1(fh) - def _parse_id3v2(self, fh): + def _parse_id3v2_header(self, fh): + size, extended, major = 0, None, None # for info on the specs, see: http://id3.org/Developer%20Information header = struct.unpack('3sBBB4B', _read(fh, 10)) tag = codecs.decode(header[0], 'ISO-8859-1') @@ -709,7 +714,12 @@ def _parse_id3v2(self, fh): # experimental = (header[3] & 0x20) > 0 # footer = (header[3] & 0x10) > 0 size = self._calc_size(header[4:8], 7) - self._bytepos_after_id3v2 = size + self._bytepos_after_id3v2 = size + return size, extended, major + + def _parse_id3v2(self, fh): + size, extended, major = self._parse_id3v2_header(fh) + if size: end_pos = fh.tell() + size parsed_size = 0 if extended: # just read over the extended header. From 32e4d399ee95f212fecefdc3c74d1b969cc85a81 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Sat, 12 Mar 2022 18:49:25 +0100 Subject: [PATCH 040/305] bumped version to 1.8.1, updated README --- README.md | 3 +++ tinytag/__init__.py | 2 +- tinytag/tinytag.py | 4 ++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 48e155d..8a6578b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,9 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') Changelog: + * 1.8.1 (2022-03-12) [still mathiascode-edition] + - MP3 ID3: Set correct file position if tag reading is disabled #119 (thanks to mathiascode) + - MP3: Fix incorrect calculation of duration for VBR encoded MP3s #128 (thanks to mathiascode) * 1.8.0 (2022-03-05) [mathiascode-edition] - Add support for ALAC audio files #130 (thanks to mathiascode) - AIFF: Fixed bitrate calculation for certain files #129 (thanks to mathiascode) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index ddb41e7..8a85310 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,7 +4,7 @@ from .tinytag import TinyTag -__version__ = '1.8.0' +__version__ = '1.8.1' if __name__ == '__main__': diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ded5b70..7d6acfb 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -2,14 +2,14 @@ # -*- coding: utf-8 -*- # tinytag - an audio meta info reader -# Copyright (c) 2014-2021 Tom Wallroth +# Copyright (c) 2014-2022 Tom Wallroth # # Sources on github: # http://github.com/devsnd/tinytag/ # MIT License -# Copyright (c) 2014-2021 Tom Wallroth +# Copyright (c) 2014-2022 Tom Wallroth # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal From 671cab5dfbf865f86fda28c4031bcc2efe103137 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sat, 12 Mar 2022 19:50:30 +0200 Subject: [PATCH 041/305] Avoid breaking links in code comments --- tinytag/tinytag.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 53c0dfa..e107db6 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -270,9 +270,8 @@ def _unpad(s): class MP4(TinyTag): - # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/ - # Metadata.html - # and: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html + # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html + # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html class Parser: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 @@ -1214,8 +1213,7 @@ def _parse_tag(self, fh): 'WM/AlbumTitle': 'album', 'WM/Composer': 'composer', } - # see: http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/ - # library/bb643323.aspx#_Toc509555195 + # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 descriptor_count = _bytes_to_int_le(fh.read(2)) for _ in range(descriptor_count): name_len = _bytes_to_int_le(fh.read(2)) From 910d233d87796dc41b8aee629b418061364f9240 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sun, 13 Mar 2022 18:29:41 +0200 Subject: [PATCH 042/305] RIFF: Handle tags containing extra zero-byte --- tinytag/tests/samples/riff_extra_zero.wav | Bin 0 -> 20670 bytes tinytag/tests/samples/riff_extra_zero_2.wav | Bin 0 -> 20682 bytes tinytag/tests/test_all.py | 13 ++++++++++++- tinytag/tinytag.py | 7 +++++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 tinytag/tests/samples/riff_extra_zero.wav create mode 100644 tinytag/tests/samples/riff_extra_zero_2.wav diff --git a/tinytag/tests/samples/riff_extra_zero.wav b/tinytag/tests/samples/riff_extra_zero.wav new file mode 100644 index 0000000000000000000000000000000000000000..93bacea85d511a085c3de97d20a45cc11e8386ea GIT binary patch literal 20670 zcmeIuPin$Y5XbTHPZvTh=?!uLp9!U6meC|N5dZKJ+Vuk!vSraI!LeJ z?)z3VznKTa$L3k~yAk<_-_!l)(f358BV9=@BHpzteetBo%j&FozRK2lY^#CRGqX0O zNsXp+n>=U|M$tsOx0QX=Zc#U*#i1En*XX}^In-=fw{3m+@@KAXb#r5rXHBQU^p^PP zPxHO|pHw}a1TVT(X4R9{g*)wEBNI+c7zDC)M<;qU4*>)aKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ h1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R-+Z@COb7C?5a- literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/riff_extra_zero_2.wav b/tinytag/tests/samples/riff_extra_zero_2.wav new file mode 100644 index 0000000000000000000000000000000000000000..0d7bfdeb0ed0c3ffe6fb4c67bbc71dc76e0df20b GIT binary patch literal 20682 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 Date: Sun, 13 Mar 2022 19:17:28 +0200 Subject: [PATCH 043/305] Add .m4v to list of supported file extensions --- README.md | 2 +- tinytag/tinytag.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8a6578b..39bfda9 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Features: * OPUS * FLAC * WMA - * MP4/M4A/M4B/M4R/ALAC + * MP4/M4A/M4B/M4R/M4V/ALAC * AIFF/AIFF-C * pure python, no dependencies * supports python 2.7 and 3.4 or higher diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 7d6acfb..66bf751 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -130,7 +130,7 @@ def _get_parser_for_filename(cls, filename): (b'.wav',): Wave, (b'.flac',): Flac, (b'.wma',): Wma, - (b'.m4b', b'.m4a', b'.m4r', b'.mp4'): MP4, + (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4'): MP4, (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, } if not isinstance(filename, bytes): # convert filename to binary From 36c52ba80132150a33b02765d9fbf8a54c217b30 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Sun, 5 Jun 2022 22:46:25 +0300 Subject: [PATCH 044/305] ID3: Only check for language in COMM and USLT frames --- .../tests/samples/id3v24_genre_null_byte.mp3 | Bin 0 -> 256 bytes tinytag/tests/test_all.py | 4 ++ tinytag/tinytag.py | 38 +++++++++--------- 3 files changed, 23 insertions(+), 19 deletions(-) create mode 100644 tinytag/tests/samples/id3v24_genre_null_byte.mp3 diff --git a/tinytag/tests/samples/id3v24_genre_null_byte.mp3 b/tinytag/tests/samples/id3v24_genre_null_byte.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..8e8dd88757f1b7e54bc105eda3d768f135849d9f GIT binary patch literal 256 zcmeZtF=k<4U|`bc3h@jv0Sq}5ndi{ZXQV? XVSW)=DS0_rDH$0B6+LwYB^_k|T0b@= literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 6603821..a0a4f28 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -189,6 +189,10 @@ ('samples/mp3/vbr/vbr48stereo.mp3', {'filesize': 36672, 'audio_offset': 456, 'bitrate': 32.33862433862434, 'channels': 2, 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), + ('samples/id3v24_genre_null_byte.mp3', + {'extra': {}, 'filesize': 256, 'album': '\u79d8\u5bc6', 'albumartist': 'aiko', + 'artist': 'aiko', 'disc': '1', 'genre': 'Pop', + 'title': '\u661f\u306e\u306a\u3044\u4e16\u754c', 'track': '10'}), # OGG ('samples/empty.ogg', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index bca0cb0..8144d50 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -211,9 +211,8 @@ def load(self, tags, duration, image=False): self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname, bytestring, transfunc=None, overwrite=True): - """convienience function to set fields of the tinytag by name. - the payload (bytestring) can be changed using the transfunc""" + def _set_field(self, fieldname, value, overwrite=True): + """convienience function to set fields of the tinytag by name""" write_dest = self # write into the TinyTag by default get_func = getattr set_func = setattr @@ -225,7 +224,6 @@ def _set_field(self, fieldname, bytestring, transfunc=None, overwrite=True): set_func = operator.setitem if get_func(write_dest, fieldname): # do not overwrite existing data return - value = bytestring if transfunc is None else transfunc(bytestring) if DEBUG: stderr('Setting field "%s" to "%s"' % (fieldname, value)) if fieldname == 'genre': @@ -737,15 +735,15 @@ def _parse_id3v1(self, fh): def asciidecode(x): return self._unpad(codecs.decode(x, self._default_encoding or 'latin1')) fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) - self._set_field('title', fields[:30], transfunc=asciidecode, overwrite=False) - self._set_field('artist', fields[30:60], transfunc=asciidecode, overwrite=False) - self._set_field('album', fields[60:90], transfunc=asciidecode, overwrite=False) - self._set_field('year', fields[90:94], transfunc=asciidecode, overwrite=False) + self._set_field('title', asciidecode(fields[:30]), overwrite=False) + self._set_field('artist', asciidecode(fields[30:60]), overwrite=False) + self._set_field('album', asciidecode(fields[60:90]), overwrite=False) + self._set_field('year', asciidecode(fields[90:94]), overwrite=False) comment = fields[94:124] if b'\x00\x00' < comment[-2:] < b'\x01\x00': self._set_field('track', str(ord(comment[-1:])), overwrite=False) comment = comment[:-2] - self._set_field('comment', comment, transfunc=asciidecode, overwrite=False) + self._set_field('comment', asciidecode(comment), overwrite=False) genre_id = ord(fields[124:125]) if genre_id < len(ID3.ID3V1_GENRES): self._set_field('genre', ID3.ID3V1_GENRES[genre_id], overwrite=False) @@ -780,7 +778,8 @@ def _parse_frame(self, fh, id3version=False): content = fh.read(frame_size) fieldname = ID3.FRAME_ID_TO_FIELD.get(frame_id) if fieldname: - self._set_field(fieldname, content, self._decode_string) + language = fieldname in ("comment", "extra.lyrics") + self._set_field(fieldname, self._decode_string(content, language)) elif frame_id in self.IMAGE_FRAME_IDS and self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames encoding = content[0:1] @@ -796,7 +795,7 @@ def _parse_frame(self, fh, id3version=False): return frame_size return 0 - def _decode_string(self, bytestr): + def _decode_string(self, bytestr, language=False): default_encoding = 'ISO-8859-1' if self._default_encoding: default_encoding = self._default_encoding @@ -808,12 +807,13 @@ def _decode_string(self, bytestr): elif first_byte == b'\x01': # UTF-16 with BOM bytestr = bytestr[1:] # remove language (but leave BOM) - if bytestr[3:5] in (b'\xfe\xff', b'\xff\xfe'): - bytestr = bytestr[3:] - if bytestr[:3].isalpha() and bytestr[3:4] == b'\x00': - bytestr = bytestr[4:] # remove language - if bytestr[:1] == b'\x00': - bytestr = bytestr[1:] # strip optional additional null byte + if language: + if bytestr[3:5] in (b'\xfe\xff', b'\xff\xfe'): + bytestr = bytestr[3:] + if bytestr[:3].isalpha() and bytestr[3:4] == b'\x00': + bytestr = bytestr[4:] # remove language + if bytestr[:1] == b'\x00': + bytestr = bytestr[1:] # strip optional additional null byte # read byte order mark to determine endianess encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' # strip the bom if it exists @@ -832,7 +832,7 @@ def _decode_string(self, bytestr): else: bytestr = bytestr encoding = default_encoding # wild guess - if bytestr[:3].isalpha() and bytestr[3:4] == b'\x00': + if language and bytestr[:3].isalpha() and bytestr[3:4] == b'\x00': bytestr = bytestr[4:] # remove language errors = 'ignore' if self._ignore_errors else 'strict' return self._unpad(codecs.decode(bytestr, encoding, errors)) @@ -1215,7 +1215,7 @@ def _parse_tag(self, fh): ]) for field_name, bytestring in data_blocks.items(): if field_name: - self._set_field(field_name, bytestring, self.__decode_string) + self._set_field(field_name, self.__decode_string(bytestring)) elif object_id == Wma.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: mapping = { 'WM/TrackNumber': 'track', From 2bf9cac97dbcdada342dc6d9459ae72df1465c06 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Mon, 6 Jun 2022 00:26:30 +0300 Subject: [PATCH 045/305] test_all.py: Remove redundant attributes with None value --- tinytag/tests/test_all.py | 148 ++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 78 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 6603821..56a0cd4 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -30,13 +30,13 @@ testfiles = OrderedDict([ # MP3 ('samples/vbri.mp3', - {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'track_total': None, + {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': None, 'duration': 0.49, + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', @@ -56,28 +56,26 @@ 'artist': 'Anais Mitchell', 'track': '3', 'filesize': 5120, 'audio_offset': 2225, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), ('samples/silence-44-s-v1.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'track_total': None, + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', 'title': 'Silence', 'artist': 'piman', 'track': '2', 'filesize': 15070, 'audio_offset': 0, 'bitrate': 32.0, 'comment': ''}), ('samples/id3v1-latin1.mp3', - {'extra': {}, 'channels': None, 'samplerate': None, 'genre': 'Rock', + {'extra': {}, 'genre': 'Rock', 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', - 'artist': 'Björk', 'track_total': None, 'year': '1993', - 'comment': ' '}), + 'artist': 'Björk', 'year': '1993', 'comment': ' '}), ('samples/UTF16.mp3', {'extra': {'text': 'MusicBrainz Artist Id664c3e0e-42d8-48c1-b209-1efca19c0325', - 'url': 'WIKIPEDIA_RELEASEhttp://en.wikipedia.org/wiki/High_Violet'}, 'channels': None, - 'samplerate': None, 'track_total': '11', 'track': '07', 'artist': 'The National', + 'url': 'WIKIPEDIA_RELEASEhttp://en.wikipedia.org/wiki/High_Violet'}, 'track_total': '11', + 'track': '07', 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), ('samples/utf-8-id3v2.mp3', - {'extra': {}, 'channels': None, 'samplerate': None, 'genre': 'Acustico', + {'extra': {}, 'genre': 'Acustico', 'track_total': '21', 'track': '01', 'filesize': 2119, 'title': 'Gran día', - 'artist': 'Paso a paso', 'album': 'S/T', 'year': None, 'disc': '', 'disc_total': '0'}), + 'artist': 'Paso a paso', 'album': 'S/T', 'disc': '', 'disc_total': '0'}), ('samples/empty_file.mp3', - {'extra': {}, 'channels': None, 'samplerate': None, 'track_total': None, 'album': None, - 'year': None, 'title': None, 'track': None, 'artist': None, 'filesize': 0}), + {'extra': {}, 'filesize': 0}), ('samples/silence-44khz-56k-mono-1s.mp3', {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.018, 'filesize': 7280, 'audio_offset': 0, 'bitrate': 56.0}), @@ -87,18 +85,17 @@ ('samples/id3v24-long-title.mp3', {'extra': {}, 'track': '1', 'disc_total': '1', 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, - 'channels': None, 'track_total': '12', 'genre': 'AlternRock', + 'track_total': '12', 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', - 'albumartist': 'Courtney Barnett', 'samplerate': None, 'year': None, 'disc': '1', + 'albumartist': 'Courtney Barnett', 'disc': '1', 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett'}), ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', - 'artist': 'The B52s', 'genre': 'Rock', 'albumartist': None, 'disc': None, - 'channels': None}), + 'artist': 'The B52s', 'genre': 'Rock'}), ('samples/id3v22_image.mp3', {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', - 'channels': None, 'genre': '.'}), + 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', 'comment': 'engiTunPGAP0', 'genre': 'Pop', 'title': 'Applause'}), @@ -148,66 +145,64 @@ 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': '1', 'disc_total': '1', 'track_total': '12', 'year': '2004'}), ('samples/mp3/vbr/vbr8.mp3', - {'filesize': 9504, 'audio_offset': 433, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, - 'extra': {}, 'samplerate': 8000}), + {'filesize': 9504, 'audio_offset': 433, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, + 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr8stereo.mp3', - {'filesize': 9504, 'audio_offset': 441, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, - 'extra': {}, 'samplerate': 8000}), + {'filesize': 9504, 'audio_offset': 441, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, + 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr11.mp3', - {'filesize': 9360, 'audio_offset': 433, 'bitrate': 8.143465909090908, 'channels': 1, - 'duration': 9.2, 'extra': {}, 'samplerate': 11025}), + {'filesize': 9360, 'audio_offset': 433, 'bitrate': 8.143465909090908, 'channels': 1, + 'duration': 9.2, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr11stereo.mp3', - {'filesize': 9360, 'audio_offset': 441, 'bitrate': 8.143465909090908, 'channels': 2, - 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), + {'filesize': 9360, 'audio_offset': 441, 'bitrate': 8.143465909090908, 'channels': 2, + 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr16.mp3', - {'filesize': 9432, 'audio_offset': 433, 'bitrate': 8.251968503937007, 'channels': 1, - 'duration': 9.2, 'extra': {}, 'samplerate': 16000}), + {'filesize': 9432, 'audio_offset': 433, 'bitrate': 8.251968503937007, 'channels': 1, + 'duration': 9.2, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr16stereo.mp3', - {'filesize': 9432, 'audio_offset': 441, 'bitrate': 8.251968503937007, 'channels': 2, - 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), + {'filesize': 9432, 'audio_offset': 441, 'bitrate': 8.251968503937007, 'channels': 2, + 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr22.mp3', - {'filesize': 9282, 'audio_offset': 433, 'bitrate': 8.145021489971347, 'channels': 1, - 'duration': 9.2, 'extra': {}, 'samplerate': 22050}), + {'filesize': 9282, 'audio_offset': 433, 'bitrate': 8.145021489971347, 'channels': 1, + 'duration': 9.2, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr22stereo.mp3', - {'filesize': 9282, 'audio_offset': 441, 'bitrate': 8.145021489971347, 'channels': 2, - 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), + {'filesize': 9282, 'audio_offset': 441, 'bitrate': 8.145021489971347, 'channels': 2, + 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr32.mp3', - {'filesize': 37008, 'audio_offset': 441, 'bitrate': 32.50592885375494, 'channels': 1, - 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), + {'filesize': 37008, 'audio_offset': 441, 'bitrate': 32.50592885375494, 'channels': 1, + 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), ('samples/mp3/vbr/vbr32stereo.mp3', - {'filesize': 37008, 'audio_offset': 456, 'bitrate': 32.50592885375494, 'channels': 2, - 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), + {'filesize': 37008, 'audio_offset': 456, 'bitrate': 32.50592885375494, 'channels': 2, + 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), ('samples/mp3/vbr/vbr44.mp3', - {'filesize': 36609, 'audio_offset': 441, 'bitrate': 32.21697198275862, 'channels': 1, - 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), + {'filesize': 36609, 'audio_offset': 441, 'bitrate': 32.21697198275862, 'channels': 1, + 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr44stereo.mp3', - {'filesize': 36609, 'audio_offset': 456, 'bitrate': 32.21697198275862, 'channels': 2, - 'duration': 9.0, 'extra': {}, 'samplerate': 44100}), + {'filesize': 36609, 'audio_offset': 456, 'bitrate': 32.21697198275862, 'channels': 2, + 'duration': 9.0, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr48.mp3', - {'filesize': 36672, 'audio_offset': 441, 'bitrate': 32.33862433862434, 'channels': 1, - 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), + {'filesize': 36672, 'audio_offset': 441, 'bitrate': 32.33862433862434, 'channels': 1, + 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), ('samples/mp3/vbr/vbr48stereo.mp3', - {'filesize': 36672, 'audio_offset': 456, 'bitrate': 32.33862433862434, 'channels': 2, - 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), + {'filesize': 36672, 'audio_offset': 456, 'bitrate': 32.33862433862434, 'channels': 2, + 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), # OGG ('samples/empty.ogg', - {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, - '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, + {'extra': {}, 'duration': 3.684716553287982, '_max_samplenum': 162496, '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, 'samplerate': 44100}), ('samples/multipagecomment.ogg', - {'extra': {}, 'track_total': None, 'duration': 3.684716553287982, 'album': None, - '_max_samplenum': 162496, 'year': None, 'title': None, 'artist': None, 'track': None, + {'extra': {}, 'duration': 3.684716553287982, '_max_samplenum': 162496, '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), ('samples/multipage-setup.ogg', - {'extra': {}, 'genre': 'JRock', 'track_total': None, 'duration': 4.128798185941043, + {'extra': {}, 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100}), ('samples/test.ogg', - {'extra': {}, 'track_total': None, 'duration': 1.0, 'album': 'the boss', 'year': '2006', + {'extra': {}, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'hello!'}), @@ -243,9 +238,9 @@ 'title': 'thetitle', 'track': '66', 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), ('samples/test-riff-tags.wav', - {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'album': None, + {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'title': 'thetitle', 'track': None, 'audio_offset': 36, 'comment': 'hello', + 'title': 'thetitle', 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, @@ -262,26 +257,24 @@ ('samples/riff_extra_zero.wav', {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20670, 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offset': 182, 'artist': 'B.O.S.E.', - 'title': 'Mission Bass', 'track': None, 'album': '808 Bass Express', 'comment': None, - 'genre': 'Hip-Hop/Rap', 'year': '1996'}), + 'title': 'Mission Bass', 'album': '808 Bass Express', 'genre': 'Hip-Hop/Rap', + 'year': '1996'}), ('samples/riff_extra_zero_2.wav', {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20682, 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offset': 194, - 'artist': 'The Jimmy Castor Bunch', 'title': 'It\'s Just Begun', 'track': None, - 'album': 'The Perfect Beats, Vol. 4', 'comment': None, 'genre': 'Pop Electronica', - 'year': None}), + 'artist': 'The Jimmy Castor Bunch', 'title': 'It\'s Just Begun', + 'album': 'The Perfect Beats, Vol. 4', 'genre': 'Pop Electronica'}), # FLAC ('samples/flac1sMono.flac', - {'extra': {}, 'genre': 'Avantgarde', 'track_total': None, 'album': 'alb', 'year': '2014', + {'extra': {}, 'genre': 'Avantgarde', 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100}), ('samples/flac453sStereo.flac', - {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, - 'duration': 453.51473922902494, 'title': None, 'track': None, 'artist': None, - 'filesize': 84236, 'bitrate': 1.4859230399999999, 'samplerate': 44100}), + {'extra': {}, 'channels': 2, 'duration': 453.51473922902494, 'filesize': 84236, + 'bitrate': 1.4859230399999999, 'samplerate': 44100}), ('samples/flac1.5sStereo.flac', - {'extra': {}, 'channels': 2, 'track_total': None, 'album': 'alb', 'year': '2014', + {'extra': {}, 'channels': 2, 'album': 'alb', 'year': '2014', 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', 'samplerate': 44100}), @@ -291,9 +284,8 @@ 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100}), ('samples/no-tags.flac', - {'extra': {}, 'channels': 2, 'track_total': None, 'album': None, 'year': None, - 'duration': 3.684716553287982, 'title': None, 'track': None, 'artist': None, - 'filesize': 4692, 'bitrate': 10.186943678613627, 'samplerate': 44100}), + {'extra': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, + 'bitrate': 10.186943678613627, 'samplerate': 44100}), ('samples/variable-block.flac', {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', @@ -310,14 +302,14 @@ 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'year': '2018'}), ('samples/with_padded_id3_header.flac', - {'extra': {}, 'filesize': 16070, 'album': 'album', 'albumartist': None, 'artist': 'artist', - 'audio_offset': None, 'bitrate': 283.4748, 'channels': 1, 'comment': None, 'disc': None, - 'disc_total': None, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, - 'title': 'title', 'track': '1', 'track_total': None, 'year': '2018'}), + {'extra': {}, 'filesize': 16070, 'album': 'album', 'artist': 'artist', + 'bitrate': 283.4748, 'channels': 1, + 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, + 'title': 'title', 'track': '1', 'year': '2018'}), ('samples/with_padded_id3_header2.flac', - {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'albumartist': None, - 'artist': 'Unbekannter Künstler', 'audio_offset': None, 'bitrate': 344.36807999999996, - 'channels': 1, 'comment': None, 'disc': '1', 'disc_total': '1', + {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', + 'artist': 'Unbekannter Künstler', 'bitrate': 344.36807999999996, + 'channels': 1, 'disc': '1', 'disc_total': '1', 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018'}), ('samples/flac_with_image.flac', @@ -330,7 +322,7 @@ ('samples/test2.wma', {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', - 'artist': 'Foo Fighters', 'duration': 83.406, 'track_total': None, 'year': '1997', + 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), # ALAC/M4A/MP4 @@ -370,8 +362,8 @@ 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014'}), ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, - 'filesize': 164, 'artist': None, 'bitrate': 1411.2, 'genre': None, 'samplerate': 44100, - 'track': None, 'title': 'Go Out and Get Some', 'album': None, 'audio_offset': 156, + 'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, + 'title': 'Go Out and Get Some', 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}), ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, @@ -379,8 +371,8 @@ 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah'}), ('samples/M1F1-mulawC-AFsp.afc', - {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'artist': None, - 'title': None, 'album': None, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, + {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, + 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio'}), ]) From c889f38c651f39d736683ec44dc82a53dfe9b822 Mon Sep 17 00:00:00 2001 From: mathiascode Date: Mon, 6 Jun 2022 01:08:56 +0300 Subject: [PATCH 046/305] __init__.py: Restore imports My previous flake8 PR introduced breaking API changes, which was not intended. --- tinytag/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 8a85310..1009f2a 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- import sys -from .tinytag import TinyTag +from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 __version__ = '1.8.1' From dfdab5328fd34bbad709eab6ae3c12e0756ba525 Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Sun, 3 Jul 2022 09:03:16 +1000 Subject: [PATCH 047/305] docs: Fix a few typos There are small typos in: - tinytag/tinytag.py Fixes: - Should read `offset` rather than `offest`. - Should read `endianness` rather than `endianess`. - Should read `convenience` rather than `convienience`. --- tinytag/tinytag.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index bca0cb0..20bb83f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -212,7 +212,7 @@ def load(self, tags, duration, image=False): self._determine_duration(self._filehandler) def _set_field(self, fieldname, bytestring, transfunc=None, overwrite=True): - """convienience function to set fields of the tinytag by name. + """convenience function to set fields of the tinytag by name. the payload (bytestring) can be changed using the transfunc""" write_dest = self # write into the TinyTag by default get_func = getattr @@ -814,7 +814,7 @@ def _decode_string(self, bytestr): bytestr = bytestr[4:] # remove language if bytestr[:1] == b'\x00': bytestr = bytestr[1:] # strip optional additional null byte - # read byte order mark to determine endianess + # read byte order mark to determine endianness encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' # strip the bom if it exists if bytestr[:2] in (b'\xfe\xff', b'\xff\xfe'): @@ -871,7 +871,7 @@ def _determine_duration(self, fh): fh.seek(max(seekpos, 1), os.SEEK_CUR) def _parse_tag(self, fh): - page_start_pos = fh.tell() # set audio_offest later if its audio data + page_start_pos = fh.tell() # set audio_offset later if its audio data for packet in self._parse_pages(fh): walker = BytesIO(packet) if packet[0:7] == b"\x01vorbis": From 2d64115652a6f9af2a4eeee3fec3c69854d018d8 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 19 Jul 2022 13:32:14 +0300 Subject: [PATCH 048/305] ID3: Read the correct number of bytes from Xing header TOC uses 100 bytes, but we were reading 400 bytes. Fixes #153 --- .../tests/samples/vbr_xing_header_short.mp3 | Bin 0 -> 432 bytes tinytag/tests/test_all.py | 35 ++++++++++-------- tinytag/tinytag.py | 2 +- 3 files changed, 20 insertions(+), 17 deletions(-) create mode 100644 tinytag/tests/samples/vbr_xing_header_short.mp3 diff --git a/tinytag/tests/samples/vbr_xing_header_short.mp3 b/tinytag/tests/samples/vbr_xing_header_short.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0c2d62f280cd8867c230694682db5070eb358e00 GIT binary patch literal 432 zcmezW*y9KT3`At+r32agK+MFzz_@{-fmHCHWB|0w&BxK#)mYEaz<@;q)2~hYjdw}eQyEBwz05ZoKW)8`^5gGuQZG-jz literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 56a0cd4..9ca7439 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -44,10 +44,10 @@ # the output of the lame encoder was 185.4 bitrate, but this is good enough for now ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, - 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 441}), + 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 141}), ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", - 'artist': 'Knodel and Valencia', 'audio_offset': 694, 'bitrate': 46.276128290848305, + 'artist': 'Knodel and Valencia', 'audio_offset': 394, 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', @@ -145,47 +145,50 @@ 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': '1', 'disc_total': '1', 'track_total': '12', 'year': '2004'}), ('samples/mp3/vbr/vbr8.mp3', - {'filesize': 9504, 'audio_offset': 433, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, + {'filesize': 9504, 'audio_offset': 133, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr8stereo.mp3', - {'filesize': 9504, 'audio_offset': 441, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, + {'filesize': 9504, 'audio_offset': 141, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr11.mp3', - {'filesize': 9360, 'audio_offset': 433, 'bitrate': 8.143465909090908, 'channels': 1, + {'filesize': 9360, 'audio_offset': 133, 'bitrate': 8.143465909090908, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr11stereo.mp3', - {'filesize': 9360, 'audio_offset': 441, 'bitrate': 8.143465909090908, 'channels': 2, + {'filesize': 9360, 'audio_offset': 141, 'bitrate': 8.143465909090908, 'channels': 2, 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr16.mp3', - {'filesize': 9432, 'audio_offset': 433, 'bitrate': 8.251968503937007, 'channels': 1, + {'filesize': 9432, 'audio_offset': 133, 'bitrate': 8.251968503937007, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr16stereo.mp3', - {'filesize': 9432, 'audio_offset': 441, 'bitrate': 8.251968503937007, 'channels': 2, + {'filesize': 9432, 'audio_offset': 141, 'bitrate': 8.251968503937007, 'channels': 2, 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr22.mp3', - {'filesize': 9282, 'audio_offset': 433, 'bitrate': 8.145021489971347, 'channels': 1, + {'filesize': 9282, 'audio_offset': 133, 'bitrate': 8.145021489971347, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr22stereo.mp3', - {'filesize': 9282, 'audio_offset': 441, 'bitrate': 8.145021489971347, 'channels': 2, + {'filesize': 9282, 'audio_offset': 141, 'bitrate': 8.145021489971347, 'channels': 2, 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr32.mp3', - {'filesize': 37008, 'audio_offset': 441, 'bitrate': 32.50592885375494, 'channels': 1, + {'filesize': 37008, 'audio_offset': 141, 'bitrate': 32.50592885375494, 'channels': 1, 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), ('samples/mp3/vbr/vbr32stereo.mp3', - {'filesize': 37008, 'audio_offset': 456, 'bitrate': 32.50592885375494, 'channels': 2, + {'filesize': 37008, 'audio_offset': 156, 'bitrate': 32.50592885375494, 'channels': 2, 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), ('samples/mp3/vbr/vbr44.mp3', - {'filesize': 36609, 'audio_offset': 441, 'bitrate': 32.21697198275862, 'channels': 1, + {'filesize': 36609, 'audio_offset': 141, 'bitrate': 32.21697198275862, 'channels': 1, 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr44stereo.mp3', - {'filesize': 36609, 'audio_offset': 456, 'bitrate': 32.21697198275862, 'channels': 2, + {'filesize': 36609, 'audio_offset': 156, 'bitrate': 32.21697198275862, 'channels': 2, 'duration': 9.0, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr48.mp3', - {'filesize': 36672, 'audio_offset': 441, 'bitrate': 32.33862433862434, 'channels': 1, + {'filesize': 36672, 'audio_offset': 141, 'bitrate': 32.33862433862434, 'channels': 1, 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), ('samples/mp3/vbr/vbr48stereo.mp3', - {'filesize': 36672, 'audio_offset': 456, 'bitrate': 32.33862433862434, 'channels': 2, + {'filesize': 36672, 'audio_offset': 156, 'bitrate': 32.33862433862434, 'channels': 2, 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), + ('samples/vbr_xing_header_short.mp3', + {'filesize': 432, 'audio_offset': 133, 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, + 'extra': {}, 'samplerate': 8000}), # OGG ('samples/empty.ogg', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index bca0cb0..1682033 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -598,7 +598,7 @@ def _parse_xing_header(fh): if header_flags & 2: # BYTES FLAG byte_count = struct.unpack('>i', fh.read(4))[0] if header_flags & 4: # TOC FLAG - toc = [struct.unpack('>i', fh.read(4))[0] for _ in range(100)] + toc = [struct.unpack('>i', fh.read(4))[0] for _ in range(25)] # 100 bytes if header_flags & 8: # VBR SCALE FLAG vbr_scale = struct.unpack('>i', fh.read(4))[0] return frames, byte_count, toc, vbr_scale From 248b2eba7b0664b8b6e35257e216079865e4996f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20S=C3=A4=C3=A4vuori?= Date: Fri, 2 Sep 2022 04:36:35 +0300 Subject: [PATCH 049/305] ID3: Add mapping for TDRC field to year --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 66fff62..a581e66 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -484,7 +484,7 @@ class ID3(TinyTag): FRAME_ID_TO_FIELD = { # Mapping from Frame ID to a field of the TinyTag 'COMM': 'comment', 'COM': 'comment', 'TRCK': 'track', 'TRK': 'track', - 'TYER': 'year', 'TYE': 'year', + 'TYER': 'year', 'TYE': 'year', 'TDRC': 'year', 'TALB': 'album', 'TAL': 'album', 'TPE1': 'artist', 'TP1': 'artist', 'TIT2': 'title', 'TT2': 'title', From 1e49ac09db2f877e466de2b7358409a3f4cbfcc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20S=C3=A4=C3=A4vuori?= Date: Fri, 2 Sep 2022 04:37:34 +0300 Subject: [PATCH 050/305] fix: add missing years to test fixtures --- tinytag/tests/test_all.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index abbb5dc..468a04a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -73,7 +73,7 @@ ('samples/utf-8-id3v2.mp3', {'extra': {}, 'genre': 'Acustico', 'track_total': '21', 'track': '01', 'filesize': 2119, 'title': 'Gran día', - 'artist': 'Paso a paso', 'album': 'S/T', 'disc': '', 'disc_total': '0'}), + 'artist': 'Paso a paso', 'album': 'S/T', 'disc': '', 'disc_total': '0', 'year': '2003'}), ('samples/empty_file.mp3', {'extra': {}, 'filesize': 0}), ('samples/silence-44khz-56k-mono-1s.mp3', @@ -88,10 +88,10 @@ 'track_total': '12', 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', 'albumartist': 'Courtney Barnett', 'disc': '1', - 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett'}), + 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett', 'year': '2013'}), ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', - 'artist': 'The B52s', 'genre': 'Rock'}), + 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), ('samples/id3v22_image.mp3', {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', @@ -189,7 +189,7 @@ ('samples/id3v24_genre_null_byte.mp3', {'extra': {}, 'filesize': 256, 'album': '\u79d8\u5bc6', 'albumartist': 'aiko', 'artist': 'aiko', 'disc': '1', 'genre': 'Pop', - 'title': '\u661f\u306e\u306a\u3044\u4e16\u754c', 'track': '10'}), + 'title': '\u661f\u306e\u306a\u3044\u4e16\u754c', 'track': '10', 'year': '2008'}), ('samples/vbr_xing_header_short.mp3', {'filesize': 432, 'audio_offset': 133, 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, 'extra': {}, 'samplerate': 8000}), @@ -376,7 +376,7 @@ {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, - 'comment': 'Audacity Pluck + Wahwah'}), + 'comment': 'Audacity Pluck + Wahwah', 'year': '2013'}), ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, From d1ff68c214a1974856a7e5a64f14469cffd5d23f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20S=C3=A4=C3=A4vuori?= Date: Fri, 2 Sep 2022 04:37:53 +0300 Subject: [PATCH 051/305] docs: fix small typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39bfda9..cec3e9d 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ List of possible attributes you can get with TinyTag: tag.title # title of the song tag.track # track number as string tag.track_total # total number of tracks as string - tag.year # year or data as string + tag.year # year or date as string For non-common fields and fields specific to single file formats use extra From 22f190939730c2d20aadb2a9f6b362be31efd9f9 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Thu, 29 Sep 2022 14:46:05 +0200 Subject: [PATCH 052/305] Update test_all.py fixed overlong line --- tinytag/tests/test_all.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 468a04a..743e48b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -88,7 +88,8 @@ 'track_total': '12', 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', 'albumartist': 'Courtney Barnett', 'disc': '1', - 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett', 'year': '2013'}), + 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett', + 'year': '2013'}), ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': '6', 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), From a781706e477e40e0b5138f779891db3ae746af45 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Sep 2022 02:49:16 +0300 Subject: [PATCH 053/305] Add bitdepth attribute for lossless audio --- README.md | 1 + tinytag/tests/samples/lossless.wma | Bin 0 -> 2500 bytes tinytag/tests/test_all.py | 81 +++++++++++++++-------------- tinytag/tests/test_cli.py | 7 +-- tinytag/tinytag.py | 27 ++++++---- 5 files changed, 64 insertions(+), 52 deletions(-) create mode 100644 tinytag/tests/samples/lossless.wma diff --git a/README.md b/README.md index cec3e9d..8b5e610 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ List of possible attributes you can get with TinyTag: tag.albumartist # album artist as string tag.artist # artist name as string tag.audio_offset # number of bytes before audio data begins + tag.bitdepth # bit depth for lossless audio tag.bitrate # bitrate in kBits/s tag.comment # file comment as string tag.composer # composer as string diff --git a/tinytag/tests/samples/lossless.wma b/tinytag/tests/samples/lossless.wma new file mode 100644 index 0000000000000000000000000000000000000000..89f730ace5d84aab0b62f09fc4183f1bbbe5ffca GIT binary patch literal 2500 zcmeHIeN0u?UJ_(9x~PH(e=^vC;sB)rn|A zVNMsum}$pAXdN;c^ za_2*+a=bxwyiP?a{RKLmsnN-4Hsp_)Pni2O;?N4g!eno_~Y?^g`RO$q+IRBF>~Wj^CDb8c;MpHYf=Hl+|w! z4hX3WJ@)Q|>91F12LfK!2c7!hc`F$(a88B{I0SsiMy}Jv3;PR0=8j9^T$A_MMRjjn zi-gVeEPvm`5`-0|){BDw48Oa90Gu1NcQ@SK8d9yOXy_@czwd5md-r4UI0KI}@W0JK z&*xK5pY1ww?ER~CoG5!%?A!m6i7&jJ;G3;h@P%S0*lTaqnW)$iMUSY9KPjKFI?-x4 za8IlAQ&T>sq0&n}aeJ=~xlbmz7?9!euIGZwFRM>f2JL?8wUDegAJwYBD0R_p-vLTh zHL*9$qPOi0+T1a(@ z-%VJDAPeC{45}>xaoR-elEXNT|D*Kh*=`po<}63BRFv9P5XPMfJ{Z}j?6?@Oq7=(e z8@zdsYNNqEq&T99!NA&gSRzJ(;6{MK5Db_!H+T}|UX;>Yq4Y`gEO%#fJ>-$Pk@%t}ml9rzOv|g#jW#O3E!lCk@g+_Xd03%i zvqf#X{^C2D2RfQL@r-uG)m$R2WW>hy=CaHg3*0j?X08TLQY9tmSPZdD%%b;s;Gd>& z{fxMnk*;rVp7JkZ%&8bWf1}A~N&aDnYl}fLyk^Fy*^9W8U7&lgROMpK)(=Znd1Dtx zdDFd}0-QI?Z^|EQE0F4=@wp+k#bm*UYjBYzqFJZPlP|8wGX(Ok!(zQjO6N$9wpa>d zGGiqk*7+I z;dW~6&9+=mg~6!CR}6yb%-9v}lx5JO?XO~`)KgA*$=b)7IP}& zGoESa%l6qD^3c(P3CQ<3*8#g-ttKaD@?%jHx@noyBDzqzAF{&7<7`t_it^axT R+6Z@^ZpvrQFO3P#{|y3_?27;Z literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 743e48b..0916ce7 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -236,40 +236,40 @@ # WAV ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, - 'samplerate': 44100, 'audio_offset': 36}), + 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 36}), ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, - 'samplerate': 44100, 'audio_offset': 36}), + 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 36}), ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'title': 'thetitle', 'track': '66', 'audio_offset': 36, 'comment': 'hello', + 'bitdepth': 16, 'title': 'thetitle', 'track': '66', 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'title': 'thetitle', 'audio_offset': 36, 'comment': 'hello', + 'bitdepth': 16, 'title': 'thetitle', 'audio_offset': 36, 'comment': 'hello', 'year': '2014'}), ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, - 'samplerate': 22050, 'audio_offset': 4088}), + 'samplerate': 22050, 'bitdepth': 16, 'audio_offset': 4088}), ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, - 'samplerate': 22050, 'audio_offset': 122, 'artist': 'Purpley', 'title': 'Test000', - 'track': '17', 'album': 'prototypes'}), + 'samplerate': 22050, 'bitdepth': 16, 'audio_offset': 122, 'artist': 'Purpley', + 'title': 'Test000', 'track': '17', 'album': 'prototypes'}), ('samples/adpcm.wav', {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, - 'bitrate': 176.4, 'samplerate': 44100, 'audio_offset': 82, 'artist': 'test artist', - 'title': 'test title', 'track': '1', 'album': 'test album', 'comment': 'test comment', - 'genre': 'test genre', 'year': '1990'}), + 'bitrate': 176.4, 'samplerate': 44100, 'bitdepth': 4, 'audio_offset': 82, + 'artist': 'test artist', 'title': 'test title', 'track': '1', 'album': 'test album', + 'comment': 'test comment', 'genre': 'test genre', 'year': '1990'}), ('samples/riff_extra_zero.wav', {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20670, - 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offset': 182, 'artist': 'B.O.S.E.', - 'title': 'Mission Bass', 'album': '808 Bass Express', 'genre': 'Hip-Hop/Rap', - 'year': '1996'}), + 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 182, + 'artist': 'B.O.S.E.', 'title': 'Mission Bass', 'album': '808 Bass Express', + 'genre': 'Hip-Hop/Rap', 'year': '1996'}), ('samples/riff_extra_zero_2.wav', {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20682, - 'bitrate': 1411.2, 'samplerate': 44100, 'audio_offset': 194, + 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 194, 'artist': 'The Jimmy Castor Bunch', 'title': 'It\'s Just Begun', 'album': 'The Perfect Beats, Vol. 4', 'genre': 'Pop Electronica'}), @@ -277,54 +277,55 @@ ('samples/flac1sMono.flac', {'extra': {}, 'genre': 'Avantgarde', 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, - 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100}), + 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100, 'bitdepth': 16}), ('samples/flac453sStereo.flac', {'extra': {}, 'channels': 2, 'duration': 453.51473922902494, 'filesize': 84236, - 'bitrate': 1.4859230399999999, 'samplerate': 44100}), + 'bitrate': 1.4859230399999999, 'samplerate': 44100, 'bitdepth': 16}), ('samples/flac1.5sStereo.flac', {'extra': {}, 'channels': 2, 'album': 'alb', 'year': '2014', 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', - 'samplerate': 44100}), + 'samplerate': 44100, 'bitdepth': 16}), ('samples/flac_application.flac', {'extra': {}, 'channels': 2, 'track_total': '11', 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', - 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100}), + 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100, 'bitdepth': 16}), ('samples/no-tags.flac', {'extra': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, - 'bitrate': 10.186943678613627, 'samplerate': 44100}), + 'bitrate': 10.186943678613627, 'samplerate': 44100, 'bitdepth': 16}), ('samples/variable-block.flac', {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, - 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, + 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'bitdepth': 16, 'composer': 'Boom Boom Satellites (Lyrics)', 'disc_total': '2'}), ('samples/106-invalid-streaminfo.flac', {'extra': {}, 'filesize': 4692}), ('samples/106-short-picture-block-size.flac', {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, - 'duration': 3.68, 'samplerate': 44100}), + 'duration': 3.68, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', {'extra': {}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, - 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'year': '2018'}), + 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, + 'year': '2018'}), ('samples/with_padded_id3_header.flac', {'extra': {}, 'filesize': 16070, 'album': 'album', 'artist': 'artist', 'bitrate': 283.4748, 'channels': 1, - 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, + 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, 'title': 'title', 'track': '1', 'year': '2018'}), ('samples/with_padded_id3_header2.flac', {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'artist': 'Unbekannter Künstler', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': '1', 'disc_total': '1', - 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'title': 'Track01', - 'track': '01', 'track_total': '05', 'year': '2018'}), + 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, + 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018'}), ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': '1', 'disc_total': '1', - 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'title': 'intro', 'track': '01', - 'track_total': '8'}), + 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'bitdepth': 16, 'title': 'intro', + 'track': '01', 'track_total': '8'}), # WMA ('samples/test2.wma', @@ -332,6 +333,9 @@ 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), + ('samples/lossless.wma', + {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, + 'duration': 43.133, 'artist': '', 'comment': '', 'title': ''}), # ALAC/M4A/MP4 ('samples/test.m4a', @@ -361,26 +365,27 @@ 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': '14', 'track_total': '27', 'disc': '1', 'disc_total': '1', 'samplerate': 44100, 'duration': 166.62639455782312, 'genre': 'Classical', 'albumartist': 'Howard Shelley', - 'channels': 2, 'bitrate': 436.743}), + 'channels': 2, 'bitrate': 436.743, 'bitdepth': 16}), # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', - 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'track': '1', 'title': 'thetitle', - 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014'}), + 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'bitdepth': 16, 'track': '1', + 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', + 'year': '2014'}), ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, - 'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, + 'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'title': 'Go Out and Get Some', 'audio_offset': 156, 'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}), ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', - 'bitrate': 176.4, 'samplerate': 11025, 'audio_offset': 116, + 'bitrate': 176.4, 'samplerate': 11025, 'bitdepth': 8, 'audio_offset': 116, 'comment': 'Audacity Pluck + Wahwah', 'year': '2013'}), ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, - 'bitrate': 256.0, 'samplerate': 8000, 'audio_offset': 154, + 'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16, 'audio_offset': 154, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio'}), ]) @@ -665,8 +670,8 @@ def test_to_str(): assert repr(tag) # since the dict is not ordered we cannot == 'somestring' assert str(tag) == ( '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", ' - '"audio_offset": 2225, "bitrate": 160.0, "channels": 2, "comment": "Waterbug Records, ' - 'www.anaismitchell.com", "composer": null, "disc": null, "disc_total": null, ' - '"duration": 0.13836297152858082, "extra": {}, "filesize": 5120, "genre": null, ' - '"samplerate": 44100, "title": "cosmic american", "track": "3", "track_total": "11", ' - '"year": "2004"}') + '"audio_offset": 2225, "bitdepth": null, "bitrate": 160.0, "channels": 2, ' + '"comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, ' + '"disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, ' + '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", ' + '"track_total": "11", "year": "2004"}') diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index af4b3c8..8e3d3df 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -12,9 +12,10 @@ bogus_file = os.path.join(sample_folder, 'there_is_no_such_ext.bogus') assert os.path.exists(mp3_with_image) -tinytag_attributes = {'album', 'albumartist', 'artist', 'audio_offset', 'bitrate', 'channels', - 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', 'filesize', - 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year'} +tinytag_attributes = {'album', 'albumartist', 'artist', 'audio_offset', 'bitdepth', 'bitrate', + 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', + 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', + 'track_total', 'year'} def run_cli(args): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a581e66..738d502 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -103,6 +103,7 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.extra = defaultdict(lambda: None) self.genre = None self.samplerate = None + self.bitdepth = None self.title = None self.track = None self.track_total = None @@ -365,12 +366,14 @@ def parse_audio_sample_entry_alac(cls, data): # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt alac_atom_size = struct.unpack('>I', data[28:32])[0] alac_atom = BytesIO(data[36:36 + alac_atom_size]) - alac_atom.seek(13, os.SEEK_CUR) + alac_atom.seek(9, os.SEEK_CUR) + bitdepth = struct.unpack('b', alac_atom.read(1))[0] + alac_atom.seek(3, os.SEEK_CUR) channels = struct.unpack('b', alac_atom.read(1))[0] alac_atom.seek(6, os.SEEK_CUR) avg_br = struct.unpack('>I', alac_atom.read(4))[0] / 1000 # kbit/s sr = struct.unpack('>I', alac_atom.read(4))[0] - return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} + return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} @classmethod def parse_mvhd(cls, data): @@ -992,23 +995,23 @@ def _determine_duration(self, fh): riff, size, fformat = struct.unpack('4sI4s', fh.read(12)) if riff != b'RIFF' or fformat != b'WAVE': raise TinyTagException('not a wave file!') - bitdepth = 16 # assume 16bit depth (CD quality) + self.bitdepth = 16 # assume 16bit depth (CD quality) chunk_header = fh.read(8) while len(chunk_header) == 8: subchunkid, subchunksize = struct.unpack('4sI', chunk_header) if subchunkid == b'fmt ': _, self.channels, self.samplerate = struct.unpack('HHI', fh.read(8)) - _, _, bitdepth = struct.unpack(' 0: fh.seek(remaining_size, 1) # skip remaining data in chunk elif subchunkid == b'data': - self.duration = subchunksize / self.channels / self.samplerate / (bitdepth / 8) + self.duration = subchunksize / self.channels / self.samplerate / (self.bitdepth / 8) self.audio_offset = fh.tell() - 8 # rewind to data header fh.seek(subchunksize, 1) elif subchunkid == b'LIST' and self._parse_tags: @@ -1099,8 +1102,7 @@ def _determine_duration(self, fh): # #---4---# #---5---# #---6---# #---7---# #--8-~ ~-12-# self.samplerate = _bytes_to_int(header[4:7]) >> 4 self.channels = ((header[6] >> 1) & 0x07) + 1 - # bit_depth = ((header[6] & 1) << 4) + ((header[7] & 0xF0) >> 4) - # bit_depth = (bit_depth + 1) + self.bitdepth = (((header[6] & 1) << 4) + ((header[7] & 0xF0) >> 4) + 1) total_sample_bytes = [(header[7] & 0x0F)] + list(header[8:12]) total_samples = _bytes_to_int(total_sample_bytes) self.duration = total_samples / self.samplerate @@ -1278,6 +1280,8 @@ def _parse_tag(self, fh): ]) self.samplerate = stream_info['samples_per_second'] self.bitrate = stream_info['avg_bytes_per_second'] * 8 / 1000 + if stream_info['codec_id_format_tag'] == 355: # lossless + self.bitdepth = stream_info['bits_per_sample'] already_read = 16 fh.seek(blocks['type_specific_data_length'] - already_read, os.SEEK_CUR) fh.seek(blocks['error_correction_data_length'], os.SEEK_CUR) @@ -1330,8 +1334,9 @@ def _determine_duration(self, fh): aiffobj = aifc.open(fh, 'rb') self.channels = aiffobj.getnchannels() self.samplerate = aiffobj.getframerate() + self.bitdepth = aiffobj.getsampwidth() * 8 self.duration = aiffobj.getnframes() / self.samplerate - self.bitrate = self.samplerate * self.channels * aiffobj.getsampwidth() * 8 / 1000 + self.bitrate = self.samplerate * self.channels * self.bitdepth / 1000 def _parse_tag(self, fh): fh.seek(0, 0) From 10bf2b90c0e244b364c994906b3cfb9dab157e33 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Sep 2022 17:12:45 +0300 Subject: [PATCH 054/305] Add Coveralls to GitHub Actions workflow --- .github/workflows/tests.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7edf5bc..84b0f34 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8'] + python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] exclude: - os: macos-latest python: 'pypy-3.6' # Not installable @@ -32,3 +32,14 @@ jobs: - name: Unit tests run: python -m pytest --cov + + - name: Coverage report + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.10' + run: coverage lcov + + - name: Coveralls + uses: coverallsapp/github-action@master + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.10' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: coverage.lcov From d977e3c69bd76351d91725824cf67a171690c566 Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 30 Sep 2022 10:19:18 +0200 Subject: [PATCH 055/305] enable DEBUG env var in tests for higher coverage --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84b0f34..9fd2793 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,6 +32,8 @@ jobs: - name: Unit tests run: python -m pytest --cov + env: + DEBUG: true - name: Coverage report if: matrix.os == 'ubuntu-latest' && matrix.python == '3.10' From b985b0774c27a1ff484cbbe8289a988d151be94b Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 30 Sep 2022 10:54:24 +0200 Subject: [PATCH 056/305] reworked tests, added mp4 fields (#161) * reworked tests, added mp4 fields (copyright and lyrics --- tinytag/tests/test_all.py | 154 +++++++++++++++++++++++--------------- tinytag/tinytag.py | 23 ++++-- 2 files changed, 109 insertions(+), 68 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0916ce7..4f12906 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -11,6 +11,7 @@ from __future__ import unicode_literals import io +import operator import os import re import shutil @@ -197,21 +198,21 @@ # OGG ('samples/empty.ogg', - {'extra': {}, 'duration': 3.684716553287982, '_max_samplenum': 162496, - '_tags_parsed': False, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, + {'extra': {}, 'duration': 3.684716553287982, + 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, 'samplerate': 44100}), ('samples/multipagecomment.ogg', - {'extra': {}, 'duration': 3.684716553287982, '_max_samplenum': 162496, - '_tags_parsed': False, 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, + {'extra': {}, 'duration': 3.684716553287982, + 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, 'samplerate': 44100}), ('samples/multipage-setup.ogg', {'extra': {}, 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', - '_tags_parsed': False, 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, + 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100}), ('samples/test.ogg', {'extra': {}, 'duration': 1.0, 'album': 'the boss', 'year': '2006', - 'title': 'the boss', 'artist': 'james brown', 'track': '1', '_tags_parsed': False, + 'title': 'the boss', 'artist': 'james brown', 'track': '1', 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'hello!'}), ('samples/corrupt_metadata.ogg', @@ -343,7 +344,7 @@ 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), ('samples/test2.m4a', - {'extra': {}, 'bitrate': 256.0, 'track': '1', + {'extra': {'copyright': '℗ 1992 Ace Records'}, 'bitrate': 256.0, 'track': '1', 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', @@ -359,7 +360,8 @@ 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), ('samples/alac_file.m4a', - {'extra': {}, 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', + {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:'}, + 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', 'filesize': 20000, 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': '14', @@ -392,33 +394,83 @@ testfolder = os.path.join(os.path.dirname(__file__)) -# load custom samples -custom_samples_folder = os.path.join(testfolder, 'custom_samples') -pattern_field_name_type = [ - (r'sr=(\d+)', 'samplerate', int), - (r'dn=(\d+)', 'disc', str), - (r'dt=(\d+)', 'disc_total', str), - (r'd=(\d+.?\d*)', 'duration', float), - (r'b=(\d+)', 'bitrate', int), - (r'c=(\d)', 'channels', int), - (r'genre="([^"]+)"', 'genre', str), -] -for filename in os.listdir(custom_samples_folder): - if filename == 'instructions.txt': - continue - if os.path.isdir(os.path.join(custom_samples_folder, filename)): - continue - expected_values = {} - for pattern, fieldname, _type in pattern_field_name_type: - match = re.findall(pattern, filename) - if match: - expected_values[fieldname] = _type(match[0]) - if expected_values: - expected_values['__do_not_require_all_values'] = True - testfiles[os.path.join('custom_samples', filename)] = expected_values - else: - # if there are no expected values, just try parsing the file - testfiles[os.path.join('custom_samples', filename)] = {} + +def load_custom_samples(): + retval = {} + custom_samples_folder = os.path.join(testfolder, 'custom_samples') + pattern_field_name_type = [ + (r'sr=(\d+)', 'samplerate', int), + (r'dn=(\d+)', 'disc', str), + (r'dt=(\d+)', 'disc_total', str), + (r'd=(\d+.?\d*)', 'duration', float), + (r'b=(\d+)', 'bitrate', int), + (r'c=(\d)', 'channels', int), + (r'genre="([^"]+)"', 'genre', str), + ] + for filename in os.listdir(custom_samples_folder): + if filename == 'instructions.txt': + continue + if os.path.isdir(os.path.join(custom_samples_folder, filename)): + continue + expected_values = {} + for pattern, fieldname, _type in pattern_field_name_type: + match = re.findall(pattern, filename) + if match: + expected_values[fieldname] = _type(match[0]) + if expected_values: + expected_values['_do_not_require_all_values'] = True + retval[os.path.join('custom_samples', filename)] = expected_values + else: + # if there are no expected values, just try parsing the file + retval[os.path.join('custom_samples', filename)] = {} + return retval + + +testfiles.update(load_custom_samples()) + + +def almost_equal_float(val1, val2): + # allow duration to be off by 100 ms and a maximum of 1% + if val1 == val2: + return True + if abs(val1 - val2) < 0.100: + if val2 and min(val1, val2) / max(val1, val2) > 0.99: + return True + return False + + +def startswith(val1, val2): + return val1.startswith(val2) + + +def error_fmt(value): + return '%s (%s)' % (repr(value), type(value)) + + +def compare(results, expected, file, prev_path=None): + assert isinstance(results, dict) + missing_keys = set(expected.keys()) - set(results) + assert not missing_keys, 'Missing data in fixture \n%s' % str(missing_keys) + + for key, result_val in results.items(): + path = prev_path + '.' + key if prev_path else key + try: + expected_val = expected[key] + except KeyError: + assert False, 'Missing field "%s": "%s" in fixture "%s"!' % ( + key, error_fmt(result_val), file) + # recurse if the result and expected values are a dict: + if isinstance(result_val, dict) and isinstance(expected_val, dict): + compare(result_val, expected_val, file, prev_path=key) + else: + fmt_string = 'field "%s": got %s expected %s in %s!' + fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) + op = operator.eq + if path == 'duration': # allow duration to be off by 100 ms and a maximum of 1% + op = almost_equal_float + if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture + op = startswith + assert op(result_val, expected_val), fmt_string % fmt_values @pytest.mark.parametrize("testfile,expected", [ @@ -427,33 +479,11 @@ def test_file_reading(testfile, expected): filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename) - - for key, expected_val in expected.items(): - if key.startswith('__'): - continue - result = getattr(tag, key) - fmt_string = 'field "%s": got %s (%s) expected %s (%s)!' - fmt_values = (key, repr(result), type(result), repr(expected_val), type(expected_val)) - if key == 'duration' and result is not None and expected_val is not None: - # allow duration to be off by 100 ms and a maximum of 1% - if abs(result - expected_val) < 0.100: - if expected_val and min(result, expected_val) / max(result, expected_val) > 0.99: - continue - assert result == expected_val, fmt_string % fmt_values - # for custom samples, allow not specifying all values - if expected.get('_do_not_require_all_values'): - return - undefined_in_fixture = {} - for key, val in tag.__dict__.items(): - if key.startswith('_') or val is None: - continue - if key not in expected: - undefined_in_fixture[key] = val - assert not undefined_in_fixture, 'Missing data in fixture \n%s' % str(undefined_in_fixture) -# -# def test_generator(): -# for testfile, expected in testfiles.items(): -# yield get_info, testfile, expected + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } + compare(results, expected, filename) def test_pathlib_compatibility(): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 738d502..4150480 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -401,18 +401,29 @@ def debug_atom(cls, data): # callables return {fieldname: value} which is updates the TinyTag. META_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { # see: http://atomicparsley.sourceforge.net/mpeg-4files.html - b'\xa9alb': {b'data': Parser.make_data_atom_parser('album')}, + # and: https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 b'\xa9ART': {b'data': Parser.make_data_atom_parser('artist')}, - b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, - # b'cpil': {b'data': Parser.make_data_atom_parser('compilation')}, + b'\xa9alb': {b'data': Parser.make_data_atom_parser('album')}, b'\xa9cmt': {b'data': Parser.make_data_atom_parser('comment')}, - b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, - b'\xa9wrt': {b'data': Parser.make_data_atom_parser('composer')}, + # need test-data for this + # b'cpil': {b'data': Parser.make_data_atom_parser('extra.compilation')}, b'\xa9day': {b'data': Parser.make_data_atom_parser('year')}, + # need test-data for this + # b'\xa9des': {b'data': Parser.make_data_atom_parser('description')}, b'\xa9gen': {b'data': Parser.make_data_atom_parser('genre')}, - b'gnre': {b'data': Parser.parse_id3v1_genre}, + b'\xa9lyr': {b'data': Parser.make_data_atom_parser('extra.lyrics')}, + b'\xa9mvn': {b'data': Parser.make_data_atom_parser('movement')}, b'\xa9nam': {b'data': Parser.make_data_atom_parser('title')}, + b'\xa9wrt': {b'data': Parser.make_data_atom_parser('composer')}, + b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, + b'cprt': {b'data': Parser.make_data_atom_parser('extra.copyright')}, + # need test-data for this + # b'desc': {b'data': Parser.make_data_atom_parser('extra.description')}, + b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, + b'gnre': {b'data': Parser.parse_id3v1_genre}, b'trkn': {b'data': Parser.make_number_parser('track', 'track_total')}, + # need test-data for this + # b'tmpo': {b'data': Parser.make_data_atom_parser('extra.bmp')}, }}}}} # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html From 1facb489f473469371d8264633e82aea485d8c45 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 30 Sep 2022 14:59:25 +0300 Subject: [PATCH 057/305] README.md: Replace outdated Travis CI badge (#160) * README.md: Replace outdated Travis CI badge * Remove Travis CI build configuration * setup.cfg: Remove reference to Travis CI --- .travis.yml | 32 -------------------------------- README.md | 2 +- setup.cfg | 1 - 3 files changed, 1 insertion(+), 34 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c6cab09..0000000 --- a/.travis.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Continuous Integration config -# travis-ci.org -# -# see http://about.travis-ci.org/docs/user/build-configuration/ -# - -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "pypy" - -# command to install dependencies -install: - - if [[ $TRAVIS_PYTHON_VERSION != 'pypy' ]]; then pip install coveralls; fi - - "pip install pytest" - - "pip install pytest-cov" - -# workaround for pypy not working anymore in travis -env: - - DEBUG=1 CRYPTOGRAPHY_ALLOW_OPENSSL_102=1 - -# command to run tests -script: - pytest --cov=tinytag --cov-branch --cov-report xml:test-results/coverage.xml --junitxml test-results/junit.xml - -after_success: - - if [[ $TRAVIS_PYTHON_VERSION != 'pypy' ]]; then coveralls; fi diff --git a/README.md b/README.md index 8b5e610..af6c5f6 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ tinytag tinytag is a library for reading music meta data of most common audio files in pure python -[![Build Status](https://travis-ci.org/devsnd/tinytag.png?branch=master)](https://travis-ci.org/devsnd/tinytag) +[![Build Status](https://github.com/devsnd/tinytag/actions/workflows/tests.yml/badge.svg)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22) [![Build status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) [![Coverage Status](https://coveralls.io/repos/devsnd/tinytag/badge.svg)](https://coveralls.io/r/devsnd/tinytag) [![PyPI version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) diff --git a/setup.cfg b/setup.cfg index 7d202e9..594102a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -54,7 +54,6 @@ exclude = .git,__pycache__,.eggs/,doc/,docs/,build/,dist/,archive/,src/ [coverage:run] cover_pylib = false omit = - /home/travis/virtualenv/* */site-packages/* */bin/* */src/* From be0d898264a240a303e3a824b32a7bccccd70eed Mon Sep 17 00:00:00 2001 From: Isaac Lyons <51010664+snowskeleton@users.noreply.github.com> Date: Wed, 26 Oct 2022 04:56:06 -0400 Subject: [PATCH 058/305] Add recognition of Audible formats (#163) * Add recognition of Audible formats Audible has two proprietary mp4 containers, aax and aaxc. They're standard in every way, except the audio is encrypted. Metadata can still be read. * Full comprehension of aax(c) --- README.md | 2 +- tinytag/tinytag.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index af6c5f6..f74b85e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Features: * OPUS * FLAC * WMA - * MP4/M4A/M4B/M4R/M4V/ALAC + * MP4/M4A/M4B/M4R/M4V/ALAC/AAX/AAXC * AIFF/AIFF-C * pure python, no dependencies * supports python 2.7 and 3.4 or higher diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4150480..b680995 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -131,7 +131,7 @@ def _get_parser_for_filename(cls, filename): (b'.wav',): Wave, (b'.flac',): Flac, (b'.wma',): Wma, - (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4'): MP4, + (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): MP4, (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, } if not isinstance(filename, bytes): # convert filename to binary @@ -151,6 +151,8 @@ def _get_parser_for_file_handle(cls, fh): b'^fLaC': Flac, b'^\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': Wma, b'....ftypM4A': MP4, # https://www.file-recovery.com/m4a-signature-format.htm + b'....ftypaax': MP4, # Audible proprietary M4A container + b'....ftypaaxc': MP4, # Audible proprietary M4A container b'\xff\xf1': MP4, # https://www.garykessler.net/library/file_sigs.html b'^FORM....AIFF': Aiff, b'^FORM....AIFC': Aiff, From 96e0f6e912895aed73d3b73a97680de57e905672 Mon Sep 17 00:00:00 2001 From: Adhith Chand Thiruvath <46568705+Pseurae@users.noreply.github.com> Date: Fri, 4 Nov 2022 16:50:14 +0530 Subject: [PATCH 059/305] Parse OGG cover art (#144) * Ogg: Parse METADATA_BLOCK_PICTURE * Add test for ogg file with image Co-authored-by: Mat --- tinytag/tests/samples/ogg_with_image.ogg | Bin 0 -> 5838 bytes tinytag/tests/test_all.py | 10 +++++++ tinytag/tinytag.py | 36 +++++++++++++++-------- 3 files changed, 34 insertions(+), 12 deletions(-) create mode 100644 tinytag/tests/samples/ogg_with_image.ogg diff --git a/tinytag/tests/samples/ogg_with_image.ogg b/tinytag/tests/samples/ogg_with_image.ogg new file mode 100644 index 0000000000000000000000000000000000000000..79b16f9e6d623e46d468fe60c21e4f0ebe14dd04 GIT binary patch literal 5838 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 F^uPXNB9H(8 literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 4f12906..cf0c7cc 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -658,6 +658,16 @@ def test_flac_image_loading(): 'header') +def test_ogg_image_loading(): + tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + image_data = tag.get_image() + assert image_data is not None + assert 1000 < len(image_data) < 2000, ('Image is %d bytes but should be around 1.2kb' % + len(image_data)) + assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' + 'header') + + def test_aiff_image_loading(): tag = TinyTag.get(os.path.join(testfolder, 'samples/test_with_image.aiff'), image=True) image_data = tag.get_image() diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b680995..03adfb9 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -40,6 +40,7 @@ from functools import reduce from io import BytesIO import aifc +import base64 import codecs import io import json @@ -945,11 +946,18 @@ def _parse_vorbis_comment(self, fh): continue if '=' in keyvalpair: key, value = keyvalpair.split('=', 1) - if DEBUG: - stderr('Found Vorbis Comment', key, value[:64]) - fieldname = comment_type_to_attr_mapping.get(key.lower()) - if fieldname: - self._set_field(fieldname, value) + key_lowercase = key.lower() + + if key_lowercase == "metadata_block_picture" and self._load_image: + if DEBUG: + stderr('Found Vorbis Image', key, value[:64]) + self._image_data = Flac._parse_image(BytesIO(base64.b64decode(value))) + else: + if DEBUG: + stderr('Found Vorbis Comment', key, value[:64]) + fieldname = comment_type_to_attr_mapping.get(key_lowercase) + if fieldname: + self._set_field(fieldname, value) def _parse_pages(self, fh): # for the spec, see: https://wiki.xiph.org/Ogg @@ -1126,13 +1134,7 @@ def _determine_duration(self, fh): oggtag._parse_vorbis_comment(fh) self.update(oggtag) elif block_type == Flac.METADATA_PICTURE and self._load_image: - # https://xiph.org/flac/format.html#metadata_block_picture - pic_type, mime_len = struct.unpack('>2I', fh.read(8)) - fh.read(mime_len) - description_len = struct.unpack('>I', fh.read(4))[0] - fh.read(description_len) - width, height, depth, colors, pic_len = struct.unpack('>5I', fh.read(20)) - self._image_data = fh.read(pic_len) + self._image_data = self._parse_image(fh) elif block_type >= 127: return # invalid block type else: @@ -1144,6 +1146,16 @@ def _determine_duration(self, fh): return header_data = fh.read(4) + @staticmethod + def _parse_image(fh): + # https://xiph.org/flac/format.html#metadata_block_picture + pic_type, mime_len = struct.unpack('>2I', fh.read(8)) + fh.read(mime_len) + description_len = struct.unpack('>I', fh.read(4))[0] + fh.read(description_len) + width, height, depth, colors, pic_len = struct.unpack('>5I', fh.read(20)) + return fh.read(pic_len) + class Wma(TinyTag): ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' From 34bcdc79c9c7580df964c45cafd840f78d1cd3aa Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 4 Nov 2022 17:46:34 +0200 Subject: [PATCH 060/305] Wave: Add proper support for padded IFF chunks --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 03adfb9..e8d7d0e 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1020,6 +1020,7 @@ def _determine_duration(self, fh): chunk_header = fh.read(8) while len(chunk_header) == 8: subchunkid, subchunksize = struct.unpack('4sI', chunk_header) + subchunksize += subchunksize % 2 # IFF chunks are padded to an even number of bytes if subchunkid == b'fmt ': _, self.channels, self.samplerate = struct.unpack('HHI', fh.read(8)) _, _, self.bitdepth = struct.unpack(' Date: Fri, 4 Nov 2022 17:53:10 +0200 Subject: [PATCH 061/305] Satisfy flake8 --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e8d7d0e..6a08e71 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1045,7 +1045,7 @@ def _determine_duration(self, fh): field = sub_fh.read(4) while len(field) == 4: data_length = struct.unpack('I', sub_fh.read(4))[0] - data_length += data_length % 2 # IFF chunks are padded to an even number of bytes + data_length += data_length % 2 # IFF chunks are padded to an even size data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte fieldname = self.riff_mapping.get(field) if fieldname: From 6dd710c83bd300bd3a1abd984cab32e80c0777b7 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 3 Jan 2023 00:44:10 +0200 Subject: [PATCH 062/305] tests.yml: Use Ubuntu 20.04 for Python 3.5/3.6 support --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9fd2793..8a37fb0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-20.04, macos-latest, windows-latest] python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] exclude: - os: macos-latest From ff086ef2252148ccebdc266dbf4aee99fa4dff7b Mon Sep 17 00:00:00 2001 From: Isaac Lyons <51010664+snowskeleton@users.noreply.github.com> Date: Mon, 2 Jan 2023 18:12:38 -0500 Subject: [PATCH 063/305] Ignore vscode config files (#169) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0524d01..aaa81a0 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,8 @@ test-results/ .venv venv +# Visual Studio Code +.vscode + # custom test samples tinytag/tests/custom_samples From 0ec78aa4c625ece34107101bee868336a7de82a7 Mon Sep 17 00:00:00 2001 From: Isaac Lyons <51010664+snowskeleton@users.noreply.github.com> Date: Sat, 14 Jan 2023 23:14:11 -0500 Subject: [PATCH 064/305] Add description fields (#168) * found sample files to test description fields * remove `description` attribute and mapped `\xa9des` to `extra.description` * sample files for testing * second `desc` file * add test entries for `mpeg4_desc_cmt.mp4` and `mpeg4_xa9des.mp4` --- tinytag/tests/samples/mpeg4_desc_cmt.m4a | Bin 0 -> 32006 bytes tinytag/tests/samples/mpeg4_xa9des.m4a | Bin 0 -> 2639 bytes tinytag/tests/test_all.py | 14 +++++++++++++- tinytag/tinytag.py | 6 ++---- 4 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 tinytag/tests/samples/mpeg4_desc_cmt.m4a create mode 100644 tinytag/tests/samples/mpeg4_xa9des.m4a diff --git a/tinytag/tests/samples/mpeg4_desc_cmt.m4a b/tinytag/tests/samples/mpeg4_desc_cmt.m4a new file mode 100644 index 0000000000000000000000000000000000000000..026de1d152a88dbae40396770f2e8dcc9ac5517b GIT binary patch literal 32006 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 Date: Mon, 20 Mar 2023 23:29:07 +0200 Subject: [PATCH 065/305] tests.yml: fix Coveralls OS check --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a37fb0..3e3141f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,12 +36,12 @@ jobs: DEBUG: true - name: Coverage report - if: matrix.os == 'ubuntu-latest' && matrix.python == '3.10' + if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.10' run: coverage lcov - name: Coveralls uses: coverallsapp/github-action@master - if: matrix.os == 'ubuntu-latest' && matrix.python == '3.10' + if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.10' with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov From 68c54096a8d7ce79f9a45d8874b337b2ebdf176a Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 20 Mar 2023 23:31:18 +0200 Subject: [PATCH 066/305] tests.yml: test Python 3.11 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3e3141f..fefb7f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] + python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] exclude: - os: macos-latest python: 'pypy-3.6' # Not installable @@ -36,12 +36,12 @@ jobs: DEBUG: true - name: Coverage report - if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.10' + if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.11' run: coverage lcov - name: Coveralls uses: coverallsapp/github-action@master - if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.10' + if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.11' with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov From 18b92b04d94ecaba3fc69353492f8508486aab62 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 20 Mar 2023 23:38:43 +0200 Subject: [PATCH 067/305] tests.yml: upgrade dependencies --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fefb7f8..4c01d6d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,12 +15,12 @@ jobs: python: 'pypy-3.6' # Not installable steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} From 40668ee30a01a34b18b5b622c88538c604f5d744 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 20 Mar 2023 23:44:48 +0200 Subject: [PATCH 068/305] tests.yml: stop testing PyPy 3.6 --- .github/workflows/tests.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4c01d6d..ce2e491 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,10 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-2.7', 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] - exclude: - - os: macos-latest - python: 'pypy-3.6' # Not installable + python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] steps: - name: Checkout code uses: actions/checkout@v3 From 85a16fa18bcc534d1d61f10ca539955823945d5d Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 20 Mar 2023 23:47:13 +0200 Subject: [PATCH 069/305] Implement replacement for Python's aifc module (#164) Closes #158 --- .../tests/samples/invalid_sample_rate.aiff | Bin 0 -> 4096 bytes tinytag/tests/test_all.py | 2 + tinytag/tinytag.py | 125 ++++++++---------- 3 files changed, 57 insertions(+), 70 deletions(-) create mode 100644 tinytag/tests/samples/invalid_sample_rate.aiff diff --git a/tinytag/tests/samples/invalid_sample_rate.aiff b/tinytag/tests/samples/invalid_sample_rate.aiff new file mode 100644 index 0000000000000000000000000000000000000000..da088da51e1196bba58175572d95829ba897324b GIT binary patch literal 4096 zcmZ?s5AtPTa5~`V>E`C_?+auz2r)1+FvvSF2>fDT03r|w4)%jEM#<3-7!3g;L*PFI zyd+Zj$Wq2Pvb>AzTLEOwD0?V{zzgP~4sI4s', fh.read(12)) + if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): raise TinyTagException('not an aiff file!') - - while True: - try: - chunk = Chunk(fh) - except EOFError: - break - - chunkname = chunk.getname() - if chunkname == b'NAME': - # "Name Chunk text contains the name of the sampled sound." - self.title = self._unpad(chunk.read().decode('utf-8')) - elif chunkname == b'AUTH': - # "Author Chunk text contains one or more author names. An author in - # this case is the creator of a sampled sound." - self.artist = self._unpad(chunk.read().decode('utf-8')) - elif chunkname == b'ANNO': - # "Annotation Chunk text contains a comment. Use of this chunk is - # discouraged within FORM AIFC." Some tools: "hold my beer" - self._set_field('comment', self._unpad(chunk.read().decode('utf-8'))) - elif chunkname == b'(c) ': - # "The Copyright Chunk contains a copyright notice for the sound. text - # contains a date followed by the copyright owner. The chunk ID '[c] ' - # serves as the copyright character. " Some tools: "hold my beer" - field = chunk.read().decode('utf-8') - self._set_field('extra.copyright', field) - elif chunkname == b'ID3 ': - super(Aiff, self)._parse_tag(fh) - elif chunkname == b'SSND': - # probably the closest equivalent, but this isn't particular viable - # for AIFF + chunk_header = fh.read(8) + while len(chunk_header) == 8: + sub_chunk_id, sub_chunk_size = struct.unpack('>4sI', chunk_header) + sub_chunk_size += sub_chunk_size % 2 # IFF chunks are padded to an even number of bytes + if sub_chunk_id in self.aiff_mapping and self._parse_tags: + value = self._unpad(fh.read(sub_chunk_size).decode('utf-8')) + self._set_field(self.aiff_mapping[sub_chunk_id], value) + elif sub_chunk_id == b'COMM': + self.channels, num_frames, self.bitdepth = struct.unpack('>hLh', fh.read(8)) + try: + exponent, mantissa = struct.unpack('>HQ', fh.read(10)) # Extended precision + self.samplerate = int(mantissa * (2 ** (exponent - 0x3FFF - 63))) + self.duration = num_frames / self.samplerate + self.bitrate = self.samplerate * self.channels * self.bitdepth / 1000 + except OverflowError: + self.samplerate = self.duration = self.bitrate = None # invalid sample rate + fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk + elif sub_chunk_id in (b'id3 ', b'ID3 ') and self._parse_tags: + ID3._parse_tag(self, fh) + elif sub_chunk_id == b'SSND': self.audio_offset = fh.tell() - chunk.skip() - else: - chunk.skip() + fh.seek(sub_chunk_size, 1) + else: # some other chunk, just skip the data + fh.seek(sub_chunk_size, 1) + chunk_header = fh.read(8) + self._tags_parsed = True + + def _determine_duration(self, fh): + if not self._tags_parsed: + self._parse_tag(fh) From 07be6e5dbb320f60cabc455ab4b9cedc37e64be0 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 21 Mar 2023 00:35:13 +0200 Subject: [PATCH 070/305] Vorbis: support standard disctotal/tracktotal comments (#171) Fixes #170 --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 8f4421c..0b70517 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -230,7 +230,7 @@ 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', - 'comment': 'ARCD0018 - Lovelight'}), + 'comment': 'ARCD0018 - Lovelight', 'disc_total': '1', 'track_total': '13'}), ('samples/8khz_5s.opus', {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a7a8a21..8ba376d 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -924,8 +924,10 @@ def _parse_vorbis_comment(self, fh): 'artist': 'artist', 'date': 'year', 'tracknumber': 'track', + 'tracktotal': 'track_total', 'totaltracks': 'track_total', 'discnumber': 'disc', + 'disctotal': 'disc_total', 'totaldiscs': 'disc_total', 'genre': 'genre', 'description': 'comment', From 27b26ce25cfd44b6fb7c6da82177d725c07a2ab9 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 21 Mar 2023 00:48:15 +0200 Subject: [PATCH 071/305] Update changelog --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index f74b85e..b097d07 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') Changelog: + * 1.9.0 (unreleased) + - Add bitdepth attribute for lossless audio #157 + - Add recognition of Audible formats #163 (thanks to snowskeleton) + - Add .m4v to list of supported file extensions #142 + - Aiff: Implement replacement for Python's aifc module #164 + - ID3: Only check for language in COMM and USLT frames #147 + - ID3: Read the correct number of bytes from Xing header #154 + - ID3: Add support for ID3v2.4 TDRC frame #156 (thanks to Uninen) + - M4A: Add description fields #168 (thanks to snowskeleton) + - RIFF: Handle tags containing extra zero-byte #141 + - Vorbis: Parse OGG cover art #144 (thanks to Pseurae) + - Vorbis: Support standard disctotal/tracktotal comments #171 + - Wave: Add proper support for padded IFF chunks * 1.8.1 (2022-03-12) [still mathiascode-edition] - MP3 ID3: Set correct file position if tag reading is disabled #119 (thanks to mathiascode) - MP3: Fix incorrect calculation of duration for VBR encoded MP3s #128 (thanks to mathiascode) From be3e3bab83a7e4e04b3b534f721cd4e33aa6ef90 Mon Sep 17 00:00:00 2001 From: Kian-Meng Ang Date: Mon, 17 Apr 2023 01:53:11 +0800 Subject: [PATCH 072/305] Fix typos (#173) * Fix typos Found via `codespell` * Update tinytag/tinytag.py Co-authored-by: Mat --- README.md | 2 +- tinytag/tests/test_cli.py | 6 +++--- tinytag/tinytag.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b097d07..2ca2b05 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ For non-common fields and fields specific to single file formats use extra The `extra` dict currently *may* contain the following data: `url`, `isrc`, `text`, `initial_key`, `lyrics`, `copyright` -Aditionally you can also get cover images from ID3 tags: +Additionally you can also get cover images from ID3 tags: tag = TinyTag.get('/some/music.mp3', image=True) image_data = tag.get_image() diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 8e3d3df..0b99d36 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -38,7 +38,7 @@ def test_print_help(): @pytest.mark.skipif(sys.platform == "win32", - reason="NamedTemporaryFile cant be reopened on windows") + reason="NamedTemporaryFile can't be reopened on windows") def test_save_image_long_opt(): temp_file = NamedTemporaryFile() assert file_size(temp_file.name) == 0 @@ -51,7 +51,7 @@ def test_save_image_long_opt(): @pytest.mark.skipif(sys.platform == "win32", - reason="NamedTemporaryFile cant be reopened on windows") + reason="NamedTemporaryFile can't be reopened on windows") def test_save_image_short_opt(): temp_file = NamedTemporaryFile() assert file_size(temp_file.name) == 0 @@ -60,7 +60,7 @@ def test_save_image_short_opt(): @pytest.mark.skipif(sys.platform == "win32", - reason="NamedTemporaryFile cant be reopened on windows") + reason="NamedTemporaryFile can't be reopened on windows") def test_save_image_bulk(): temp_file = NamedTemporaryFile(suffix='.jpg') temp_file_no_ext = temp_file.name[:-4] diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 8ba376d..e9f6f8e 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -563,7 +563,7 @@ class ID3(TinyTag): def __init__(self, filehandler, filesize, *args, **kwargs): TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) - # save position after the ID3 tag for duration mesurement speedup + # save position after the ID3 tag for duration measurement speedup self._bytepos_after_id3v2 = None @classmethod @@ -1100,7 +1100,7 @@ def _determine_duration(self, fh): if len(stream_info_header) < 34: # invalid streaminfo return header = struct.unpack('HH3s3s8B16s', stream_info_header) - # From the ciph documentation: + # From the xiph documentation: # py | # ---------------------------------------------- # H | <16> The minimum block size (in samples) From 9a6682510bbddd37e65e3b9fbba3a3eb84289cfe Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 21 Apr 2023 13:00:51 +0200 Subject: [PATCH 073/305] 1.9.0 release --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2ca2b05..3efbabf 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') Changelog: - * 1.9.0 (unreleased) + * 1.9.0 (2023-04-23) - Add bitdepth attribute for lossless audio #157 - Add recognition of Audible formats #163 (thanks to snowskeleton) - Add .m4v to list of supported file extensions #142 From 6aae5362f92d2a452c34642814ba995ad4e36efc Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 21 Apr 2023 13:03:03 +0200 Subject: [PATCH 074/305] bumped version to 1.9.0 --- tinytag/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 1009f2a..dfac44c 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,7 +4,7 @@ from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 -__version__ = '1.8.1' +__version__ = '1.9.0' if __name__ == '__main__': From b584698bf690425cbfdd9f7b2cef7c8629f3e4db Mon Sep 17 00:00:00 2001 From: Tom Wallroth Date: Fri, 21 Apr 2023 13:20:32 +0200 Subject: [PATCH 075/305] added Makefile used to release new tinytag version --- Makefile | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..652a51f --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +LATEST := $(shell bash -c "find dist | sort -V -r | head -n 1") + +.PHONY : all +all: upload + +test: + pytest + +assure_tag_is_version: + bash -c 'grep `git tag | sort -V | tail -1` tinytag/__init__.py' || (echo "git version is not the same as version in __init__.py"; exit 1) + +buildpkg: assure_tag_is_version test + python ./setup.py sdist + +upload: buildpkg + bash -c 'twine upload -r pypi `find dist | sort -V -r | head -n 1`' + From b516e439bb86d478d6196776437ec7d8062353b2 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 19:45:43 +0300 Subject: [PATCH 076/305] Fix deprecations related to setuptools (#176) --- setup.cfg | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/setup.cfg b/setup.cfg index 594102a..1367c88 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,7 +1,7 @@ [metadata] name = tinytag author = Tom Wallroth -author-email = tomwallroth@gmail.com +author_email = tomwallroth@gmail.com url = https://github.com/devsnd/tinytag description = Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files keywords = @@ -25,15 +25,12 @@ classifiers = Topic :: Multimedia :: Sound/Audio Topic :: Multimedia :: Sound/Audio :: Analysis license = MIT -license-file = LICENSE -long-description = file: README.md -long-description-content-type = text/markdown +license_files = LICENSE +long_description = file: README.md +long_description_content_type = text/markdown [options] python_requires = >= 2.7 -setup_requires = - setuptools >= 38.6 - pip >= 10 include_package_data = True packages = find: install_requires = From 14e4d76162e1f68d7d8304593c88b7401096a43e Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 19:54:01 +0300 Subject: [PATCH 077/305] Add list of supported file extensions (#177) Closes #167 --- tinytag/tinytag.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e9f6f8e..a3f1305 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -77,6 +77,14 @@ def _bytes_to_int(b): class TinyTag(object): + SUPPORTED_FILE_EXTENSIONS = [ + '.mp1', '.mp2', '.mp3', + '.oga', '.ogg', '.opus', + '.wav', '.flac', '.wma', + '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', + '.aiff', '.aifc', '.aif', '.afc' + ] + def __init__(self, filehandler, filesize, ignore_errors=False): # This is required for compatibility between python2 and python3 # in python2 there is a difference between `str` and `unicode` From add818300f0e791117e15b42eab32c5f4790525b Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 20:01:48 +0300 Subject: [PATCH 078/305] Update changelog --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 3efbabf..08cb6ab 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,9 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') Changelog: + * 1.9.1 (unreleased) + - Fix deprecations related to setuptools #176 + - Add list of supported file extensions #177 * 1.9.0 (2023-04-23) - Add bitdepth attribute for lossless audio #157 - Add recognition of Audible formats #163 (thanks to snowskeleton) From aba7afad2f57ab76d24dfef047227bbc977d8b35 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 20:12:23 +0300 Subject: [PATCH 079/305] README.md: document SUPPORTED_FILE_EXTENSIONS constant --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 08cb6ab..885eabb 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ Alternatively you can use tinytag directly on the command line: Check `python -m tinytag --help` for all CLI options, for example other output formats +To receive a list of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: + + TinyTag.SUPPORTED_FILE_EXTENSIONS + +Alternatively, check if a file is supported: + + is_supported = TinyTag.is_supported('/some/music.mp3') + List of possible attributes you can get with TinyTag: tag.album # album as string From 465bc8f83687636024e5fec51c92cbee89288ed8 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 20:26:41 +0300 Subject: [PATCH 080/305] README.md: formatting improvements --- README.md | 257 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 150 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 885eabb..b920589 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -tinytag -======= +# tinytag -tinytag is a library for reading music meta data of most common audio files in pure python +tinytag is a library for reading music meta data of most common audio files in pure Python [![Build Status](https://github.com/devsnd/tinytag/actions/workflows/tests.yml/badge.svg)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22) [![Build status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) @@ -9,17 +8,16 @@ tinytag is a library for reading music meta data of most common audio files in p [![PyPI version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) [![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag.svg)](https://pypistats.org/packages/tinytag) -Install -------- + +## Install ```pip install tinytag``` -Features: ---------- +## Features * Read tags, length and cover images of audio files - * supported formats + * Supported formats: * MP3/MP2/MP1 (ID3 v1, v1.1, v2.2, v2.3+) * Wave/RIFF * OGG @@ -28,9 +26,9 @@ Features: * WMA * MP4/M4A/M4B/M4R/M4V/ALAC/AAX/AAXC * AIFF/AIFF-C - * pure python, no dependencies - * supports python 2.7 and 3.4 or higher - * high test coverage + * Pure Python, no dependencies + * Supports Python 2.7 and 3.4 or higher + * High test coverage * Just a few hundred lines of code (just include it in your project!) tinytag only provides the minimum needed for _reading_ meta-data. @@ -46,7 +44,7 @@ Alternatively you can use tinytag directly on the command line: $ python -m tinytag --format csv /some/music.mp3 > {"filename": "/some/music.mp3", "filesize": 30212227, "album": "Album", "albumartist": "Artist", "artist": "Artist", "audio_offset": null, "bitrate": 256, "channels": 2, "comment": null, "composer": null, "disc": "1", "disc_total": null, "duration": 10, "genre": null, "samplerate": 44100, "title": "Title", "track": "5", "track_total": null, "year": "2012"} -Check `python -m tinytag --help` for all CLI options, for example other output formats +Check `python -m tinytag --help` for all CLI options, for example other output formats. To receive a list of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: @@ -77,7 +75,7 @@ List of possible attributes you can get with TinyTag: tag.track_total # total number of tracks as string tag.year # year or date as string -For non-common fields and fields specific to single file formats use extra +For non-common fields and fields specific to single file formats, use `extra`: tag.extra # a dict of additional data @@ -95,97 +93,142 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') -Changelog: - * 1.9.1 (unreleased) - - Fix deprecations related to setuptools #176 - - Add list of supported file extensions #177 - * 1.9.0 (2023-04-23) - - Add bitdepth attribute for lossless audio #157 - - Add recognition of Audible formats #163 (thanks to snowskeleton) - - Add .m4v to list of supported file extensions #142 - - Aiff: Implement replacement for Python's aifc module #164 - - ID3: Only check for language in COMM and USLT frames #147 - - ID3: Read the correct number of bytes from Xing header #154 - - ID3: Add support for ID3v2.4 TDRC frame #156 (thanks to Uninen) - - M4A: Add description fields #168 (thanks to snowskeleton) - - RIFF: Handle tags containing extra zero-byte #141 - - Vorbis: Parse OGG cover art #144 (thanks to Pseurae) - - Vorbis: Support standard disctotal/tracktotal comments #171 - - Wave: Add proper support for padded IFF chunks - * 1.8.1 (2022-03-12) [still mathiascode-edition] - - MP3 ID3: Set correct file position if tag reading is disabled #119 (thanks to mathiascode) - - MP3: Fix incorrect calculation of duration for VBR encoded MP3s #128 (thanks to mathiascode) - * 1.8.0 (2022-03-05) [mathiascode-edition] - - Add support for ALAC audio files #130 (thanks to mathiascode) - - AIFF: Fixed bitrate calculation for certain files #129 (thanks to mathiascode) - - MP3: Do not round MP3 bitrates #131 (thanks to mathiascode) - - MP3 ID3: Support any language in COMM and USLT frames #135 (thanks to mathiascode) - - Performance: Don't use regex when parsing genre #136 (thanks to mathiascode) - - Disable tag parsing for all formats when requested #137 (thanks to mathiascode) - - M4A: Fix invalid bitrates in certain files #132 (thanks to mathiascode) - - WAV: Fix metadata parsing for certain files #133 (thanks to mathiascode) - * 1.7.0. (2021-12-14) - - fixed rare occasion of ID3v2 tags missing their first character, #106 - - allow overriding the default encoding of ID3 tags (e.g. `TinyTag.get(..., encoding='gbk'))`) - - fixed calculation of bitrate for very short mp3 files, #99 - - utf-8 support for AIFF files, #123 - - fixed image parsing for id3v2 with images containing utf-16LE descriptions, #117 - - fixed ID3v1 tags overwriting ID3v2 tags, #121 - - Set correct file position if tag reading is disabled for ID3 (thanks to mathiascode) - * 1.6.0 (2021-08-28) [aw-edition]: - - fixed handling of non-latin encoding types for images (thanks to aw-was-here) - - added support for ISRC data, available in `extra['isrc']` field (thanks to aw-was-here) - - added support for AIFF/AIFF-C (thanks to aw-was-here) - - fixed import deprecation warnings (thanks to idotobi) - - fixed exception for TinyTag misuse being different in different python versions (thanks to idotobi) - - added support for ID3 initial key tonality hint, available in `extra['initial_key']` - - added support for ID3 unsynchronized lyrics, available in `extra['lyrics']` - - added `extra` field, which may contain additional metadata not available in all file formats - * 1.5.0 (2020-11-05): - - fixed data type to always return str for disc, disc_total, track, track_total #97 (thanks to kostalski) - - fixed package install being reported as UNKNOWN for some python/pip variations #90 (thanks to russpoutine) - - Added automatic detection for certain MP4 file headers - * 1.4.0 (2020-04-23): - - detecting file types based on their magic header bytes, #85 - - fixed opus duration being wrong for files with lower sample rate #81 - - implemented support for binary paths #72 - - always cast mp3 bitrates to int, so that CBR and VBR output behaves the sam - - made __str__ deterministic and use json as output format - * 1.3.0 (2020-03-09): - - added option to ignore encoding errors `ignore_errors` #73 - - Improved text decoding for many malformed files - * 1.2.2 (2019-04-13): - - Improved stability when reading corrupted mp3 files - * 1.2.1 (2019-04-13): - - fixed wav files not correctly reporting the number of channels #61 - * 1.2.0 (2019-04-13): - - using setup.cfg instead of setup.py (thanks to scivision) - - added support for calling TinyTag.get with pathlib.Path (thanks to scivision) - - added appveyor windows test CI (thanks to scivision) - - using pytest instead of nosetest (thanks to scivision) - * 1.1.0 (2019-04-13): - - added new field "composer" (Thanks to Phil Borman) - * 1.0.1 (2019-04-13): - - fixed ID3 loading for files with corrupt header (thanks to Ian Homer) - - fixed parsing of duration in wav file (thanks to Ian Homer) - * 1.0.0 (2018-12-12): - - added comment field - - added wav-riff format support - - use MP4 parser for m4b files - - added simple cli tool - - fix parsing of FLAC files with ID3 header (thanks to minus7) - - added method `TinyTag.is_supported(filename)` - * 0.19.0 (2018-02-11): - - fixed corrupted images for some mp3s (#45) - * 0.18.0 (2017-04-29): - - fixed wrong bitrate and crash when parsing xing header - * 0.17.0 (2016-10-02): - - supporting ID3v2.2 images - * 0.16.0 (2016-08-06): - - MP4 cover image support - * 0.15.2 (2016-08-06): - - fixed crash for malformed MP4 files (#34) - * 0.15.0 (2016-08-06): - - fixed decoding of UTF-16LE ID3v2 Tags, improved overall stability - * 0.14.0 (2016-06-05): - - MP4/M4A and Opus support + +## Changelog + +### 1.9.1 (unreleased) + +- Fix deprecations related to setuptools #176 +- Add list of supported file extensions #177 + +### 1.9.0 (2023-04-23) + +- Add bitdepth attribute for lossless audio #157 +- Add recognition of Audible formats #163 (thanks to snowskeleton) +- Add .m4v to list of supported file extensions #142 +- Aiff: Implement replacement for Python's aifc module #164 +- ID3: Only check for language in COMM and USLT frames #147 +- ID3: Read the correct number of bytes from Xing header #154 +- ID3: Add support for ID3v2.4 TDRC frame #156 (thanks to Uninen) +- M4A: Add description fields #168 (thanks to snowskeleton) +- RIFF: Handle tags containing extra zero-byte #141 +- Vorbis: Parse OGG cover art #144 (thanks to Pseurae) +- Vorbis: Support standard disctotal/tracktotal comments #171 +- Wave: Add proper support for padded IFF chunks + +### 1.8.1 (2022-03-12) [still mathiascode-edition] + +- MP3 ID3: Set correct file position if tag reading is disabled #119 (thanks to mathiascode) +- MP3: Fix incorrect calculation of duration for VBR encoded MP3s #128 (thanks to mathiascode) + +### 1.8.0 (2022-03-05) [mathiascode-edition] + +- Add support for ALAC audio files #130 (thanks to mathiascode) +- AIFF: Fixed bitrate calculation for certain files #129 (thanks to mathiascode) +- MP3: Do not round MP3 bitrates #131 (thanks to mathiascode) +- MP3 ID3: Support any language in COMM and USLT frames #135 (thanks to mathiascode) +- Performance: Don't use regex when parsing genre #136 (thanks to mathiascode) +- Disable tag parsing for all formats when requested #137 (thanks to mathiascode) +- M4A: Fix invalid bitrates in certain files #132 (thanks to mathiascode) +- WAV: Fix metadata parsing for certain files #133 (thanks to mathiascode) + +### 1.7.0. (2021-12-14) + +- fixed rare occasion of ID3v2 tags missing their first character, #106 +- allow overriding the default encoding of ID3 tags (e.g. `TinyTag.get(..., encoding='gbk'))`) +- fixed calculation of bitrate for very short mp3 files, #99 +- utf-8 support for AIFF files, #123 +- fixed image parsing for id3v2 with images containing utf-16LE descriptions, #117 +- fixed ID3v1 tags overwriting ID3v2 tags, #121 +- Set correct file position if tag reading is disabled for ID3 (thanks to mathiascode) + +### 1.6.0 (2021-08-28) [aw-edition] + +- fixed handling of non-latin encoding types for images (thanks to aw-was-here) +- added support for ISRC data, available in `extra['isrc']` field (thanks to aw-was-here) +- added support for AIFF/AIFF-C (thanks to aw-was-here) +- fixed import deprecation warnings (thanks to idotobi) +- fixed exception for TinyTag misuse being different in different python versions (thanks to idotobi) +- added support for ID3 initial key tonality hint, available in `extra['initial_key']` +- added support for ID3 unsynchronized lyrics, available in `extra['lyrics']` +- added `extra` field, which may contain additional metadata not available in all file formats + +### 1.5.0 (2020-11-05) + +- fixed data type to always return str for disc, disc_total, track, track_total #97 (thanks to kostalski) +- fixed package install being reported as UNKNOWN for some python/pip variations #90 (thanks to russpoutine) +- Added automatic detection for certain MP4 file headers + +### 1.4.0 (2020-04-23) + +- detecting file types based on their magic header bytes, #85 +- fixed opus duration being wrong for files with lower sample rate #81 +- implemented support for binary paths #72 +- always cast mp3 bitrates to int, so that CBR and VBR output behaves the sam +- made __str__ deterministic and use json as output format + +### 1.3.0 (2020-03-09) + +- added option to ignore encoding errors `ignore_errors` #73 +- Improved text decoding for many malformed files + +### 1.2.2 (2019-04-13) + +- Improved stability when reading corrupted mp3 files + +### 1.2.1 (2019-04-13) + +- fixed wav files not correctly reporting the number of channels #61 + +### 1.2.0 (2019-04-13) + +- using setup.cfg instead of setup.py (thanks to scivision) +- added support for calling TinyTag.get with pathlib.Path (thanks to scivision) +- added appveyor windows test CI (thanks to scivision) +- using pytest instead of nosetest (thanks to scivision) + +### 1.1.0 (2019-04-13) + +- added new field "composer" (Thanks to Phil Borman) + +### 1.0.1 (2019-04-13) + +- fixed ID3 loading for files with corrupt header (thanks to Ian Homer) +- fixed parsing of duration in wav file (thanks to Ian Homer) + +### 1.0.0 (2018-12-12) + +- added comment field +- added wav-riff format support +- use MP4 parser for m4b files +- added simple cli tool +- fix parsing of FLAC files with ID3 header (thanks to minus7) +- added method `TinyTag.is_supported(filename)` + +### 0.19.0 (2018-02-11) + +- fixed corrupted images for some mp3s (#45) + +### 0.18.0 (2017-04-29) + +- fixed wrong bitrate and crash when parsing xing header + +### 0.17.0 (2016-10-02) + +- supporting ID3v2.2 images + +### 0.16.0 (2016-08-06) + +- MP4 cover image support + +### 0.15.2 (2016-08-06) + +- fixed crash for malformed MP4 files (#34) + +### 0.15.0 (2016-08-06) + +- fixed decoding of UTF-16LE ID3v2 Tags, improved overall stability + +### 0.14.0 (2016-06-05): + +- MP4/M4A and Opus support From dd19c7d3b3301e7cb4621827f593fd041b692768 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 20:43:35 +0300 Subject: [PATCH 081/305] tests.yml: cache pip packages --- .github/workflows/tests.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce2e491..6627ab2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,9 +20,11 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} + cache: 'pip' + cache-dependency-path: setup.cfg - name: Install dependencies - run: python -m pip install flake8 pytest pytest-cov + run: python -m pip install -e ".[tests]" - name: Flake8 linter run: python -m flake8 From 72435f18a88ed1ed8e360b35dccd0cf23d74dec6 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 21:06:02 +0300 Subject: [PATCH 082/305] tests.yml: revert extras_require dependency installation --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6627ab2..d40d312 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: cache-dependency-path: setup.cfg - name: Install dependencies - run: python -m pip install -e ".[tests]" + run: python -m pip install flake8 pytest pytest-cov - name: Flake8 linter run: python -m flake8 From f9f7d0f23e60395be87957d21b200ed9e7a731c9 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 21:13:05 +0300 Subject: [PATCH 083/305] Remove runtests.py It's not used anywhere, and you can just run 'pytest' directly. --- runtests.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100755 runtests.py diff --git a/runtests.py b/runtests.py deleted file mode 100755 index 85a5ba2..0000000 --- a/runtests.py +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env python -import pytest - - -def runtests(): - pytest.main() - - -if __name__ == '__main__': - runtests() From d67e830673a787a46400df281b06d9eca9d82537 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 21:18:46 +0300 Subject: [PATCH 084/305] Revert "tests.yml: cache pip packages" This reverts commit dd19c7d3b3301e7cb4621827f593fd041b692768. Will revisit this in tinytag 2.0.0. --- .github/workflows/tests.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d40d312..ce2e491 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -20,8 +20,6 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} - cache: 'pip' - cache-dependency-path: setup.cfg - name: Install dependencies run: python -m pip install flake8 pytest pytest-cov From 337c044ce51f9e707b8cd31d02896c9041a98afa Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 2 Jun 2023 21:23:10 +0300 Subject: [PATCH 085/305] tests.yml: stop testing Python 2.7 Python 2.7 will not be supported by GitHub Actions any longer: https://github.com/actions/setup-python/issues/672 We can continue using PyPy 2.7 to test Python 2 until we drop support in tinytag 2.0.0. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce2e491..5867675 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['2.7', '3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] + python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] steps: - name: Checkout code uses: actions/checkout@v3 From b882e9eed8cf25acc3f40d9f5630cf90f0102d87 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 21 Jun 2023 15:04:25 +0300 Subject: [PATCH 086/305] Add support for file-like objects (BytesIO) (#178) Can be used as such: TinyTag.get(file_obj=file_obj) --- tinytag/tests/test_all.py | 7 ++++++ tinytag/tinytag.py | 52 ++++++++++++++++++++++----------------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0b70517..aa3a38e 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -510,6 +510,13 @@ def test_pathlib_compatibility(): TinyTag.get(filename) +def test_bytesio_compatibility(): + testfile = next(iter(testfiles.keys())) + filename = os.path.join(testfolder, testfile) + with io.open(filename, 'rb') as file_handle: + TinyTag.get(file_obj=io.BytesIO(file_handle.read())) + + @pytest.mark.skipif(sys.platform == "win32", reason='Windows does not support binary paths') def test_binary_path_compatibility(): binary_file_path = os.path.join(os.path.dirname(__file__).encode('utf-8'), b'\x01.mp3') diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a3f1305..595a101 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -170,40 +170,48 @@ def _get_parser_for_file_handle(cls, fh): return parser @classmethod - def get_parser_class(cls, filename, filehandle): + def get_parser_class(cls, filename=None, filehandle=None): if cls != TinyTag: # if `get` is invoked on TinyTag, find parser by ext return cls # otherwise use the class on which `get` was invoked - parser_class = cls._get_parser_for_filename(filename) - if parser_class is not None: - return parser_class + 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 - parser_class = cls._get_parser_for_file_handle(filehandle) - if parser_class is not None: - return parser_class + if filehandle: + parser_class = cls._get_parser_for_file_handle(filehandle) + if parser_class is not None: + return parser_class raise TinyTagException('No tag reader found to support filetype! ') @classmethod - def get(cls, filename, tags=True, duration=True, image=False, ignore_errors=False, - encoding=None): - try: # cast pathlib.Path to str - import pathlib - if isinstance(filename, pathlib.Path): - filename = str(filename.absolute()) - except ImportError: - pass + def get(cls, filename=None, tags=True, duration=True, image=False, + ignore_errors=False, encoding=None, file_obj=None): + should_open_file = (file_obj is None) + if should_open_file: + try: + file_obj = io.open(filename, 'rb') + except TypeError: + file_obj = io.open(str(filename.absolute()), 'rb') # Python 3.4/3.5 pathlib support + filename = file_obj.name else: - filename = os.path.expanduser(filename) - size = os.path.getsize(filename) - if not size > 0: - return TinyTag(None, 0) - with io.open(filename, 'rb') as af: - parser_class = cls.get_parser_class(filename, af) - tag = parser_class(af, size, ignore_errors=ignore_errors) + file_obj = io.BufferedReader(file_obj) # buffered reader to support peeking + try: + file_obj.seek(0, os.SEEK_END) + filesize = file_obj.tell() + file_obj.seek(0) + if filesize <= 0: + return TinyTag(None, filesize) + parser_class = cls.get_parser_class(filename, file_obj) + tag = parser_class(file_obj, filesize, ignore_errors=ignore_errors) tag._filename = filename tag._default_encoding = encoding tag.load(tags=tags, duration=duration, image=image) tag.extra = dict(tag.extra) # turn default dict into dict so that it can throw KeyError return tag + finally: + if should_open_file: + file_obj.close() def __str__(self): return json.dumps(OrderedDict(sorted(self.as_dict().items()))) From e9c436ae24d583aa50efd1376b822596e9a42aa0 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 17 Jul 2023 11:51:42 +0300 Subject: [PATCH 087/305] Fix pathlib support in TinyTag.is_supported() --- tinytag/tests/test_all.py | 5 +++-- tinytag/tinytag.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index aa3a38e..1d9bed6 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -354,8 +354,8 @@ 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, - 'title': 'Cold Water (feat. Justin Bieber & M�)', - 'album': 'Cold Water (feat. Justin Bieber & M�) - Single', 'year': '2016', + 'title': 'Cold Water (feat. Justin Bieber & M\uFFFD)', + 'album': 'Cold Water (feat. Justin Bieber & M\uFFFD) - Single', 'year': '2016', 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), @@ -508,6 +508,7 @@ def test_pathlib_compatibility(): testfile = next(iter(testfiles.keys())) filename = pathlib.Path(testfolder) / testfile TinyTag.get(filename) + assert TinyTag.is_supported(filename) def test_bytesio_compatibility(): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 595a101..a4ca611 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -142,7 +142,11 @@ def _get_parser_for_filename(cls, filename): (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, } if not isinstance(filename, bytes): # convert filename to binary - filename = filename.encode('ASCII', errors='ignore').lower() + try: + filename = filename.encode('ASCII', errors='ignore') + except AttributeError: + filename = bytes(filename) # pathlib + filename = filename.lower() for ext, tagclass in mapping.items(): if filename.endswith(ext): return tagclass @@ -193,7 +197,6 @@ def get(cls, filename=None, tags=True, duration=True, image=False, file_obj = io.open(filename, 'rb') except TypeError: file_obj = io.open(str(filename.absolute()), 'rb') # Python 3.4/3.5 pathlib support - filename = file_obj.name else: file_obj = io.BufferedReader(file_obj) # buffered reader to support peeking try: From a56da4523cedf11b4874ac24d1b700700e93a9ee Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 17 Jul 2023 12:21:18 +0300 Subject: [PATCH 088/305] Update changelog --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b920589..a643afc 100644 --- a/README.md +++ b/README.md @@ -93,13 +93,20 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') +To use a file-like object (e.g. BytesIO) instead of a file path, pass a +`file_obj` keyword argument: + + TinyTag.get(file_obj=your_file_obj) + ## Changelog -### 1.9.1 (unreleased) +### 1.10.0 (unreleased) - Fix deprecations related to setuptools #176 - Add list of supported file extensions #177 +- Add support for file-like objects (BytesIO) #178 +- Fix pathlib support in TinyTag.is_supported() ### 1.9.0 (2023-04-23) From cf3b9df17ec84e10223915e7e17d3eaf968393bc Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 17 Jul 2023 12:37:56 +0300 Subject: [PATCH 089/305] test_all.py: test file handles --- tinytag/tests/test_all.py | 7 +++++-- tinytag/tinytag.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 1d9bed6..0173854 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -511,11 +511,14 @@ def test_pathlib_compatibility(): assert TinyTag.is_supported(filename) -def test_bytesio_compatibility(): +def test_file_obj_compatibility(): testfile = next(iter(testfiles.keys())) filename = os.path.join(testfolder, testfile) with io.open(filename, 'rb') as file_handle: - TinyTag.get(file_obj=io.BytesIO(file_handle.read())) + tag = TinyTag.get(file_obj=file_handle) + file_handle.seek(0) + tag_bytesio = TinyTag.get(file_obj=io.BytesIO(file_handle.read())) + assert tag.filesize == tag_bytesio.filesize @pytest.mark.skipif(sys.platform == "win32", reason='Windows does not support binary paths') diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a4ca611..cdf7088 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -197,7 +197,7 @@ def get(cls, filename=None, tags=True, duration=True, image=False, file_obj = io.open(filename, 'rb') except TypeError: file_obj = io.open(str(filename.absolute()), 'rb') # Python 3.4/3.5 pathlib support - else: + elif isinstance(file_obj, io.BytesIO): file_obj = io.BufferedReader(file_obj) # buffered reader to support peeking try: file_obj.seek(0, os.SEEK_END) From def8108dc06429f7f4a75b9485a36678dcae53f3 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 14 Aug 2023 09:59:00 +0300 Subject: [PATCH 090/305] tinytag.py: cache mappings --- tinytag/tinytag.py | 56 +++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index cdf7088..6596c54 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -84,6 +84,8 @@ class TinyTag(object): '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' ] + _file_extension_mapping = None + _magic_bytes_mapping = None def __init__(self, filehandler, filesize, ignore_errors=False): # This is required for compatibility between python2 and python3 @@ -132,44 +134,46 @@ def get_image(self): @classmethod def _get_parser_for_filename(cls, filename): - mapping = { - (b'.mp1', b'.mp2', b'.mp3'): ID3, - (b'.oga', b'.ogg', b'.opus'): Ogg, - (b'.wav',): Wave, - (b'.flac',): Flac, - (b'.wma',): Wma, - (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): MP4, - (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, - } + if cls._file_extension_mapping is None: + cls._file_extension_mapping = { + (b'.mp1', b'.mp2', b'.mp3'): ID3, + (b'.oga', b'.ogg', b'.opus'): Ogg, + (b'.wav',): Wave, + (b'.flac',): Flac, + (b'.wma',): Wma, + (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): MP4, + (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, + } if not isinstance(filename, bytes): # convert filename to binary try: filename = filename.encode('ASCII', errors='ignore') except AttributeError: filename = bytes(filename) # pathlib filename = filename.lower() - for ext, tagclass in mapping.items(): + for ext, tagclass in cls._file_extension_mapping.items(): if filename.endswith(ext): return tagclass @classmethod def _get_parser_for_file_handle(cls, fh): # https://en.wikipedia.org/wiki/List_of_file_signatures - magic_bytes_mapping = { - b'^ID3': ID3, - b'^\xff\xfb': ID3, - b'^OggS': Ogg, - b'^RIFF....WAVE': Wave, - b'^fLaC': Flac, - b'^\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': Wma, - b'....ftypM4A': MP4, # https://www.file-recovery.com/m4a-signature-format.htm - b'....ftypaax': MP4, # Audible proprietary M4A container - b'....ftypaaxc': MP4, # Audible proprietary M4A container - b'\xff\xf1': MP4, # https://www.garykessler.net/library/file_sigs.html - b'^FORM....AIFF': Aiff, - b'^FORM....AIFC': Aiff, - } - header = fh.peek(max(len(sig) for sig in magic_bytes_mapping)) - for magic, parser in magic_bytes_mapping.items(): + if cls._magic_bytes_mapping is None: + cls._magic_bytes_mapping = { + b'^ID3': ID3, + b'^\xff\xfb': ID3, + b'^OggS': Ogg, + b'^RIFF....WAVE': Wave, + b'^fLaC': Flac, + b'^\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': Wma, + b'....ftypM4A': MP4, # https://www.file-recovery.com/m4a-signature-format.htm + b'....ftypaax': MP4, # Audible proprietary M4A container + b'....ftypaaxc': MP4, # Audible proprietary M4A container + b'\xff\xf1': MP4, # https://www.garykessler.net/library/file_sigs.html + b'^FORM....AIFF': Aiff, + b'^FORM....AIFC': Aiff, + } + header = fh.peek(max(len(sig) for sig in cls._magic_bytes_mapping)) + for magic, parser in cls._magic_bytes_mapping.items(): if re.match(magic, header): return parser From aca08992a35b77d0f67f132cd945f86bc917f760 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 14 Aug 2023 22:03:10 +0300 Subject: [PATCH 091/305] Add myself to copyright notices (#180) --- LICENSE | 2 +- tinytag/tinytag.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index c0162e4..9091269 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2014-2017 Tom Wallroth +Copyright (c) 2014-2023 Tom Wallroth, Mat (mathiascode) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 6596c54..4c4f9b0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -2,14 +2,15 @@ # -*- coding: utf-8 -*- # tinytag - an audio meta info reader -# Copyright (c) 2014-2022 Tom Wallroth +# Copyright (c) 2014-2023 Tom Wallroth +# Copyright (c) 2021-2023 Mat (mathiascode) # -# Sources on github: +# Sources on GitHub: # http://github.com/devsnd/tinytag/ # MIT License -# Copyright (c) 2014-2022 Tom Wallroth +# Copyright (c) 2014-2023 Tom Wallroth, Mat (mathiascode) # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal From 2b0d33fdae5f11ac752a266589ce96a51bd21153 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 14 Aug 2023 22:44:17 +0300 Subject: [PATCH 092/305] tinytag.py: stricter conditions in while loops --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4c4f9b0..c8728aa 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -985,7 +985,7 @@ def _parse_pages(self, fh): # for the spec, see: https://wiki.xiph.org/Ogg previous_page = b'' # contains data from previous (continuing) pages header_data = fh.read(27) # read ogg page header - while len(header_data) != 0: + while len(header_data) == 27: header = struct.unpack('<4sBBqIIiB', header_data) # https://xiph.org/ogg/doc/framing.html oggs, version, flags, pos, serial, pageseq, crc, segments = header @@ -1113,7 +1113,7 @@ def load(self, tags, duration, image=False): def _determine_duration(self, fh): # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) - while len(header_data): + while len(header_data) == 4: meta_header = struct.unpack('B3B', header_data) block_type = meta_header[0] & 0x7f is_last_block = meta_header[0] & 0x80 From 6f74b1dfd65f7b6d2a843d9cab72b8315892694c Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 15 Aug 2023 06:05:26 +0300 Subject: [PATCH 093/305] README.md: update list of supported formats --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a643afc..de02b06 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,13 @@ tinytag is a library for reading music meta data of most common audio files in p * Read tags, length and cover images of audio files * Supported formats: - * MP3/MP2/MP1 (ID3 v1, v1.1, v2.2, v2.3+) - * Wave/RIFF - * OGG - * OPUS + * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) + * M4A (AAC / ALAC) + * Wave / RIFF + * OGG (Opus / Vorbis) * FLAC * WMA - * MP4/M4A/M4B/M4R/M4V/ALAC/AAX/AAXC - * AIFF/AIFF-C + * AIFF / AIFF-C * Pure Python, no dependencies * Supports Python 2.7 and 3.4 or higher * High test coverage From b6bb7ed43b643190dfb06fff8aac559a6f0cae50 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 15 Aug 2023 09:11:07 +0300 Subject: [PATCH 094/305] Aiff: don't inherit ID3 class Matches behavior in other classes. --- tinytag/tinytag.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c8728aa..8d95935 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -283,7 +283,8 @@ def update(self, other): # update the values of this tag with the values from another tag for key in ['track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', - 'genre', 'disc', 'disc_total', 'comment', 'composer']: + 'genre', 'disc', 'disc_total', 'comment', 'composer', + '_image_data']: if not getattr(self, key) and getattr(other, key): setattr(self, key, getattr(other, key)) @@ -1336,7 +1337,7 @@ def _parse_tag(self, fh): fh.seek(object_size - 24, os.SEEK_CUR) # read over onknown object ids -class Aiff(ID3): +class Aiff(TinyTag): # # AIFF is part of the IFF family of file formats. # @@ -1379,7 +1380,7 @@ class Aiff(ID3): } def __init__(self, filehandler, filesize, *args, **kwargs): - ID3.__init__(self, filehandler, filesize, *args, **kwargs) + TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) self._tags_parsed = False def _parse_tag(self, fh): @@ -1404,7 +1405,9 @@ def _parse_tag(self, fh): self.samplerate = self.duration = self.bitrate = None # invalid sample rate fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk elif sub_chunk_id in (b'id3 ', b'ID3 ') and self._parse_tags: - ID3._parse_tag(self, fh) + id3 = ID3(fh, 0) + id3.load(tags=True, duration=False, image=self._load_image) + self.update(id3) elif sub_chunk_id == b'SSND': self.audio_offset = fh.tell() fh.seek(sub_chunk_size, 1) From 8ca4ba4920c21f1388ade38b3f9b6a068684319d Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 15 Aug 2023 09:18:53 +0300 Subject: [PATCH 095/305] Add stricter magic byte matching for OGG files Ensure we don't try to parse ogv files. --- tinytag/tests/samples/detect_ogg_opus.x | Bin 0 -> 10000 bytes .../{detect_ogg.x => detect_ogg_vorbis.x} | Bin tinytag/tests/test_all.py | 3 ++- tinytag/tinytag.py | 3 ++- 4 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 tinytag/tests/samples/detect_ogg_opus.x rename tinytag/tests/samples/{detect_ogg.x => detect_ogg_vorbis.x} (100%) diff --git a/tinytag/tests/samples/detect_ogg_opus.x b/tinytag/tests/samples/detect_ogg_opus.x new file mode 100644 index 0000000000000000000000000000000000000000..1d170dc3395c4ebe8ee29b7c70c14330569c1dad GIT binary patch literal 10000 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�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 Date: Tue, 15 Aug 2023 12:26:17 +0300 Subject: [PATCH 096/305] Wave: support image loading --- ...est_with_image.aiff => aiff_with_image.aiff} | Bin tinytag/tests/samples/wav_with_image.wav | Bin 0 -> 22908 bytes tinytag/tests/test_all.py | 12 +++++++++++- tinytag/tinytag.py | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) rename tinytag/tests/samples/{test_with_image.aiff => aiff_with_image.aiff} (100%) create mode 100644 tinytag/tests/samples/wav_with_image.wav diff --git a/tinytag/tests/samples/test_with_image.aiff b/tinytag/tests/samples/aiff_with_image.aiff similarity index 100% rename from tinytag/tests/samples/test_with_image.aiff rename to tinytag/tests/samples/aiff_with_image.aiff diff --git a/tinytag/tests/samples/wav_with_image.wav b/tinytag/tests/samples/wav_with_image.wav new file mode 100644 index 0000000000000000000000000000000000000000..2dd6f7c22b0f6e333b783bc403e8d681e2926ee9 GIT binary patch literal 22908 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 Date: Tue, 15 Aug 2023 12:51:31 +0300 Subject: [PATCH 097/305] Add support for OGG FLAC format (#182) --- README.md | 2 +- tinytag/tests/samples/detect_ogg_flac.x | Bin 0 -> 9273 bytes tinytag/tests/samples/test_flac.oga | Bin 0 -> 9273 bytes tinytag/tests/test_all.py | 14 ++++- tinytag/tinytag.py | 77 +++++++++++++++++------- 5 files changed, 68 insertions(+), 25 deletions(-) create mode 100644 tinytag/tests/samples/detect_ogg_flac.x create mode 100644 tinytag/tests/samples/test_flac.oga diff --git a/README.md b/README.md index de02b06..5cd37b6 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ tinytag is a library for reading music meta data of most common audio files in p * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) * M4A (AAC / ALAC) * Wave / RIFF - * OGG (Opus / Vorbis) + * OGG (FLAC / Opus / Vorbis) * FLAC * WMA * AIFF / AIFF-C diff --git a/tinytag/tests/samples/detect_ogg_flac.x b/tinytag/tests/samples/detect_ogg_flac.x new file mode 100644 index 0000000000000000000000000000000000000000..dd605c0cae0311f5ad1910d334e1ad58930b8745 GIT binary patch literal 9273 zcmeI&T}V@59LMozIxFAiaoIA{T5FnN**04yv2t6>mfMVN_C^W1<`6t!{DYr~D<`cPPZs;&l0=*@+eLbKjt zG8LOj%*9FaHOcbF?r~IlZP9R3)RF@FgYH03@s3p4&I+&B<)aB}nlf?lsNY?-pQefu z<*B{)pvz`4S&HQEEXrIyzsqJ1HJoXR)`hT&JK%RJ58RFgJo4SnfZ}4TA9M#jE`?^S z7xdem<-SUPc9x?gnr50 z3@*S0xBwU60$hL#Z~-pB1-Jkg-~wEL3;g2+l&ul$6Db2*Bl1>}Fx04x(&Cl~ofsFc z;R6@o0$hL#Z~-pB1-Jkg-~wEL3vhw|uYmIYtS*hw-#GC8oFGT2S6e&tHGemx`Pt!2 zF-Yn9K0~T!q-}SgxKWZ$1;vk$TB4BpJt=I365f)&mO{&3kw)G@%ioZWcS9?FkXlbb ziF2fkd?;y#w6zCHo+kAVK`E1@s!LGn2h#8~r1?mis)N$LkRsWT)I)lC1xoKF9hrl) z1ElH*wDKY8n-mfMVN_C^W1<`6t!{DYr~D<`cPPZs;&l0=*@+eLbKjt zG8LOj%*9FaHOcbF?r~IlZP9R3)RF@FgYH03@s3p4&I+&B<)aB}nlf?lsNY?-pQefu z<*B{)pvz`4S&HQEEXrIyzsqJ1HJoXR)`hT&JK%RJ58RFgJo4SnfZ}4TA9M#jE`?^S z7xdem<-SUPc9x?gnr50 z3@*S0xBwU60$hL#Z~-pB1-Jkg-~wEL3;g2+l&ul$6Db2*Bl1>}Fx04x(&Cl~ofsFc z;R6@o0$hL#Z~-pB1-Jkg-~wEL3vhw|uYmIYtS*hw-#GC8oFGT2S6e&tHGemx`Pt!2 zF-Yn9K0~T!q-}SgxKWZ$1;vk$TB4BpJt=I365f)&mO{&3kw)G@%ioZWcS9?FkXlbb ziF2fkd?;y#w6zCHo+kAVK`E1@s!LGn2h#8~r1?mis)N$LkRsWT)I)lC1xoKF9hrl) z1ElH*wDKY8n- max_page_size: fh.seek(-max_page_size, 2) # go to last possible page position while True: @@ -911,33 +919,55 @@ def _determine_duration(self, fh): def _parse_tag(self, fh): page_start_pos = fh.tell() # set audio_offset later if its audio data + check_flac_second_packet = False for packet in self._parse_pages(fh): walker = BytesIO(packet) if packet[0:7] == b"\x01vorbis": - (channels, self.samplerate, max_bitrate, bitrate, - min_bitrate) = struct.unpack(" Date: Tue, 15 Aug 2023 22:00:57 +0300 Subject: [PATCH 098/305] Add support for OGG Speex format (#181) --- README.md | 2 +- tinytag/tests/samples/test.spx | Bin 0 -> 7921 bytes tinytag/tests/test_all.py | 18 ++++++++++-------- tinytag/tinytag.py | 30 ++++++++++++++++++++++++------ 4 files changed, 35 insertions(+), 15 deletions(-) create mode 100644 tinytag/tests/samples/test.spx diff --git a/README.md b/README.md index 5cd37b6..6a7af0f 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ tinytag is a library for reading music meta data of most common audio files in p * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) * M4A (AAC / ALAC) * Wave / RIFF - * OGG (FLAC / Opus / Vorbis) + * OGG (FLAC / Opus / Speex / Vorbis) * FLAC * WMA * AIFF / AIFF-C diff --git a/tinytag/tests/samples/test.spx b/tinytag/tests/samples/test.spx new file mode 100644 index 0000000000000000000000000000000000000000..524f62ac02d0578a2ecc10df7fe17dcf939f3662 GIT binary patch literal 7921 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 Chmx@X literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index a4541a6..eb3903b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -223,14 +223,6 @@ 'audio_offset': 0, 'bitrate': 112.0, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer', 'comment': 'A Comment'}), - ('samples/test_flac.oga', - {'extra': {'copyright': 'test3', 'isrc': 'test4', 'lyrics': 'test7'}, - 'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5', - 'bitrate': 20.022488249118684, 'duration': 3.705034013605442, 'channels': 2, - 'genre': 'Acoustic', 'samplerate': 44100, 'bitdepth': 16, 'title': 'test1', 'track': '5', - 'year': '2023'}), - - # OPUS ('samples/test.opus', {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', @@ -239,6 +231,16 @@ 'comment': 'ARCD0018 - Lovelight', 'disc_total': '1', 'track_total': '13'}), ('samples/8khz_5s.opus', {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), + ('samples/test_flac.oga', + {'extra': {'copyright': 'test3', 'isrc': 'test4', 'lyrics': 'test7'}, + 'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5', + 'bitrate': 20.022488249118684, 'duration': 3.705034013605442, 'channels': 2, + 'genre': 'Acoustic', 'samplerate': 44100, 'bitdepth': 16, 'title': 'test1', 'track': '5', + 'year': '2023'}), + ('samples/test.spx', + {'extra': {}, 'filesize': 7921, 'channels': 1, 'samplerate': 16000, 'bitrate': -1, + 'duration': 2.1445625, 'artist': 'test1', 'title': 'test2', + 'comment': 'Encoded with Speex 1.2.0'}), # WAV ('samples/test.wav', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 7fed3e5..73b609e 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -80,7 +80,7 @@ def _bytes_to_int(b): class TinyTag(object): SUPPORTED_FILE_EXTENSIONS = [ '.mp1', '.mp2', '.mp3', - '.oga', '.ogg', '.opus', + '.oga', '.ogg', '.opus', '.spx', '.wav', '.flac', '.wma', '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' @@ -139,7 +139,7 @@ def _get_parser_for_filename(cls, filename): if cls._file_extension_mapping is None: cls._file_extension_mapping = { (b'.mp1', b'.mp2', b'.mp3'): ID3, - (b'.oga', b'.ogg', b'.opus'): Ogg, + (b'.oga', b'.ogg', b'.opus', b'.spx'): Ogg, (b'.wav',): Wave, (b'.flac',): Flac, (b'.wma',): Wma, @@ -165,6 +165,7 @@ def _get_parser_for_file_handle(cls, fh): b'^\xff\xfb': ID3, b'^OggS.........................FLAC': Ogg, b'^OggS........................Opus': Ogg, + b'^OggS........................Speex': Ogg, b'^OggS.........................vorbis': Ogg, b'^RIFF....WAVE': Wave, b'^fLaC': Flac, @@ -920,6 +921,7 @@ def _determine_duration(self, fh): def _parse_tag(self, fh): page_start_pos = fh.tell() # set audio_offset later if its audio data check_flac_second_packet = False + check_speex_second_packet = False for packet in self._parse_pages(fh): walker = BytesIO(packet) if packet[0:7] == b"\x01vorbis": @@ -955,13 +957,27 @@ def _parse_tag(self, fh): self.update(flactag, all_fields=True) check_flac_second_packet = True elif check_flac_second_packet: + # second packet contains FLAC metadata block if self._parse_tags: - # second packet contains FLAC metadata block meta_header = struct.unpack('B3B', walker.read(4)) block_type = meta_header[0] & 0x7f if block_type == Flac.METADATA_VORBIS_COMMENT: self._parse_vorbis_comment(walker) check_flac_second_packet = False + elif packet[0:8] == b'Speex ': + # https://speex.org/docs/manual/speex-manual/node8.html + if self._parse_duration: + walker.seek(36, os.SEEK_CUR) # jump over header name and irrelevant fields + (self.samplerate, _, _, self.channels, + self.bitrate) = struct.unpack("<5i", walker.read(20)) + check_speex_second_packet = True + elif check_speex_second_packet: + if self._parse_tags: + length = struct.unpack('I', walker.read(4))[0] # starts with a comment string + comment = codecs.decode(walker.read(length), 'UTF-8') + self._set_field('comment', comment) + self._parse_vorbis_comment(walker, contains_vendor=False) # other tags + check_speex_second_packet = False else: if DEBUG: stderr('Unsupported Ogg page type: ', packet[:16]) @@ -969,7 +985,7 @@ def _parse_tag(self, fh): page_start_pos = fh.tell() self._tags_parsed = True - def _parse_vorbis_comment(self, fh): + def _parse_vorbis_comment(self, fh, contains_vendor=True): # for the spec, see: http://xiph.org/vorbis/doc/v-comment.html # discnumber tag based on: https://en.wikipedia.org/wiki/Vorbis_comment # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/Vorbis.html @@ -978,6 +994,7 @@ def _parse_vorbis_comment(self, fh): 'albumartist': 'albumartist', 'title': 'title', 'artist': 'artist', + 'author': 'artist', 'date': 'year', 'tracknumber': 'track', 'tracktotal': 'track_total', @@ -993,8 +1010,9 @@ def _parse_vorbis_comment(self, fh): 'isrc': 'extra.isrc', 'lyrics': 'extra.lyrics', } - vendor_length = struct.unpack('I', fh.read(4))[0] - fh.seek(vendor_length, os.SEEK_CUR) # jump over vendor + if contains_vendor: + vendor_length = struct.unpack('I', fh.read(4))[0] + fh.seek(vendor_length, os.SEEK_CUR) # jump over vendor elements = struct.unpack('I', fh.read(4))[0] for i in range(elements): length = struct.unpack('I', fh.read(4))[0] From 925402c79f2e9c3394d10bdc7f6d7e53f0642823 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 15 Aug 2023 22:44:23 +0300 Subject: [PATCH 099/305] Only remove zero bytes at the end of strings Fixes #151 --- tinytag/tests/test_all.py | 19 ++++++++++--------- tinytag/tinytag.py | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index eb3903b..313825a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -66,9 +66,9 @@ 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', 'artist': 'Björk', 'year': '1993', 'comment': ' '}), ('samples/UTF16.mp3', - {'extra': {'text': 'MusicBrainz Artist Id664c3e0e-42d8-48c1-b209-1efca19c0325', - 'url': 'WIKIPEDIA_RELEASEhttp://en.wikipedia.org/wiki/High_Violet'}, 'track_total': '11', - 'track': '07', 'artist': 'The National', + {'extra': {'text': 'MusicBrainz Artist Id\x00664c3e0e-42d8-48c1-b209-1efca19c0325', + 'url': 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet'}, + 'track_total': '11', 'track': '07', 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), ('samples/utf-8-id3v2.mp3', @@ -100,14 +100,14 @@ 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', - 'comment': 'engiTunPGAP0', 'genre': 'Pop', 'title': 'Applause'}), + 'comment': 'engiTunPGAP\x000', 'genre': 'Pop', 'title': 'Applause'}), ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {'isrc': 'USTC40852229'}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), ('samples/id3_comment_utf_16_double_bom.mp3', - {'extra': {'text': 'LABEL\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', + {'extra': {'text': 'LABEL\x00\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', 'title': 'The Embrace (Romano Alfieri Remix)', 'track': '04-johannes_heil_and_d.diggler-the_embrace_(romano_alfieri_remix)', @@ -122,7 +122,7 @@ 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', - 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATINGL'}, + 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATING\x00L'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), ('samples/nicotinetestdata.mp3', {'extra': {}, 'filesize': 80919, 'audio_offset': 45, 'channels': 2, @@ -138,7 +138,7 @@ 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), ('samples/id3_xxx_lang.mp3', {'extra': {'isrc': 'USVI20400513', 'lyrics': "Don't fret, precious", - 'text': 'SCRIPT\ufeffLatn'}, + 'text': 'SCRIPT\x00\ufeffLatn'}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', 'artist': 'A Perfect Circle', 'audio_offset': 3647, 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', @@ -409,7 +409,8 @@ ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16, 'audio_offset': 154, - 'comment': 'AFspdate: 2003-01-30 03:28:34 UTCuser: kabal@CAPELLAprogram: CopyAudio'}), + 'comment': + 'AFspdate: 2003-01-30 03:28:34 UTC\x00user: kabal@CAPELLA\x00program: CopyAudio'}), ('samples/invalid_sample_rate.aiff', {'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16}), @@ -605,7 +606,7 @@ def test_invalid_aiff_file(): def test_unpad(): # make sure that unpad only removes trailing 0-bytes assert TinyTag._unpad('foo\x00') == 'foo' - assert TinyTag._unpad('foo\x00bar\x00') == 'foobar' + assert TinyTag._unpad('foo\x00bar\x00') == 'foo\x00bar' def test_mp3_image_loading(): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 73b609e..604aef5 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -299,7 +299,7 @@ def update(self, other, all_fields=False): @staticmethod def _unpad(s): # strings in mp3 and asf *may* be terminated with a zero byte at the end - return s.replace('\x00', '') + return s.strip('\x00') class MP4(TinyTag): From 843e81825bace09973dc01c903a99700f3e4cef8 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 26 Sep 2023 19:34:10 +0300 Subject: [PATCH 100/305] Update changelog --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a7af0f..fc553c0 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,16 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ### 1.10.0 (unreleased) -- Fix deprecations related to setuptools #176 -- Add list of supported file extensions #177 +- Add support for OGG FLAC format #182 +- Add support for OGG Speex format #181 +- Wave: support image loading - Add support for file-like objects (BytesIO) #178 +- Add list of supported file extensions #177 +- Fix deprecations related to setuptools #176 - Fix pathlib support in TinyTag.is_supported() +- Only remove zero bytes at the end of strings +- Stricter conditions in while loops +- OGG: Add stricter magic byte matching for OGG files ### 1.9.0 (2023-04-23) From 901b35844142189ef3f97f98f3e90b84f6749cff Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 26 Sep 2023 19:54:14 +0300 Subject: [PATCH 101/305] Test Python 3.12 and PyPy 3.10 (#185) --- .github/workflows/tests.yml | 2 +- setup.cfg | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5867675..0141b43 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9'] + python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12.0-rc.3', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/setup.cfg b/setup.cfg index 1367c88..f8c0f98 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,11 @@ classifiers = Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 License :: OSI Approved :: MIT License Development Status :: 5 - Production/Stable Environment :: Web Environment From f09241feb32451a8c18a8fe55eb4d2eb1944db66 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 6 Oct 2023 01:41:44 +0300 Subject: [PATCH 102/305] tests.yml: test stable Python 3.12 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0141b43..01f3000 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12.0-rc.3', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - name: Checkout code uses: actions/checkout@v3 @@ -33,12 +33,12 @@ jobs: DEBUG: true - name: Coverage report - if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.11' + if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.12' run: coverage lcov - name: Coveralls uses: coverallsapp/github-action@master - if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.11' + if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.12' with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov From 260e1800ff4b876527496b4ef88c94d91bfde97f Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 6 Oct 2023 22:00:44 +0300 Subject: [PATCH 103/305] Modernize packaging (#186) - Move version field to setup.cfg - Add Python script for creating a new PyPi release - Use 'python -m build' instead of 'python setup.py sdist' - Remove official support for Python 3.4 and 3.5 (PyPi stats show there are no users on these versions) --- .appveyor.yml | 8 +++----- .github/workflows/tests.yml | 11 +++++++++-- MANIFEST.in | 2 +- Makefile | 17 ----------------- README.md | 3 ++- release.py | 20 ++++++++++++++++++++ setup.cfg | 3 +-- setup.py | 14 ++------------ tinytag/__init__.py | 6 +++--- tinytag/tinytag.py | 5 +---- 10 files changed, 42 insertions(+), 47 deletions(-) delete mode 100644 Makefile create mode 100755 release.py diff --git a/.appveyor.yml b/.appveyor.yml index 060c910..d6dba94 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -13,10 +13,8 @@ build: off init: - cmd: set PATH=%PY_DIR%;%PY_DIR%\Scripts;%PATH% -install: -- pip install -e .[tests] -- pip install mypy +install: +- pip install .[tests] -test_script: +test_script: - pytest --cov -# - mypy . --ignore-missing-imports diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01f3000..7edc946 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['3.5', '3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - name: Checkout code uses: actions/checkout@v3 @@ -22,7 +22,11 @@ jobs: python-version: ${{ matrix.python }} - name: Install dependencies - run: python -m pip install flake8 pytest pytest-cov + run: python -m pip install build .[tests] + + - name: Downgrade importlib-metadata + run: python -m pip install importlib-metadata==4.13.0 + if: matrix.python == '3.7' || matrix.python == 'pypy-3.7' - name: Flake8 linter run: python -m flake8 @@ -32,6 +36,9 @@ jobs: env: DEBUG: true + - name: Build package + run: python -m build + - name: Coverage report if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.12' run: coverage lcov diff --git a/MANIFEST.in b/MANIFEST.in index ddf3a98..51a0f07 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include *.md -include *.rst +include *.toml include *.txt include LICENSE diff --git a/Makefile b/Makefile deleted file mode 100644 index 652a51f..0000000 --- a/Makefile +++ /dev/null @@ -1,17 +0,0 @@ -LATEST := $(shell bash -c "find dist | sort -V -r | head -n 1") - -.PHONY : all -all: upload - -test: - pytest - -assure_tag_is_version: - bash -c 'grep `git tag | sort -V | tail -1` tinytag/__init__.py' || (echo "git version is not the same as version in __init__.py"; exit 1) - -buildpkg: assure_tag_is_version test - python ./setup.py sdist - -upload: buildpkg - bash -c 'twine upload -r pypi `find dist | sort -V -r | head -n 1`' - diff --git a/README.md b/README.md index fc553c0..4c88841 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ tinytag is a library for reading music meta data of most common audio files in p * WMA * AIFF / AIFF-C * Pure Python, no dependencies - * Supports Python 2.7 and 3.4 or higher + * Supports Python 2.7 and 3.6 or higher * High test coverage * Just a few hundred lines of code (just include it in your project!) @@ -112,6 +112,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - Only remove zero bytes at the end of strings - Stricter conditions in while loops - OGG: Add stricter magic byte matching for OGG files +- Compatibility with Python 3.4 and 3.5 is no longer tested ### 1.9.0 (2023-04-23) diff --git a/release.py b/release.py new file mode 100755 index 0000000..ba7be98 --- /dev/null +++ b/release.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python + +import subprocess +import sys + + +def release_package(): + # Run tests + subprocess.check_call([sys.executable, "-m", "flake8"]) + subprocess.check_call([sys.executable, "-m", "pytest"]) + + # Prepare source distribution and wheel + subprocess.check_call([sys.executable, "-m", "build", "--sdist", "--wheel"]) + + # Upload package to PyPi + subprocess.check_call([sys.executable, "-m", "twine", "upload", "dist/*"]) + + +if __name__ == "__main__": + release_package() diff --git a/setup.cfg b/setup.cfg index f8c0f98..7c802c2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,6 @@ [metadata] name = tinytag +version = attr: tinytag.__version__ author = Tom Wallroth author_email = tomwallroth@gmail.com url = https://github.com/devsnd/tinytag @@ -11,8 +12,6 @@ classifiers = Programming Language :: Python Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 - Programming Language :: Python :: 3.4 - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 diff --git a/setup.py b/setup.py index 1c4046d..c823345 100755 --- a/setup.py +++ b/setup.py @@ -1,14 +1,4 @@ #!/usr/bin/env python -from os.path import join -from setuptools import setup, find_packages +from setuptools import setup - -def get_version(): - with open(join("tinytag", "__init__.py")) as f: - version_line = next(line for line in f if line.startswith("__version__ =")) - return version_line.split("=")[1].strip().strip("\"'") - - -setup(name="tinytag", - version=get_version(), - packages=find_packages()) +setup() diff --git a/tinytag/__init__.py b/tinytag/__init__.py index dfac44c..f8e056f 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,11 +1,11 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -import sys -from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 - __version__ = '1.9.0' +import sys +from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 + if __name__ == '__main__': print(TinyTag.get(sys.argv[1])) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 604aef5..a3abf4b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -202,10 +202,7 @@ def get(cls, filename=None, tags=True, duration=True, image=False, ignore_errors=False, encoding=None, file_obj=None): should_open_file = (file_obj is None) if should_open_file: - try: - file_obj = io.open(filename, 'rb') - except TypeError: - file_obj = io.open(str(filename.absolute()), 'rb') # Python 3.4/3.5 pathlib support + file_obj = io.open(filename, 'rb') elif isinstance(file_obj, io.BytesIO): file_obj = io.BufferedReader(file_obj) # buffered reader to support peeking try: From 21e65cd4131b4c639654c6c158d05f8b0d0fdff6 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 18 Oct 2023 16:05:19 +0300 Subject: [PATCH 104/305] Bump version to 1.10.0 --- README.md | 2 +- tinytag/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4c88841..6ea468c 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ## Changelog -### 1.10.0 (unreleased) +### 1.10.0 (2023-10-18) - Add support for OGG FLAC format #182 - Add support for OGG Speex format #181 diff --git a/tinytag/__init__.py b/tinytag/__init__.py index f8e056f..e113b4b 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -__version__ = '1.9.0' +__version__ = '1.10.0' import sys from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 From 1276c4c7acf7549514d2dc019c7be1fc5b33665a Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 23 Oct 2023 19:48:57 +0300 Subject: [PATCH 105/305] Update 'extra' fields with data from other tags (#188) Fixes #187 --- tinytag/tests/samples/aiff_extra_tags.aiff | Bin 0 -> 18532 bytes tinytag/tests/test_all.py | 8 +++++++- tinytag/tinytag.py | 2 ++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 tinytag/tests/samples/aiff_extra_tags.aiff diff --git a/tinytag/tests/samples/aiff_extra_tags.aiff b/tinytag/tests/samples/aiff_extra_tags.aiff new file mode 100644 index 0000000000000000000000000000000000000000..9051b2565f3a474aac8bd356cc93abb3e0dedf5a GIT binary patch literal 18532 zcmeIuy9&ZU5QX6pBLr+DwM8ns6w%T~*%(Zb3ucQ1Y%GEVM8WLSTRB1BL4<#b-LrF= zjP6QA@009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#~Er9hT!LJ@DJw>SR7U)t&rYY0T9m#6Co7slIfSljw!v|DIrOw=?@ n)V5JEj?L^^o4Gc_<9-cY?{1wnWu?oRE?qY`jSK<^{H?$n_DLQz literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 313825a..d802394 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -316,7 +316,8 @@ {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.68, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', - {'extra': {}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', + {'extra': {'text': 'ID\x00\ufeff8591671910'}, 'filesize': 64837, 'album': ' ', + 'artist': '群星', 'disc': '0', 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, 'year': '2018'}), @@ -390,6 +391,7 @@ 'comment': 'test comment', 'duration': 727.1066666666667, 'extra': {'description': 'test description'}}), + # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', @@ -413,6 +415,10 @@ 'AFspdate: 2003-01-30 03:28:34 UTC\x00user: kabal@CAPELLA\x00program: CopyAudio'}), ('samples/invalid_sample_rate.aiff', {'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16}), + ('samples/aiff_extra_tags.aiff', + {'extra': {'isrc': 'CC-XXX-YY-NNNNN'}, 'channels': 1, 'duration': 2.176, + 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8, + 'title': 'song title', 'artist': 'artist 1;artist 2', 'audio_offset': 46}), ]) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a3abf4b..f57ec48 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -292,6 +292,8 @@ def update(self, other, all_fields=False): '_image_data']: if not getattr(self, key) and getattr(other, key): setattr(self, key, getattr(other, key)) + if other.extra: + self.extra.update(other.extra) @staticmethod def _unpad(s): From c8e6765c49be4a2415240ac9c5d0ee050bde1e67 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 23 Oct 2023 19:59:52 +0300 Subject: [PATCH 106/305] ID3: Add missing 'extra.copyright' field --- tinytag/tests/test_all.py | 16 +++++++++------- tinytag/tinytag.py | 1 + 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index d802394..4f68a4a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -31,7 +31,7 @@ testfiles = OrderedDict([ # MP3 ('samples/vbri.mp3', - {'extra': {'url': ''}, 'channels': 2, 'samplerate': 44100, + {'extra': {'copyright': '', 'url': ''}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': '01', 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', @@ -84,7 +84,9 @@ {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, 'audio_offset': 0, 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v24-long-title.mp3', - {'extra': {}, 'track': '1', 'disc_total': '1', + {'extra': + {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett'}, + 'track': '1', 'disc_total': '1', 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, 'track_total': '12', 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', @@ -102,9 +104,9 @@ {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', 'comment': 'engiTunPGAP\x000', 'genre': 'Pop', 'title': 'Applause'}), ('samples/id3_comment_utf_16_with_bom.mp3', - {'extra': {'isrc': 'USTC40852229'}, 'filesize': 19980, 'album': 'Ghosts I-IV', - 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'disc': '1', - 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', + {'extra': {'copyright': '(c) 2008 nin', 'isrc': 'USTC40852229'}, 'filesize': 19980, + 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', + 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), ('samples/id3_comment_utf_16_double_bom.mp3', {'extra': {'text': 'LABEL\x00\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', @@ -416,8 +418,8 @@ ('samples/invalid_sample_rate.aiff', {'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16}), ('samples/aiff_extra_tags.aiff', - {'extra': {'isrc': 'CC-XXX-YY-NNNNN'}, 'channels': 1, 'duration': 2.176, - 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8, + {'extra': {'copyright': 'test', 'isrc': 'CC-XXX-YY-NNNNN'}, 'channels': 1, + 'duration': 2.176, 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8, 'title': 'song title', 'artist': 'artist 1;artist 2', 'audio_offset': 46}), ]) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index f57ec48..032f9ef 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -541,6 +541,7 @@ class ID3(TinyTag): 'TXXX': 'extra.text', 'TKEY': 'extra.initial_key', 'USLT': 'extra.lyrics', + 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', } IMAGE_FRAME_IDS = {'APIC', 'PIC'} PARSABLE_FRAME_IDS = set(FRAME_ID_TO_FIELD.keys()).union(IMAGE_FRAME_IDS) From bbadadc62f0d2a9fcbff20ab6eb4559bee450251 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 23 Oct 2023 20:04:47 +0300 Subject: [PATCH 107/305] Update changelog --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6ea468c..3b0e7cc 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,11 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ## Changelog +### 1.10.1 (Unreleased) + +- Update 'extra' fields with data from other tags #188 +- ID3: Add missing 'extra.copyright' field + ### 1.10.0 (2023-10-18) - Add support for OGG FLAC format #182 From d8a62a2d32f6a1163ed3ef66a84a1967c8c6ab2a Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 26 Oct 2023 22:25:33 +0300 Subject: [PATCH 108/305] Bump version to 1.10.1 --- README.md | 2 +- tinytag/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3b0e7cc..47f3d08 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ## Changelog -### 1.10.1 (Unreleased) +### 1.10.1 (2023-10-26) - Update 'extra' fields with data from other tags #188 - ID3: Add missing 'extra.copyright' field diff --git a/tinytag/__init__.py b/tinytag/__init__.py index e113b4b..6859238 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -__version__ = '1.10.0' +__version__ = '1.10.1' import sys from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 From 5e09252706f250fe7667fd58d3f10229dc461da4 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 26 Feb 2024 16:31:25 +0200 Subject: [PATCH 109/305] Add support for custom fields (#183) --- README.md | 8 ++- tinytag/tests/test_all.py | 117 +++++++++++++++++++++++++++++--------- tinytag/tinytag.py | 67 ++++++++++++++++------ 3 files changed, 146 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 47f3d08..7ce1b72 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,14 @@ List of possible attributes you can get with TinyTag: tag.track_total # total number of tracks as string tag.year # year or date as string -For non-common fields and fields specific to single file formats, use `extra`: +For non-common fields and fields specific to certain file formats, use `extra`: tag.extra # a dict of additional data -The `extra` dict currently *may* contain the following data: - `url`, `isrc`, `text`, `initial_key`, `lyrics`, `copyright` +The following standard `extra` field names are used when file formats provide relevant data: + `url`, `isrc`, `initial_key`, `lyrics`, `copyright` + +Any other `extra` field names are not guaranteed to be consistent across audio formats. Additionally you can also get cover images from ID3 tags: diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 4f68a4a..d412c20 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -66,8 +66,14 @@ 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': '12', 'artist': 'Björk', 'year': '1993', 'comment': ' '}), ('samples/UTF16.mp3', - {'extra': {'text': 'MusicBrainz Artist Id\x00664c3e0e-42d8-48c1-b209-1efca19c0325', - 'url': 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet'}, + {'extra': {'musicbrainz artist id': '664c3e0e-42d8-48c1-b209-1efca19c0325', + 'musicbrainz album id': '25322466-a29b-417b-b560-399687b91ddd', + 'musicbrainz album artist id': '664c3e0e-42d8-48c1-b209-1efca19c0325', + 'musicbrainz disc id': 'p.5xoyYRtCVFe2gt0mfTfsXrO9U-', + 'musicip puid': '6ff97581-1c73-fc05-b4e4-a4ccee12ec84', 'asin': 'B003KVNV4S', + 'musicbrainz album status': 'Official', 'musicbrainz album type': 'Album', + 'musicbrainz album release country': 'United States', + 'url': 'WIKIPEDIA_RELEASE\u0000http://en.wikipedia.org/wiki/High_Violet'}, 'track_total': '11', 'track': '07', 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), @@ -109,7 +115,7 @@ 'disc': '1', 'disc_total': '2', 'title': '1 Ghosts I', 'track': '1', 'track_total': '36', 'year': '2008', 'comment': '3/4 time'}), ('samples/id3_comment_utf_16_double_bom.mp3', - {'extra': {'text': 'LABEL\x00\ufeffUnclear'}, 'filesize': 512, 'album': 'The Embrace', + {'extra': {'label': 'Unclear'}, 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', 'title': 'The Embrace (Romano Alfieri Remix)', 'track': '04-johannes_heil_and_d.diggler-the_embrace_(romano_alfieri_remix)', @@ -124,7 +130,7 @@ 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', - 'artist': 'Blind Guardian', 'comment': '', 'extra': {'text': 'LOVE RATING\x00L'}, + 'artist': 'Blind Guardian', 'comment': '', 'extra': {'love rating': 'L'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': '01', 'year': '1992'}), ('samples/nicotinetestdata.mp3', {'extra': {}, 'filesize': 80919, 'audio_offset': 45, 'channels': 2, @@ -139,8 +145,22 @@ 'audio_offset': 194, 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), ('samples/id3_xxx_lang.mp3', - {'extra': {'isrc': 'USVI20400513', 'lyrics': "Don't fret, precious", - 'text': 'SCRIPT\x00\ufeffLatn'}, + {'extra': {'script': 'Latn', 'originalyear': '2004', + 'acoustid id': '2dc0b571-a633-45b0-aa5e-f3d25e4e0020', + 'musicbrainz album type': 'album', + 'musicbrainz album artist id': '078a9376-3c04-4280-b7d7-b20e158f345d', + 'musicbrainz artist id': '078a9376-3c04-4280-b7d7-b20e158f345d', + 'barcode': '724386668721', + 'musicbrainz album id': '38b555fe-24c7-37b3-ad1b-f6dea9f1aafa', + 'artists': 'A Perfect Circle', + 'musicbrainz release track id': '7f7c31a5-0905-39ba-ba72-68db91d3b9da', + 'catalognumber': '7243 8 66687 2 1', + 'musicbrainz release group id': '0f21095a-e629-389c-981a-d9569e9673c9', + 'musicbrainz album status': 'official', + 'asin': 'B000641ZIQ', 'musicbrainz album release country': 'US', + 'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious', + 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', + 'replaygain_album_gain': '-8.26 dB'}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', 'artist': 'A Perfect Circle', 'audio_offset': 3647, 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', @@ -203,12 +223,11 @@ {'extra': {}, 'duration': 3.684716553287982, 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, 'samplerate': 44100}), - ('samples/multipagecomment.ogg', - {'extra': {}, 'duration': 3.684716553287982, - 'filesize': 135694, 'audio_offset': 0, 'bitrate': 112, - 'samplerate': 44100}), ('samples/multipage-setup.ogg', - {'extra': {}, 'genre': 'JRock', 'duration': 4.128798185941043, + {'extra': {'transcoded': 'mp3;241', 'replaygain_album_gain': '-10.29 dB', + 'replaygain_album_peak': '1.50579047', 'replaygain_track_peak': '1.17979193', + 'replaygain_track_gain': '-10.02 dB'}, + 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': '7', 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'SRCL-6240'}), @@ -226,13 +245,19 @@ 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': '2', 'year': '2007', 'composer': 'some composer', 'comment': 'A Comment'}), ('samples/test.opus', - {'extra': {}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, + {'extra': {'encoder': 'Lavc57.24.102 libopus', 'arrange': '\u6771\u65b9', + 'catalogid': 'ARCD0024', 'discid': 'A212230D', 'event': '\u4f8b\u5927\u796d5', + 'lyricist': 'Haruka', 'mastering': 'Hedonist', + 'origin': '\u6771\u65b9\u5e7b\u60f3\u90f7', 'originaltitle': 'Bad Apple!!', + 'performer': 'Masayoshi Minoshima', 'vocal': 'nomico'}, + 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': '1', 'disc': '1', 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', 'comment': 'ARCD0018 - Lovelight', 'disc_total': '1', 'track_total': '13'}), ('samples/8khz_5s.opus', - {'extra': {}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0}), + {'extra': {'encoder': 'opusenc from opus-tools 0.2'}, 'filesize': 7251, 'channels': 1, + 'samplerate': 48000, 'duration': 5.0}), ('samples/test_flac.oga', {'extra': {'copyright': 'test3', 'isrc': 'test4', 'lyrics': 'test7'}, 'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5', @@ -288,7 +313,8 @@ ('samples/flac1sMono.flac', {'extra': {}, 'genre': 'Avantgarde', 'album': 'alb', 'year': '2014', 'duration': 1.0, 'title': 'track', 'track': '23', 'artist': 'art', 'channels': 1, - 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100, 'bitdepth': 16}), + 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100, 'bitdepth': 16, + 'comment': 'hello'}), ('samples/flac453sStereo.flac', {'extra': {}, 'channels': 2, 'duration': 453.51473922902494, 'filesize': 84236, 'bitrate': 1.4859230399999999, 'samplerate': 44100, 'bitdepth': 16}), @@ -296,9 +322,16 @@ {'extra': {}, 'channels': 2, 'album': 'alb', 'year': '2014', 'duration': 1.4995238095238095, 'title': 'track', 'track': '23', 'artist': 'art', 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', - 'samplerate': 44100, 'bitdepth': 16}), + 'samplerate': 44100, 'bitdepth': 16, 'comment': 'hello'}), ('samples/flac_application.flac', - {'extra': {}, 'channels': 2, 'track_total': '11', + {'extra': {'replaygain_track_peak': '0.9976', + 'musicbrainz_albumartistid': 'e5c7b94f-e264-473c-bb0f-37c85d4d5c70', + 'musicbrainz_trackid': 'e65fb332-0c1e-4172-85e0-59cd37e5669e', + 'replaygain_album_gain': '-8.14 dB', 'labelid': 'RTRADLP480', + 'musicbrainz_albumid': '359a91e9-3bb3-4b60-a823-8aaa4bad1e36', + 'artistsort': 'Belle and Sebastian', 'replaygain_track_gain': '-8.08 dB', + 'replaygain_album_peak': '1.0000'}, + 'channels': 2, 'track_total': '11', 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, 'title': 'I Want the World to Stop', 'track': '4', 'artist': 'Belle and Sebastian', 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100, 'bitdepth': 16}), @@ -306,7 +339,15 @@ {'extra': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, 'bitrate': 10.186943678613627, 'samplerate': 44100, 'bitdepth': 16}), ('samples/variable-block.flac', - {'extra': {}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', + {'extra': {'discid': 'AA0B360B', + 'japanese title': ('\u30a2\u30c3\u30d7\u30eb\u30b7\u30fc\u30c9 ' + '\u30aa\u30ea\u30b8\u30ca\u30eb\u30fb\u30b5\u30a6' + '\u30f3\u30c9\u30c8\u30e9\u30c3\u30af'), + 'organization': 'Sony Music Records (SRCP-371)', + 'ripper': 'Exact Audio Copy 0.99pb5', + 'replaygain_album_gain': '-8.68 dB', 'replaygain_album_peak': '1.000000', + 'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000'}, + 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': '01', 'track_total': '11', 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, 'disc': '1', 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'bitdepth': 16, @@ -318,31 +359,38 @@ {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.68, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', - {'extra': {'text': 'ID\x00\ufeff8591671910'}, 'filesize': 64837, 'album': ' ', + {'extra': {'id': '8591671910'}, 'filesize': 64837, 'album': ' ', 'artist': '群星', 'disc': '0', 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': '0', 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'year': '2018'}), + 'year': '2018', 'comment': 'comment'}), ('samples/with_padded_id3_header.flac', {'extra': {}, 'filesize': 16070, 'album': 'album', 'artist': 'artist', 'bitrate': 283.4748, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'title': 'title', 'track': '1', 'year': '2018'}), + 'title': 'title', 'track': '1', 'year': '2018', 'comment': 'comment'}), ('samples/with_padded_id3_header2.flac', {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', 'artist': 'Unbekannter Künstler', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': '1', 'disc_total': '1', 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018'}), + 'title': 'Track01', 'track': '01', 'track_total': '05', 'year': '2018', + 'comment': 'comment'}), ('samples/flac_with_image.flac', - {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', + {'extra': {'band': ''}, 'filesize': 80000, 'album': 'smilin´ in circles', + 'artist': 'Andreas Kümmert', 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': '1', 'disc_total': '1', 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'bitdepth': 16, 'title': 'intro', 'track': '01', 'track_total': '8'}), # WMA ('samples/test2.wma', - {'extra': {}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', + {'extra': {'track': '0', 'lyrics': '', + 'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}', + 'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145', + 'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369, + 'averagelevel': 7291}, + 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': '1', 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), @@ -352,11 +400,27 @@ # ALAC/M4A/MP4 ('samples/test.m4a', - {'extra': {}, 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, + {'extra': {'itunsmpb': (' 00000000 00000840 000001DC 0000000000D3E9E4 00000000 00000000 ' + '00000000 00000000 00000000 00000000 00000000 00000000'), + 'itunnorm': (' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 ' + '00007E90 00007BFD 00009293'), + 'itunes_cddb_ids': '11++', 'ufidhttp://www.cddb.com/id3/taginfo1.html': + '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1'}, + 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': '11', 'track': '11', 'artist': 'Marian', 'filesize': 61432}), ('samples/test2.m4a', - {'extra': {'copyright': '℗ 1992 Ace Records'}, 'bitrate': 256.0, 'track': '1', + {'extra': {'copyright': '℗ 1992 Ace Records', + 'itunnorm': (' 00000371 00000481 00002E90 00002EA6 00000099 00000058 000073F3 ' + '0000768E 00000092 00000092'), + 'itunsmpb': (' 00000000 00000840 00000110 000000000070DEB0 00000000 00000000 ' + '00000000 00000000 00000000 00000000 00000000 00000000'), + 'itunmovi': ('\n\n\n\n\t' + 'asset-info\n\t\n\t\tflavor\n\t\t' + '2:256\n\t\n\n\n')}, + 'bitrate': 256.0, 'track': '1', 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', @@ -372,7 +436,8 @@ 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), ('samples/alac_file.m4a', - {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:'}, + {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:', + 'upc': '0034571177380'}, 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', 'filesize': 20000, 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 032f9ef..63fb1cf 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -289,7 +289,7 @@ def update(self, other, all_fields=False): for key in ['track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', 'genre', 'disc', 'disc_total', 'comment', 'composer', - '_image_data']: + 'extra', '_image_data']: if not getattr(self, key) and getattr(other, key): setattr(self, key, getattr(other, key)) if other.extra: @@ -308,15 +308,15 @@ class MP4(TinyTag): class Parser: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 ATOM_DECODER_BY_TYPE = { - 0: lambda x: x, # 'reserved', + # 0: 'reserved' 1: lambda x: codecs.decode(x, 'utf-8', 'replace'), # UTF-8 2: lambda x: codecs.decode(x, 'utf-16', 'replace'), # UTF-16 3: lambda x: codecs.decode(x, 's/jis', 'replace'), # S/JIS # 16: duration in millis 13: lambda x: x, # JPEG 14: lambda x: x, # PNG - 21: lambda x: struct.unpack('>b', x)[0], # BE Signed int - 22: lambda x: struct.unpack('>B', x)[0], # BE Unsigned int + # 21: BE Signed int + # 22: BE Unsigned int 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 24: lambda x: struct.unpack('>d', x)[0], # BE Float64 # 27: lambda x: x, # BMP @@ -366,6 +366,29 @@ def read_extended_descriptor(cls, esds_atom): if esds_atom.read(1) != b'\x80': break + @classmethod + def parse_custom_field(cls, data): + fh = BytesIO(data) + header_size = 8 + field_name = None + data_atom = b'' + atom_header = fh.read(header_size) + while len(atom_header) == header_size: + atom_size = struct.unpack('>I', atom_header[:4])[0] - header_size + atom_type = atom_header[4:] + if atom_type == b'name': + atom_value = fh.read(atom_size)[4:].lower() + field_name = 'extra.' + codecs.decode(atom_value, 'utf-8', 'replace') + elif atom_type == b'data': + data_atom = fh.read(atom_size) + else: + fh.seek(atom_size, os.SEEK_CUR) + atom_header = fh.read(header_size) # read next atom + if len(data_atom) < 8: + return {} + parser = cls.make_data_atom_parser(field_name) + return parser(data_atom) + @classmethod def parse_audio_sample_entry_mp4a(cls, data): # this atom also contains the esds atom: @@ -453,6 +476,7 @@ def debug_atom(cls, data): b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, b'gnre': {b'data': Parser.parse_id3v1_genre}, b'trkn': {b'data': Parser.make_number_parser('track', 'track_total')}, + b'----': Parser.parse_custom_field, # need test-data for this # b'tmpo': {b'data': Parser.make_data_atom_parser('extra.bmp')}, }}}}} @@ -538,13 +562,13 @@ class ID3(TinyTag): 'TPE2': 'albumartist', 'TCOM': 'composer', 'WXXX': 'extra.url', 'TSRC': 'extra.isrc', - 'TXXX': 'extra.text', 'TKEY': 'extra.initial_key', 'USLT': 'extra.lyrics', 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', } IMAGE_FRAME_IDS = {'APIC', 'PIC'} - PARSABLE_FRAME_IDS = set(FRAME_ID_TO_FIELD.keys()).union(IMAGE_FRAME_IDS) + CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} + PARSABLE_FRAME_IDS = set(FRAME_ID_TO_FIELD.keys()).union(IMAGE_FRAME_IDS, CUSTOM_FRAME_IDS) _MAX_ESTIMATION_SEC = 30 _CBR_DETECTION_FRAME_COUNT = 5 _USE_XING_HEADER = True # much faster, but can be deactivated for testing @@ -826,6 +850,12 @@ def _parse_frame(self, fh, id3version=False): if fieldname: language = fieldname in ("comment", "extra.lyrics") self._set_field(fieldname, self._decode_string(content, language)) + elif frame_id in self.CUSTOM_FRAME_IDS: + # custom fields + custom_text = self._decode_string(content) + custom_field_name, _separator, value = custom_text.partition('\x00') + if custom_field_name: + self._set_field('extra.' + custom_field_name.lower(), value.lstrip(u'\ufeff')) elif frame_id in self.IMAGE_FRAME_IDS and self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames encoding = content[0:1] @@ -1005,10 +1035,8 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): 'genre': 'genre', 'description': 'comment', 'comment': 'comment', + 'comments': 'comment', 'composer': 'composer', - 'copyright': 'extra.copyright', - 'isrc': 'extra.isrc', - 'lyrics': 'extra.lyrics', } if contains_vendor: vendor_length = struct.unpack('I', fh.read(4))[0] @@ -1031,9 +1059,9 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): else: if DEBUG: stderr('Found Vorbis Comment', key, value[:64]) - fieldname = comment_type_to_attr_mapping.get(key_lowercase) - if fieldname: - self._set_field(fieldname, value) + fieldname = comment_type_to_attr_mapping.get( + key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' + self._set_field(fieldname, value) def _parse_pages(self, fh): # for the spec, see: https://wiki.xiph.org/Ogg @@ -1337,11 +1365,16 @@ def _parse_tag(self, fh): name = self.__decode_string(fh.read(name_len)) value_type = _bytes_to_int_le(fh.read(2)) value_len = _bytes_to_int_le(fh.read(2)) - value = fh.read(value_len) - field_name = mapping.get(name) - if field_name: - field_value = self.__decode_ext_desc(value_type, value) - self._set_field(field_name, field_value) + if value_type == 1: + fh.seek(value_len, os.SEEK_CUR) # skip byte values + continue + field_name = mapping.get(name) # try to get normalized field name + if field_name is None: # custom field + if name.startswith('WM/'): + name = name[3:] + field_name = 'extra.' + name.lower() + field_value = self.__decode_ext_desc(value_type, fh.read(value_len)) + self._set_field(field_name, field_value) elif object_id == Wma.ASF_FILE_PROPERTY_OBJECT: blocks = self.read_blocks(fh, [ ('file_id', 16, False), From 453bfd044d635c8c699539bdbd68fb7ac216d60c Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 26 Feb 2024 19:28:59 +0200 Subject: [PATCH 110/305] Mark subclasses as private (#194) They are not meant to be used directly, and were never documented. --- tinytag/__init__.py | 2 +- tinytag/tests/test_all.py | 36 +++++------ tinytag/tinytag.py | 128 +++++++++++++++++++------------------- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 6859238..5f2cfcd 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,7 +4,7 @@ __version__ = '1.10.1' import sys -from .tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac # noqa: F401 +from .tinytag import TinyTag, TinyTagException # noqa: F401 if __name__ == '__main__': diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index d412c20..6b7c816 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -20,7 +20,7 @@ import pytest from pytest import raises -from tinytag.tinytag import TinyTag, TinyTagException, ID3, Ogg, Wave, Flac, Wma, MP4, Aiff +from tinytag.tinytag import TinyTag, TinyTagException, _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff try: from collections import OrderedDict @@ -641,39 +641,39 @@ def test_unsubclassed_tinytag_parse_tag(): def test_mp3_length_estimation(): - ID3.set_estimation_precision(0.7) + _ID3.set_estimation_precision(0.7) tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) assert 3.5 < tag.duration < 4.0 @pytest.mark.xfail(raises=TinyTagException) def test_unexpected_eof(): - ID3.get(os.path.join(testfolder, 'samples/incomplete.mp3')) + _ID3.get(os.path.join(testfolder, 'samples/incomplete.mp3')) @pytest.mark.xfail(raises=TinyTagException) def test_invalid_flac_file(): - Flac.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) + _Flac.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) @pytest.mark.xfail(raises=TinyTagException) def test_invalid_mp3_file(): - ID3.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + _ID3.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) @pytest.mark.xfail(raises=TinyTagException) def test_invalid_ogg_file(): - Ogg.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + _Ogg.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) @pytest.mark.xfail(raises=TinyTagException) def test_invalid_wave_file(): - Wave.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) + _Wave.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) @pytest.mark.xfail(raises=TinyTagException) def test_invalid_aiff_file(): - Aiff.get(os.path.join(testfolder, 'samples/ilbm.aiff')) + _Aiff.get(os.path.join(testfolder, 'samples/ilbm.aiff')) def test_unpad(): @@ -798,16 +798,16 @@ def test_aiff_image_loading(): @pytest.mark.parametrize("testfile,expected", [ pytest.param(testfile, expected) for testfile, expected in [ - ('samples/detect_mp3_id3.x', ID3), - ('samples/detect_mp3_fffb.x', ID3), - ('samples/detect_ogg_flac.x', Ogg), - ('samples/detect_ogg_opus.x', Ogg), - ('samples/detect_ogg_vorbis.x', Ogg), - ('samples/detect_wav.x', Wave), - ('samples/detect_flac.x', Flac), - ('samples/detect_wma.x', Wma), - ('samples/detect_mp4_m4a.x', MP4), - ('samples/detect_aiff.x', Aiff), + ('samples/detect_mp3_id3.x', _ID3), + ('samples/detect_mp3_fffb.x', _ID3), + ('samples/detect_ogg_flac.x', _Ogg), + ('samples/detect_ogg_opus.x', _Ogg), + ('samples/detect_ogg_vorbis.x', _Ogg), + ('samples/detect_wav.x', _Wave), + ('samples/detect_flac.x', _Flac), + ('samples/detect_wma.x', _Wma), + ('samples/detect_mp4_m4a.x', _MP4), + ('samples/detect_aiff.x', _Aiff), ] ]) def test_detect_magic_headers(testfile, expected): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 63fb1cf..3c1b0ee 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -138,13 +138,13 @@ def get_image(self): def _get_parser_for_filename(cls, filename): if cls._file_extension_mapping is None: cls._file_extension_mapping = { - (b'.mp1', b'.mp2', b'.mp3'): ID3, - (b'.oga', b'.ogg', b'.opus', b'.spx'): Ogg, - (b'.wav',): Wave, - (b'.flac',): Flac, - (b'.wma',): Wma, - (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): MP4, - (b'.aiff', b'.aifc', b'.aif', b'.afc'): Aiff, + (b'.mp1', b'.mp2', b'.mp3'): _ID3, + (b'.oga', b'.ogg', b'.opus', b'.spx'): _Ogg, + (b'.wav',): _Wave, + (b'.flac',): _Flac, + (b'.wma',): _Wma, + (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): _MP4, + (b'.aiff', b'.aifc', b'.aif', b'.afc'): _Aiff, } if not isinstance(filename, bytes): # convert filename to binary try: @@ -161,21 +161,21 @@ def _get_parser_for_file_handle(cls, fh): # https://en.wikipedia.org/wiki/List_of_file_signatures if cls._magic_bytes_mapping is None: cls._magic_bytes_mapping = { - b'^ID3': ID3, - b'^\xff\xfb': ID3, - b'^OggS.........................FLAC': Ogg, - b'^OggS........................Opus': Ogg, - b'^OggS........................Speex': Ogg, - b'^OggS.........................vorbis': Ogg, - b'^RIFF....WAVE': Wave, - b'^fLaC': Flac, - b'^\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': Wma, - b'....ftypM4A': MP4, # https://www.file-recovery.com/m4a-signature-format.htm - b'....ftypaax': MP4, # Audible proprietary M4A container - b'....ftypaaxc': MP4, # Audible proprietary M4A container - b'\xff\xf1': MP4, # https://www.garykessler.net/library/file_sigs.html - b'^FORM....AIFF': Aiff, - b'^FORM....AIFC': Aiff, + b'^ID3': _ID3, + b'^\xff\xfb': _ID3, + b'^OggS.........................FLAC': _Ogg, + b'^OggS........................Opus': _Ogg, + b'^OggS........................Speex': _Ogg, + b'^OggS.........................vorbis': _Ogg, + b'^RIFF....WAVE': _Wave, + b'^fLaC': _Flac, + b'^\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': _Wma, + b'....ftypM4A': _MP4, # https://www.file-recovery.com/m4a-signature-format.htm + b'....ftypaax': _MP4, # Audible proprietary M4A container + b'....ftypaaxc': _MP4, # Audible proprietary M4A container + b'\xff\xf1': _MP4, # https://www.garykessler.net/library/file_sigs.html + b'^FORM....AIFF': _Aiff, + b'^FORM....AIFC': _Aiff, } header = fh.peek(max(len(sig) for sig in cls._magic_bytes_mapping)) for magic, parser in cls._magic_bytes_mapping.items(): @@ -261,8 +261,8 @@ def _set_field(self, fieldname, value, overwrite=True): else: # funkier: the TCO may contain genres in parens, e.g. '(13)' if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): genre_id = int(value[1:-1]) - if 0 <= genre_id < len(ID3.ID3V1_GENRES): - value = ID3.ID3V1_GENRES[genre_id] + if 0 <= genre_id < len(_ID3.ID3V1_GENRES): + value = _ID3.ID3V1_GENRES[genre_id] if fieldname in ("track", "disc", "track_total", "disc_total"): # Converting to string for type consistency value = str(value) @@ -301,7 +301,7 @@ def _unpad(s): return s.strip('\x00') -class MP4(TinyTag): +class _MP4(TinyTag): # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html @@ -356,8 +356,8 @@ def _(data_atom): def parse_id3v1_genre(cls, data_atom): # dunno why the genre is offset by -1 but that's how mutagen does it idx = struct.unpack('>H', data_atom[8:])[0] - 1 - if idx < len(ID3.ID3V1_GENRES): - return {'genre': ID3.ID3V1_GENRES[idx]} + if idx < len(_ID3.ID3V1_GENRES): + return {'genre': _ID3.ID3V1_GENRES[idx]} return {'genre': None} @classmethod @@ -549,7 +549,7 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): atom_header = fh.read(header_size) # read next atom -class ID3(TinyTag): +class _ID3(TinyTag): FRAME_ID_TO_FIELD = { # Mapping from Frame ID to a field of the TinyTag 'COMM': 'comment', 'COM': 'comment', 'TRCK': 'track', 'TRK': 'track', @@ -676,7 +676,7 @@ def _determine_duration(self, fh): if self._bytepos_after_id3v2 is None: self._parse_id3v2_header(fh) - max_estimation_frames = (ID3._MAX_ESTIMATION_SEC * 44100) // ID3.samples_per_frame + max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3.samples_per_frame frame_size_accu = 0 header_bytes = 4 frames = 0 # count frames for determining mp3 duration @@ -708,23 +708,23 @@ def _determine_duration(self, fh): continue try: self.channels = self.channels_per_channel_mode[channel_mode] - frame_bitrate = ID3.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] - self.samplerate = ID3.samplerates[mpeg_id][sr_id] + frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] + self.samplerate = self.samplerates[mpeg_id][sr_id] except (IndexError, TypeError): raise TinyTagException('mp3 parsing failed') # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate - if frames == 0 and ID3._USE_XING_HEADER: + if frames == 0 and self._USE_XING_HEADER: xing_header_offset = b.find(b'Xing') if xing_header_offset != -1: fh.seek(xing_header_offset, os.SEEK_CUR) - xframes, byte_count, toc, vbr_scale = ID3._parse_xing_header(fh) + xframes, byte_count, toc, vbr_scale = self._parse_xing_header(fh) if xframes and xframes != 0 and byte_count: # MPEG-2 Audio Layer III uses 576 samples per frame - samples_per_frame = 576 if mpeg_id <= 2 else ID3.samples_per_frame + samples_per_frame = 576 if mpeg_id <= 2 else self.samples_per_frame self.duration = xframes * samples_per_frame / float(self.samplerate) - # self.duration = (xframes * ID3.samples_per_frame / self.samplerate + # self.duration = (xframes * self.samples_per_frame / self.samplerate # / self.channels) # noqa self.bitrate = byte_count * 8 / self.duration / 1000 self.audio_offset = fh.tell() @@ -735,20 +735,20 @@ def _determine_duration(self, fh): bitrate_accu += frame_bitrate if frames == 1: self.audio_offset = fh.tell() - if frames <= ID3._CBR_DETECTION_FRAME_COUNT: + if frames <= self._CBR_DETECTION_FRAME_COUNT: last_bitrates.append(frame_bitrate) fh.seek(4, os.SEEK_CUR) # jump over peeked bytes frame_length = (144000 * frame_bitrate) // self.samplerate + padding frame_size_accu += frame_length # if bitrate does not change over time its probably CBR - is_cbr = (frames == ID3._CBR_DETECTION_FRAME_COUNT and len(set(last_bitrates)) == 1) + is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(set(last_bitrates)) == 1) if frames == max_estimation_frames or is_cbr: # try to estimate duration fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) audio_stream_size = fh.tell() - self.audio_offset est_frame_count = audio_stream_size / (frame_size_accu / frames) - samples = est_frame_count * ID3.samples_per_frame + samples = est_frame_count * self.samples_per_frame self.duration = samples / self.samplerate self.bitrate = bitrate_accu / frames return @@ -756,7 +756,7 @@ def _determine_duration(self, fh): if frame_length > 1: # jump over current frame body fh.seek(frame_length - header_bytes, os.SEEK_CUR) if self.samplerate: - self.duration = frames * ID3.samples_per_frame / self.samplerate + self.duration = frames * self.samples_per_frame / self.samplerate def _parse_tag(self, fh): self._parse_id3v2(fh) @@ -815,8 +815,8 @@ def asciidecode(x): comment = comment[:-2] self._set_field('comment', asciidecode(comment), overwrite=False) genre_id = ord(fields[124:125]) - if genre_id < len(ID3.ID3V1_GENRES): - self._set_field('genre', ID3.ID3V1_GENRES[genre_id], overwrite=False) + if genre_id < len(self.ID3V1_GENRES): + self._set_field('genre', self.ID3V1_GENRES[genre_id], overwrite=False) @staticmethod def index_utf16(s, search): @@ -842,11 +842,11 @@ def _parse_frame(self, fh, id3version=False): (frame_id, fh.tell(), fh.tell() + frame_size, self.filesize)) if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. - if frame_id not in ID3.PARSABLE_FRAME_IDS: # jump over unparsable frames + if frame_id not in self.PARSABLE_FRAME_IDS: # jump over unparsable frames fh.seek(frame_size, os.SEEK_CUR) return frame_size content = fh.read(frame_size) - fieldname = ID3.FRAME_ID_TO_FIELD.get(frame_id) + fieldname = self.FRAME_ID_TO_FIELD.get(frame_id) if fieldname: language = fieldname in ("comment", "extra.lyrics") self._set_field(fieldname, self._decode_string(content, language)) @@ -865,7 +865,7 @@ def _parse_frame(self, fh, id3version=False): desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mimetype, pictype(1) # latin1 and utf-8 are 1 byte termination = b'\x00' if encoding in (b'\x00', b'\x03') else b'\x00\x00' - desc_length = ID3.index_utf16(content[desc_start_pos:], termination) + desc_length = self.index_utf16(content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_length + len(termination) self._image_data = content[desc_end_pos:] return frame_size @@ -920,7 +920,7 @@ def _calc_size(self, bytestr, bits_per_byte): return reduce(lambda accu, elem: (accu << bits_per_byte) + elem, bytestr, 0) -class Ogg(TinyTag): +class _Ogg(TinyTag): def __init__(self, filehandler, filesize, *args, **kwargs): TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) self._tags_parsed = False @@ -981,7 +981,7 @@ def _parse_tag(self, fh): elif packet[0:5] == b'\x7fFLAC': # https://xiph.org/flac/ogg_mapping.html walker.seek(9, os.SEEK_CUR) # jump over header name, version and number of headers - flactag = Flac(io.BufferedReader(walker), self.filesize) + flactag = _Flac(io.BufferedReader(walker), self.filesize) flactag.load(tags=self._parse_tags, duration=self._parse_duration, image=self._load_image) self.update(flactag, all_fields=True) @@ -991,7 +991,7 @@ def _parse_tag(self, fh): if self._parse_tags: meta_header = struct.unpack('B3B', walker.read(4)) block_type = meta_header[0] & 0x7f - if block_type == Flac.METADATA_VORBIS_COMMENT: + if block_type == _Flac.METADATA_VORBIS_COMMENT: self._parse_vorbis_comment(walker) check_flac_second_packet = False elif packet[0:8] == b'Speex ': @@ -1055,7 +1055,7 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): if key_lowercase == "metadata_block_picture" and self._load_image: if DEBUG: stderr('Found Vorbis Image', key, value[:64]) - self._image_data = Flac._parse_image(BytesIO(base64.b64decode(value))) + self._image_data = _Flac._parse_image(BytesIO(base64.b64decode(value))) else: if DEBUG: stderr('Found Vorbis Comment', key, value[:64]) @@ -1091,7 +1091,7 @@ def _parse_pages(self, fh): header_data = fh.read(27) -class Wave(TinyTag): +class _Wave(TinyTag): # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html riff_mapping = { b'INAM': 'title', @@ -1156,7 +1156,7 @@ def _determine_duration(self, fh): self._set_field(fieldname, codecs.decode(data, 'utf-8')) field = sub_fh.read(4) elif subchunkid in (b'id3 ', b'ID3 ') and self._parse_tags: - id3 = ID3(fh, 0) + id3 = _ID3(fh, 0) id3.load(tags=True, duration=False, image=self._load_image) self.update(id3) else: # some other chunk, just skip the data @@ -1169,7 +1169,7 @@ def _parse_tag(self, fh): self._determine_duration(fh) # parse whole file to determine tags:( -class Flac(TinyTag): +class _Flac(TinyTag): METADATA_STREAMINFO = 0 METADATA_PADDING = 1 METADATA_APPLICATION = 2 @@ -1184,7 +1184,7 @@ def load(self, tags, duration, image=False): self._load_image = image header = self._filehandler.peek(4) if header[:3] == b'ID3': # parse ID3 header if it exists - id3 = ID3(self._filehandler, 0) + id3 = _ID3(self._filehandler, 0) id3._parse_id3v2(self._filehandler) self.update(id3) header = self._filehandler.peek(4) # after ID3 should be fLaC @@ -1202,7 +1202,7 @@ def _determine_duration(self, fh): is_last_block = meta_header[0] & 0x80 size = _bytes_to_int(meta_header[1:4]) # http://xiph.org/flac/format.html#metadata_block_streaminfo - if block_type == Flac.METADATA_STREAMINFO and self._parse_duration: + if block_type == self.METADATA_STREAMINFO and self._parse_duration: stream_info_header = fh.read(size) if len(stream_info_header) < 34: # invalid streaminfo return @@ -1234,11 +1234,11 @@ def _determine_duration(self, fh): self.duration = total_samples / self.samplerate if self.duration > 0: self.bitrate = self.filesize / self.duration * 8 / 1000 - elif block_type == Flac.METADATA_VORBIS_COMMENT and self._parse_tags: - oggtag = Ogg(fh, 0) + elif block_type == self.METADATA_VORBIS_COMMENT and self._parse_tags: + oggtag = _Ogg(fh, 0) oggtag._parse_vorbis_comment(fh) self.update(oggtag) - elif block_type == Flac.METADATA_PICTURE and self._load_image: + elif block_type == self.METADATA_PICTURE and self._load_image: self._image_data = self._parse_image(fh) elif block_type >= 127: return # invalid block type @@ -1262,7 +1262,7 @@ def _parse_image(fh): return fh.read(pic_len) -class Wma(TinyTag): +class _Wma(TinyTag): ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' b'\xa0\xc9^\xa8P') @@ -1330,7 +1330,7 @@ def _parse_tag(self, fh): object_size = _bytes_to_int_le(fh.read(8)) if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. - if object_id == Wma.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: + if object_id == self.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: len_blocks = self.read_blocks(fh, [ ('title_length', 2, True), ('author_length', 2, True), @@ -1348,7 +1348,7 @@ def _parse_tag(self, fh): for field_name, bytestring in data_blocks.items(): if field_name: self._set_field(field_name, self.__decode_string(bytestring)) - elif object_id == Wma.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: + elif object_id == self.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: mapping = { 'WM/TrackNumber': 'track', 'WM/PartOfSet': 'disc', @@ -1375,7 +1375,7 @@ def _parse_tag(self, fh): field_name = 'extra.' + name.lower() field_value = self.__decode_ext_desc(value_type, fh.read(value_len)) self._set_field(field_name, field_value) - elif object_id == Wma.ASF_FILE_PROPERTY_OBJECT: + elif object_id == self.ASF_FILE_PROPERTY_OBJECT: blocks = self.read_blocks(fh, [ ('file_id', 16, False), ('file_size', 8, False), @@ -1393,7 +1393,7 @@ def _parse_tag(self, fh): # to get the actual duration of the file preroll = blocks.get('preroll') / 1000 self.duration = max(blocks.get('play_duration') / 10000000 - preroll, 0.0) - elif object_id == Wma.ASF_STREAM_PROPERTIES_OBJECT: + elif object_id == self.ASF_STREAM_PROPERTIES_OBJECT: blocks = self.read_blocks(fh, [ ('stream_type', 16, False), ('error_correction_type', 16, False), @@ -1404,7 +1404,7 @@ def _parse_tag(self, fh): ('reserved', 4, False) ]) already_read = 0 - if blocks['stream_type'] == Wma.STREAM_TYPE_ASF_AUDIO_MEDIA: + if blocks['stream_type'] == self.STREAM_TYPE_ASF_AUDIO_MEDIA: stream_info = self.read_blocks(fh, [ ('codec_id_format_tag', 2, True), ('number_of_channels', 2, True), @@ -1424,7 +1424,7 @@ def _parse_tag(self, fh): fh.seek(object_size - 24, os.SEEK_CUR) # read over onknown object ids -class Aiff(TinyTag): +class _Aiff(TinyTag): # # AIFF is part of the IFF family of file formats. # @@ -1492,7 +1492,7 @@ def _parse_tag(self, fh): self.samplerate = self.duration = self.bitrate = None # invalid sample rate fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk elif sub_chunk_id in (b'id3 ', b'ID3 ') and self._parse_tags: - id3 = ID3(fh, 0) + id3 = _ID3(fh, 0) id3.load(tags=True, duration=False, image=self._load_image) self.update(id3) elif sub_chunk_id == b'SSND': From 83ad8764d36ce4e1f1256af9ae082b0303cb7a3b Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 26 Feb 2024 22:45:45 +0200 Subject: [PATCH 111/305] Don't include format-specific code in _set_field() (#196) --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 47 ++++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 6b7c816..fe4ca23 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -385,7 +385,7 @@ # WMA ('samples/test2.wma', - {'extra': {'track': '0', 'lyrics': '', + {'extra': {'track': 0, 'lyrics': '', 'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}', 'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145', 'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3c1b0ee..68c929a 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -254,26 +254,8 @@ def _set_field(self, fieldname, value, overwrite=True): return if DEBUG: stderr('Setting field "%s" to "%s"' % (fieldname, value)) - if fieldname == 'genre': - genre_id = 255 - if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field - genre_id = int(value) - else: # funkier: the TCO may contain genres in parens, e.g. '(13)' - if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): - genre_id = int(value[1:-1]) - if 0 <= genre_id < len(_ID3.ID3V1_GENRES): - value = _ID3.ID3V1_GENRES[genre_id] - if fieldname in ("track", "disc", "track_total", "disc_total"): - # Converting to string for type consistency - value = str(value) - mapping = [(fieldname, value)] - if fieldname in ("track", "disc"): - if type(value).__name__ in ('str', 'unicode') and '/' in value: - value, total = value.split('/')[:2] - mapping = [(fieldname, str(value)), ("%s_total" % fieldname, str(total))] - for k, v in mapping: - if overwrite or not get_func(write_dest, k): - set_func(write_dest, k, v) + if overwrite or not get_func(write_dest, fieldname): + set_func(write_dest, fieldname, value) def _determine_duration(self, fh): raise NotImplementedError() @@ -349,7 +331,7 @@ def _(data_atom): number_data = data_atom[8:14] numbers = struct.unpack('>HHH', number_data) # for some reason the first number is always irrelevant. - return {fieldname1: numbers[1], fieldname2: numbers[2]} + return {fieldname1: str(numbers[1]), fieldname2: str(numbers[2])} return _ @classmethod @@ -848,8 +830,22 @@ def _parse_frame(self, fh, id3version=False): content = fh.read(frame_size) fieldname = self.FRAME_ID_TO_FIELD.get(frame_id) if fieldname: - language = fieldname in ("comment", "extra.lyrics") - self._set_field(fieldname, self._decode_string(content, language)) + language = fieldname in ('comment', 'extra.lyrics') + value = self._decode_string(content, language) + if fieldname in ('track', 'disc'): + if '/' in value: + value, total = value.split('/')[:2] + self._set_field('%s_total' % fieldname, total) + elif fieldname == 'genre': + genre_id = 255 + if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field + genre_id = int(value) + else: # funkier: the TCO may contain genres in parens, e.g. '(13)' + if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): + genre_id = int(value[1:-1]) + if 0 <= genre_id < len(_ID3.ID3V1_GENRES): + value = _ID3.ID3V1_GENRES[genre_id] + self._set_field(fieldname, value) elif frame_id in self.CUSTOM_FRAME_IDS: # custom fields custom_text = self._decode_string(content) @@ -1061,6 +1057,9 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): stderr('Found Vorbis Comment', key, value[:64]) fieldname = comment_type_to_attr_mapping.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' + if fieldname in ('track', 'disc') and '/' in value: + value, total = value.split('/')[:2] + self._set_field('%s_total' % fieldname, total) self._set_field(fieldname, value) def _parse_pages(self, fh): @@ -1374,6 +1373,8 @@ def _parse_tag(self, fh): name = name[3:] field_name = 'extra.' + name.lower() field_value = self.__decode_ext_desc(value_type, fh.read(value_len)) + if field_name in ('track', 'disc'): + field_value = str(field_value) self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT: blocks = self.read_blocks(fh, [ From fc42879803fed0105e747c9b6dd4b2fb3221fda9 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 26 Feb 2024 23:59:48 +0200 Subject: [PATCH 112/305] Convert track/disc fields to integers (#197) --- .../samples/flac_invalid_track_number.flac | Bin 0 -> 235 bytes .../samples/wav_invalid_track_number.wav | Bin 0 -> 8908 bytes .../samples/wma_invalid_track_number.wma | Bin 0 -> 3940 bytes tinytag/tests/test_all.py | 126 ++++++++++-------- tinytag/tinytag.py | 77 +++++++---- 5 files changed, 121 insertions(+), 82 deletions(-) create mode 100644 tinytag/tests/samples/flac_invalid_track_number.flac create mode 100644 tinytag/tests/samples/wav_invalid_track_number.wav create mode 100644 tinytag/tests/samples/wma_invalid_track_number.wma diff --git a/tinytag/tests/samples/flac_invalid_track_number.flac b/tinytag/tests/samples/flac_invalid_track_number.flac new file mode 100644 index 0000000000000000000000000000000000000000..d32f29c7f4a450687f7a0eae46c1a57559cfc245 GIT binary patch literal 235 zcmYfENpxmlU{DfZ5CBr#3=F(nM;tydFbG<`Dvw*85kKGm>C%Z)iDSIaY&G(v$tQUuaj$#ZF*u+ zQet|lAW+!FGuS!AKg7`oCMX0`iy;b9ifTY31A`(^Iezf}$4L%`sX#S~d;kBaUHXoRF3L3f;6waj5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ r0R#|0009ILKmY**5cpT%Ze4$iC2QQHvaU?yzWLAOw?+1B```Nng^&|n literal 0 HcmV?d00001 diff --git a/tinytag/tests/samples/wma_invalid_track_number.wma b/tinytag/tests/samples/wma_invalid_track_number.wma new file mode 100644 index 0000000000000000000000000000000000000000..24780dd0bdf18d79dcf2e7fc15cea4e3bcf59865 GIT binary patch literal 3940 zcmXp|+f>?@c3yDVO@>trN#}AlF)@Gv8<1pVT6kx5kNe8=f_+aI4)7=hr)EGTF~|jc zP@x99tM@j}Wn_eMCv1~v1~D&ea03g;@IWL$mNWp_Ak6TEkzp(I{&+o*J&z&wBteuh z2wu9pdKXCi8AP0o!2~Gt>hzDJ>!Zs(TGChbxcu@tB?VQ@z%WHd*=5F*i=GP|Wrh7* z3qlYw4ofavVt;%|aQX*^1t;TH1e}2CVP?=^2xstR&}VRE$YV%m$Y)4l$ON*|8G;#7 z8A^ZNMtBuNMkT#FksMQFa**d(f|k<7^I*k1pw6)0d@KV)j>=FX%+yQ z2@+2NlIcLR85rcC>O+8{i9oxv8T=SZ8FGOtQh_4a^-N^Hvwb^Kh(OdaFgVQ1{NWpY zUU0=ILMm>3cnjnPt}4%7}NpotI&6#!CZXlgfs(hwE^ zat{bFGBz\n\n\n\t' 'asset-info\n\t\n\t\tflavor\n\t\t' '2:256\n\t\n\n\n')}, - 'bitrate': 256.0, 'track': '1', + 'bitrate': 256.0, 'track': 1, 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', - 'artist': 'Millie Jackson', 'track_total': '9', 'disc_total': '1', 'genre': 'R&B/Soul', - 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': '1', + 'artist': 'Millie Jackson', 'track_total': 9, 'disc_total': 1, 'genre': 'R&B/Soul', + 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': 1, 'title': 'Go Out and Get Some', 'comment': "Millie Jackson - Get It Out 'cha System - 1978", 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), @@ -441,8 +451,8 @@ 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', 'filesize': 20000, 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', - 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': '14', - 'track_total': '27', 'disc': '1', 'disc_total': '1', 'samplerate': 44100, + 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': 14, + 'track_total': 27, 'disc': 1, 'disc_total': 1, 'samplerate': 44100, 'duration': 166.62639455782312, 'genre': 'Classical', 'albumartist': 'Howard Shelley', 'channels': 2, 'bitrate': 436.743, 'bitdepth': 16}), ('samples/mpeg4_desc_cmt.m4a', { @@ -462,7 +472,7 @@ # AIFF ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', - 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'bitdepth': 16, 'track': '1', + 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'bitdepth': 16, 'track': 1, 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', 'year': '2014'}), ('samples/test.aiff', @@ -833,5 +843,5 @@ def test_to_str(): '"audio_offset": 2225, "bitdepth": null, "bitrate": 160.0, "channels": 2, ' '"comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, ' '"disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, ' - '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": "3", ' - '"track_total": "11", "year": "2004"}') + '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": 3, ' + '"track_total": 11, "year": "2004"}') diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 68c929a..d28d892 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -331,7 +331,7 @@ def _(data_atom): number_data = data_atom[8:14] numbers = struct.unpack('>HHH', number_data) # for some reason the first number is always irrelevant. - return {fieldname1: str(numbers[1]), fieldname2: str(numbers[2])} + return {fieldname1: numbers[1], fieldname2: numbers[2]} return _ @classmethod @@ -793,7 +793,7 @@ def asciidecode(x): self._set_field('year', asciidecode(fields[90:94]), overwrite=False) comment = fields[94:124] if b'\x00\x00' < comment[-2:] < b'\x01\x00': - self._set_field('track', str(ord(comment[-1:])), overwrite=False) + self._set_field('track', ord(comment[-1:]), overwrite=False) comment = comment[:-2] self._set_field('comment', asciidecode(comment), overwrite=False) genre_id = ord(fields[124:125]) @@ -832,20 +832,26 @@ def _parse_frame(self, fh, id3version=False): if fieldname: language = fieldname in ('comment', 'extra.lyrics') value = self._decode_string(content, language) - if fieldname in ('track', 'disc'): - if '/' in value: - value, total = value.split('/')[:2] - self._set_field('%s_total' % fieldname, total) - elif fieldname == 'genre': - genre_id = 255 - if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field - genre_id = int(value) - else: # funkier: the TCO may contain genres in parens, e.g. '(13)' - if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): - genre_id = int(value[1:-1]) - if 0 <= genre_id < len(_ID3.ID3V1_GENRES): - value = _ID3.ID3V1_GENRES[genre_id] - self._set_field(fieldname, value) + try: + if fieldname in ('track', 'disc'): + if '/' in value: + value, total = value.split('/')[:2] + self._set_field('%s_total' % fieldname, int(total)) + value = int(value) + elif fieldname == 'genre': + genre_id = 255 + if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field + genre_id = int(value) + else: # funkier: the TCO may contain genres in parens, e.g. '(13)' + if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): + genre_id = int(value[1:-1]) + if 0 <= genre_id < len(_ID3.ID3V1_GENRES): + value = _ID3.ID3V1_GENRES[genre_id] + except ValueError as exc: + if DEBUG: + stderr('Failed to read %s: %s' % (fieldname, exc)) + else: + self._set_field(fieldname, value) elif frame_id in self.CUSTOM_FRAME_IDS: # custom fields custom_text = self._decode_string(content) @@ -1057,10 +1063,19 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): stderr('Found Vorbis Comment', key, value[:64]) fieldname = comment_type_to_attr_mapping.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' - if fieldname in ('track', 'disc') and '/' in value: - value, total = value.split('/')[:2] - self._set_field('%s_total' % fieldname, total) - self._set_field(fieldname, value) + try: + if fieldname in ('track', 'disc'): + if '/' in value: + value, total = value.split('/')[:2] + self._set_field('%s_total' % fieldname, int(total)) + value = int(value) + elif fieldname in ('track_total', 'disc_total'): + value = int(value) + except ValueError as exc: + if DEBUG: + stderr('Failed to read %s: %s' % (fieldname, exc)) + else: + self._set_field(fieldname, value) def _parse_pages(self, fh): # for the spec, see: https://wiki.xiph.org/Ogg @@ -1101,6 +1116,7 @@ class _Wave(TinyTag): b'ICRD': 'year', b'IGNR': 'genre', b'ISRC': 'extra.isrc', + b'IPRT': 'track', b'ITRK': 'track', b'TRCK': 'track', b'PRT1': 'track', @@ -1152,7 +1168,15 @@ def _determine_duration(self, fh): data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte fieldname = self.riff_mapping.get(field) if fieldname: - self._set_field(fieldname, codecs.decode(data, 'utf-8')) + value = codecs.decode(data, 'utf-8') + try: + if fieldname == 'track': + value = int(value) + except ValueError as exc: + if DEBUG: + stderr('Failed to read %s: %s' % (fieldname, exc)) + else: + self._set_field(fieldname, value) field = sub_fh.read(4) elif subchunkid in (b'id3 ', b'ID3 ') and self._parse_tags: id3 = _ID3(fh, 0) @@ -1373,9 +1397,14 @@ def _parse_tag(self, fh): name = name[3:] field_name = 'extra.' + name.lower() field_value = self.__decode_ext_desc(value_type, fh.read(value_len)) - if field_name in ('track', 'disc'): - field_value = str(field_value) - self._set_field(field_name, field_value) + try: + if field_name in ('track', 'disc'): + field_value = int(field_value) + except ValueError as exc: + if DEBUG: + stderr('Failed to read %s: %s' % (field_name, exc)) + else: + self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT: blocks = self.read_blocks(fh, [ ('file_id', 16, False), From 06a44a71d90a9a6970ec7a7e1de3b1ec00493744 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 00:11:04 +0200 Subject: [PATCH 113/305] Remove support for Python 2 (#195) --- .github/workflows/tests.yml | 2 +- README.md | 2 +- setup.cfg | 3 +- tinytag/tests/test_cli.py | 6 +-- tinytag/tinytag.py | 83 +++++++++++++++++-------------------- 5 files changed, 43 insertions(+), 53 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7edc946..1089d4f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-2.7', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/README.md b/README.md index 7ce1b72..ead3fe5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ tinytag is a library for reading music meta data of most common audio files in p * WMA * AIFF / AIFF-C * Pure Python, no dependencies - * Supports Python 2.7 and 3.6 or higher + * Supports Python 3.6 or higher * High test coverage * Just a few hundred lines of code (just include it in your project!) diff --git a/setup.cfg b/setup.cfg index 7c802c2..39cd9cf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,6 @@ keywords = music classifiers = Programming Language :: Python - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 @@ -34,7 +33,7 @@ long_description = file: README.md long_description_content_type = text/markdown [options] -python_requires = >= 2.7 +python_requires = >= 3.6 include_package_data = True packages = find: install_requires = diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 0b99d36..de592cc 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -42,7 +42,7 @@ def test_print_help(): def test_save_image_long_opt(): temp_file = NamedTemporaryFile() assert file_size(temp_file.name) == 0 - run_cli('--save-image %s %s' % (temp_file.name, mp3_with_image)) + run_cli(f'--save-image {temp_file.name} {mp3_with_image}') assert file_size(temp_file.name) > 0 with open(temp_file.name, 'rb') as fh: image_data = fh.read(20) @@ -55,7 +55,7 @@ def test_save_image_long_opt(): def test_save_image_short_opt(): temp_file = NamedTemporaryFile() assert file_size(temp_file.name) == 0 - run_cli('-i %s %s' % (temp_file.name, mp3_with_image)) + run_cli(f'-i {temp_file.name} {mp3_with_image}') assert file_size(temp_file.name) > 0 @@ -65,7 +65,7 @@ def test_save_image_bulk(): temp_file = NamedTemporaryFile(suffix='.jpg') temp_file_no_ext = temp_file.name[:-4] assert file_size(temp_file.name) == 0 - run_cli('-i %s %s %s %s' % (temp_file.name, mp3_with_image, mp3_with_image, mp3_with_image)) + run_cli(f'-i {temp_file.name} {mp3_with_image} {mp3_with_image} {mp3_with_image}') assert file_size(temp_file.name) == 0 assert file_size(temp_file_no_ext + '00000.jpg') > 0 assert file_size(temp_file_no_ext + '00001.jpg') > 0 diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d28d892..c5b07df 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # tinytag - an audio meta info reader # Copyright (c) 2014-2023 Tom Wallroth @@ -31,16 +30,11 @@ # SOFTWARE. -from __future__ import division, print_function -from collections import OrderedDict, defaultdict -try: - from collections.abc import MutableMapping -except ImportError: - from collections import MutableMapping +from collections import defaultdict +from collections.abc import MutableMapping from functools import reduce from io import BytesIO import base64 -import codecs import io import json import operator @@ -64,7 +58,8 @@ def _read(fh, nbytes): # helper function to check if we haven't reached EOF def stderr(*args): - sys.stderr.write('%s\n' % ' '.join(repr(arg) for arg in args)) + args_str = ' '.join(repr(arg) for arg in args) + sys.stderr.write(f'{args_str}\n') sys.stderr.flush() @@ -77,7 +72,7 @@ def _bytes_to_int(b): return reduce(lambda accu, elem: (accu << 8) + elem, b, 0) -class TinyTag(object): +class TinyTag: SUPPORTED_FILE_EXTENSIONS = [ '.mp1', '.mp2', '.mp3', '.oga', '.ogg', '.opus', '.spx', @@ -89,11 +84,7 @@ class TinyTag(object): _magic_bytes_mapping = None def __init__(self, filehandler, filesize, ignore_errors=False): - # This is required for compatibility between python2 and python3 - # in python2 there is a difference between `str` and `unicode` - # whereas in python3 everything every string is `unicode` by default and - # the type `unicode` is deprecated - if type(filehandler).__name__ in ('str', 'unicode'): + if isinstance(filehandler, str): raise Exception('Use `TinyTag.get(filepath)` instead of `TinyTag(filepath)`') self._filehandler = filehandler self._filename = None # for debugging purposes @@ -202,7 +193,7 @@ def get(cls, filename=None, tags=True, duration=True, image=False, ignore_errors=False, encoding=None, file_obj=None): should_open_file = (file_obj is None) if should_open_file: - file_obj = io.open(filename, 'rb') + file_obj = open(filename, 'rb') elif isinstance(file_obj, io.BytesIO): file_obj = io.BufferedReader(file_obj) # buffered reader to support peeking try: @@ -223,7 +214,7 @@ def get(cls, filename=None, tags=True, duration=True, image=False, file_obj.close() def __str__(self): - return json.dumps(OrderedDict(sorted(self.as_dict().items()))) + return json.dumps(dict(sorted(self.as_dict().items()))) def __repr__(self): return str(self) @@ -253,7 +244,7 @@ def _set_field(self, fieldname, value, overwrite=True): if get_func(write_dest, fieldname): # do not overwrite existing data return if DEBUG: - stderr('Setting field "%s" to "%s"' % (fieldname, value)) + stderr(f'Setting field "{fieldname}" to "{value}"') if overwrite or not get_func(write_dest, fieldname): set_func(write_dest, fieldname, value) @@ -291,9 +282,9 @@ class Parser: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 ATOM_DECODER_BY_TYPE = { # 0: 'reserved' - 1: lambda x: codecs.decode(x, 'utf-8', 'replace'), # UTF-8 - 2: lambda x: codecs.decode(x, 'utf-16', 'replace'), # UTF-16 - 3: lambda x: codecs.decode(x, 's/jis', 'replace'), # S/JIS + 1: lambda x: x.decode('utf-8', 'replace'), # UTF-8 + 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 + 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS # 16: duration in millis 13: lambda x: x, # JPEG 14: lambda x: x, # PNG @@ -319,7 +310,7 @@ def parse_data_atom(data_atom): data_type = struct.unpack('>I', data_atom[:4])[0] conversion = cls.ATOM_DECODER_BY_TYPE.get(data_type) if conversion is None: - stderr('Cannot convert data type: %s' % data_type) + stderr(f'Cannot convert data type: {data_type}') return {} # don't know how to convert data atom # skip header & null-bytes, convert rest return {fieldname: conversion(data_atom[8:])} @@ -360,7 +351,7 @@ def parse_custom_field(cls, data): atom_type = atom_header[4:] if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() - field_name = 'extra.' + codecs.decode(atom_value, 'utf-8', 'replace') + field_name = 'extra.' + atom_value.decode('utf-8', 'replace') elif atom_type == b'data': data_atom = fh.read(atom_size) else: @@ -601,7 +592,7 @@ class _ID3(TinyTag): ] def __init__(self, filehandler, filesize, *args, **kwargs): - TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) + super().__init__(filehandler, filesize, *args, **kwargs) # save position after the ID3 tag for duration measurement speedup self._bytepos_after_id3v2 = None @@ -705,7 +696,7 @@ def _determine_duration(self, fh): if xframes and xframes != 0 and byte_count: # MPEG-2 Audio Layer III uses 576 samples per frame samples_per_frame = 576 if mpeg_id <= 2 else self.samples_per_frame - self.duration = xframes * samples_per_frame / float(self.samplerate) + self.duration = xframes * samples_per_frame / self.samplerate # self.duration = (xframes * self.samples_per_frame / self.samplerate # / self.channels) # noqa self.bitrate = byte_count * 8 / self.duration / 1000 @@ -752,12 +743,12 @@ def _parse_id3v2_header(self, fh): size, extended, major = 0, None, None # for info on the specs, see: http://id3.org/Developer%20Information header = struct.unpack('3sBBB4B', _read(fh, 10)) - tag = codecs.decode(header[0], 'ISO-8859-1') + tag = header[0].decode('ISO-8859-1') # check if there is an ID3v2 tag at the beginning of the file if tag == 'ID3': major, rev = header[1:3] if DEBUG: - stderr('Found id3 v2.%s' % major) + stderr('Found id3 v2.{major}') # unsync = (header[3] & 0x80) > 0 extended = (header[3] & 0x40) > 0 # experimental = (header[3] & 0x20) > 0 @@ -785,7 +776,7 @@ def _parse_id3v2(self, fh): def _parse_id3v1(self, fh): if fh.read(3) == b'TAG': # check if this is an ID3 v1 tag def asciidecode(x): - return self._unpad(codecs.decode(x, self._default_encoding or 'latin1')) + return self._unpad(x.decode(self._default_encoding or 'latin1')) fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) self._set_field('title', asciidecode(fields[:30]), overwrite=False) self._set_field('artist', asciidecode(fields[30:60]), overwrite=False) @@ -836,7 +827,7 @@ def _parse_frame(self, fh, id3version=False): if fieldname in ('track', 'disc'): if '/' in value: value, total = value.split('/')[:2] - self._set_field('%s_total' % fieldname, int(total)) + self._set_field(f'{fieldname}_total', int(total)) value = int(value) elif fieldname == 'genre': genre_id = 255 @@ -849,7 +840,7 @@ def _parse_frame(self, fh, id3version=False): value = _ID3.ID3V1_GENRES[genre_id] except ValueError as exc: if DEBUG: - stderr('Failed to read %s: %s' % (fieldname, exc)) + stderr(f'Failed to read {fieldname}: {exc}') else: self._set_field(fieldname, value) elif frame_id in self.CUSTOM_FRAME_IDS: @@ -857,7 +848,7 @@ def _parse_frame(self, fh, id3version=False): custom_text = self._decode_string(content) custom_field_name, _separator, value = custom_text.partition('\x00') if custom_field_name: - self._set_field('extra.' + custom_field_name.lower(), value.lstrip(u'\ufeff')) + self._set_field('extra.' + custom_field_name.lower(), value.lstrip('\ufeff')) elif frame_id in self.IMAGE_FRAME_IDS and self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames encoding = content[0:1] @@ -913,9 +904,9 @@ def _decode_string(self, bytestr, language=False): if language and bytestr[:3].isalpha() and bytestr[3:4] == b'\x00': bytestr = bytestr[4:] # remove language errors = 'ignore' if self._ignore_errors else 'strict' - return self._unpad(codecs.decode(bytestr, encoding, errors)) - except UnicodeDecodeError: - raise TinyTagException('Error decoding ID3 Tag!') + return self._unpad(bytestr.decode(encoding, errors)) + except UnicodeDecodeError as exc: + raise TinyTagException('Error decoding ID3 Tag!') from exc def _calc_size(self, bytestr, bits_per_byte): # length of some mp3 header fields is described by 7 or 8-bit-bytes @@ -924,7 +915,7 @@ def _calc_size(self, bytestr, bits_per_byte): class _Ogg(TinyTag): def __init__(self, filehandler, filesize, *args, **kwargs): - TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) + super().__init__(filehandler, filesize, *args, **kwargs) self._tags_parsed = False self._max_samplenum = 0 # maximum sample position ever read @@ -1006,7 +997,7 @@ def _parse_tag(self, fh): elif check_speex_second_packet: if self._parse_tags: length = struct.unpack('I', walker.read(4))[0] # starts with a comment string - comment = codecs.decode(walker.read(length), 'UTF-8') + comment = walker.read(length).decode('UTF-8') self._set_field('comment', comment) self._parse_vorbis_comment(walker, contains_vendor=False) # other tags check_speex_second_packet = False @@ -1047,7 +1038,7 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): for i in range(elements): length = struct.unpack('I', fh.read(4))[0] try: - keyvalpair = codecs.decode(fh.read(length), 'UTF-8') + keyvalpair = fh.read(length).decode('UTF-8') except UnicodeDecodeError: continue if '=' in keyvalpair: @@ -1067,13 +1058,13 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): if fieldname in ('track', 'disc'): if '/' in value: value, total = value.split('/')[:2] - self._set_field('%s_total' % fieldname, int(total)) + self._set_field(f'{fieldname}_total', int(total)) value = int(value) elif fieldname in ('track_total', 'disc_total'): value = int(value) except ValueError as exc: if DEBUG: - stderr('Failed to read %s: %s' % (fieldname, exc)) + stderr(f'Failed to read {fieldname}: {exc}') else: self._set_field(fieldname, value) @@ -1126,7 +1117,7 @@ class _Wave(TinyTag): } def __init__(self, filehandler, filesize, *args, **kwargs): - TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) + super().__init__(filehandler, filesize, *args, **kwargs) self._duration_parsed = False def _determine_duration(self, fh): @@ -1168,13 +1159,13 @@ def _determine_duration(self, fh): data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte fieldname = self.riff_mapping.get(field) if fieldname: - value = codecs.decode(data, 'utf-8') + value = data.decode('utf-8') try: if fieldname == 'track': value = int(value) except ValueError as exc: if DEBUG: - stderr('Failed to read %s: %s' % (fieldname, exc)) + stderr(f'Failed to read {fieldname}: {exc}') else: self._set_field(fieldname, value) field = sub_fh.read(4) @@ -1299,7 +1290,7 @@ class _Wma(TinyTag): # http://uguisu.skr.jp/Windows/format_asf.html def __init__(self, filehandler, filesize, *args, **kwargs): - TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) + super().__init__(filehandler, filesize, *args, **kwargs) self.__tag_parsed = False def _determine_duration(self, fh): @@ -1326,7 +1317,7 @@ def __bytes_to_guid(self, obj_id_bytes): ]) def __decode_string(self, bytestring): - return self._unpad(codecs.decode(bytestring, 'utf-16')) + return self._unpad(bytestring.decode('utf-16')) def __decode_ext_desc(self, value_type, value): """ decode ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" @@ -1402,7 +1393,7 @@ def _parse_tag(self, fh): field_value = int(field_value) except ValueError as exc: if DEBUG: - stderr('Failed to read %s: %s' % (field_name, exc)) + stderr(f'Failed to read {field_name}: {exc}') else: self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT: @@ -1497,7 +1488,7 @@ class _Aiff(TinyTag): } def __init__(self, filehandler, filesize, *args, **kwargs): - TinyTag.__init__(self, filehandler, filesize, *args, **kwargs) + super().__init__(filehandler, filesize, *args, **kwargs) self._tags_parsed = False def _parse_tag(self, fh): From ee4256e2060592e7b8bf517a065f47545df6e07a Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 00:16:27 +0200 Subject: [PATCH 114/305] Don't inherit LookupError --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c5b07df..be8081b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -46,7 +46,7 @@ DEBUG = os.environ.get('DEBUG', False) # some of the parsers can print debug info -class TinyTagException(LookupError): # inherit LookupError for backwards compat +class TinyTagException(Exception): pass From 124426e7fcc57d4bb0042021ae3367e6cc5c62bc Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 00:26:39 +0200 Subject: [PATCH 115/305] Remove audio_offset attribute This attribute isn't implemented for every format, and is quite useless without additional information such as when the audio data ends. --- README.md | 1 - tinytag/tests/test_all.py | 98 +++++++++++++++++++-------------------- tinytag/tests/test_cli.py | 2 +- tinytag/tinytag.py | 14 ++---- 4 files changed, 54 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index ead3fe5..47faae0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ List of possible attributes you can get with TinyTag: tag.album # album as string tag.albumartist # album artist as string tag.artist # artist name as string - tag.audio_offset # number of bytes before audio data begins tag.bitdepth # bit depth for lossless audio tag.bitrate # bitrate in kBits/s tag.comment # file comment as string diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 898eff3..87174e8 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -34,32 +34,32 @@ {'extra': {'copyright': '', 'url': ''}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, - 'filesize': 8192, 'audio_offset': 1007, 'genre': '(3)Dance', + 'filesize': 8192, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, - 'filesize': 8186, 'audio_offset': 246, 'bitrate': 128.0, 'genre': 'Dance', + 'filesize': 8186, 'bitrate': 128.0, 'genre': 'Dance', 'comment': 'Ripped by THSLIVE'}), # the output of the lame encoder was 185.4 bitrate, but this is good enough for now ('samples/vbr_xing_header.mp3', {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, - 'duration': 3.944489795918367, 'filesize': 91731, 'audio_offset': 141}), + 'duration': 3.944489795918367, 'filesize': 91731}), ('samples/vbr_xing_header_2channel.mp3', {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", - 'artist': 'Knodel and Valencia', 'audio_offset': 394, 'bitrate': 46.276128290848305, + 'artist': 'Knodel and Valencia', 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.138, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', - 'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120, 'audio_offset': 2225, + 'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), ('samples/silence-44-s-v1.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', - 'title': 'Silence', 'artist': 'piman', 'track': 2, 'filesize': 15070, 'audio_offset': 0, + 'title': 'Silence', 'artist': 'piman', 'track': 2, 'filesize': 15070, 'bitrate': 32.0, 'comment': ''}), ('samples/id3v1-latin1.mp3', {'extra': {}, 'genre': 'Rock', @@ -85,9 +85,9 @@ {'extra': {}, 'filesize': 0}), ('samples/silence-44khz-56k-mono-1s.mp3', {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.018, 'filesize': 7280, - 'audio_offset': 0, 'bitrate': 56.0}), + 'bitrate': 56.0}), ('samples/silence-22khz-mono-1s.mp3', - {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, 'audio_offset': 0, + {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v24-long-title.mp3', {'extra': @@ -125,23 +125,23 @@ 'year': '0'}), ('samples/image-text-encoding.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, - 'title': 'image-encoding', 'audio_offset': 6820, 'bitrate': 32.0, + 'title': 'image-encoding', 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', 'comment': '', 'extra': {'love rating': 'L'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), ('samples/nicotinetestdata.mp3', - {'extra': {}, 'filesize': 80919, 'audio_offset': 45, 'channels': 2, + {'extra': {}, 'filesize': 80919, 'channels': 2, 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), ('samples/chinese_id3.mp3', {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', - 'artist': 'ËÕÔÆ', 'audio_offset': 512, 'bitrate': 128.0, 'channels': 2, + 'artist': 'ËÕÔÆ', 'bitrate': 128.0, 'channels': 2, 'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': 1}), ('samples/cut_off_titles.mp3', {'extra': {}, 'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', - 'audio_offset': 194, 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, + 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), ('samples/id3_xxx_lang.mp3', {'extra': {'script': 'Latn', 'originalyear': '2004', @@ -161,66 +161,66 @@ 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', 'replaygain_album_gain': '-8.26 dB'}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', - 'artist': 'A Perfect Circle', 'audio_offset': 3647, 'bitrate': 192.0, 'channels': 2, + 'artist': 'A Perfect Circle', 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', 'samplerate': 44100, 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', 'track': 10, 'comment': ' ', 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': 1, 'disc_total': 1, 'track_total': 12, 'year': '2004'}), ('samples/mp3/vbr/vbr8.mp3', - {'filesize': 9504, 'audio_offset': 133, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, + {'filesize': 9504, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr8stereo.mp3', - {'filesize': 9504, 'audio_offset': 141, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, + {'filesize': 9504, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr11.mp3', - {'filesize': 9360, 'audio_offset': 133, 'bitrate': 8.143465909090908, 'channels': 1, + {'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr11stereo.mp3', - {'filesize': 9360, 'audio_offset': 141, 'bitrate': 8.143465909090908, 'channels': 2, + {'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 2, 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr16.mp3', - {'filesize': 9432, 'audio_offset': 133, 'bitrate': 8.251968503937007, 'channels': 1, + {'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr16stereo.mp3', - {'filesize': 9432, 'audio_offset': 141, 'bitrate': 8.251968503937007, 'channels': 2, + {'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 2, 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr22.mp3', - {'filesize': 9282, 'audio_offset': 133, 'bitrate': 8.145021489971347, 'channels': 1, + {'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 1, 'duration': 9.2, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr22stereo.mp3', - {'filesize': 9282, 'audio_offset': 141, 'bitrate': 8.145021489971347, 'channels': 2, + {'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 2, 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr32.mp3', - {'filesize': 37008, 'audio_offset': 141, 'bitrate': 32.50592885375494, 'channels': 1, + {'filesize': 37008, 'bitrate': 32.50592885375494, 'channels': 1, 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), ('samples/mp3/vbr/vbr32stereo.mp3', - {'filesize': 37008, 'audio_offset': 156, 'bitrate': 32.50592885375494, 'channels': 2, + {'filesize': 37008, 'bitrate': 32.50592885375494, 'channels': 2, 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), ('samples/mp3/vbr/vbr44.mp3', - {'filesize': 36609, 'audio_offset': 141, 'bitrate': 32.21697198275862, 'channels': 1, + {'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 1, 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr44stereo.mp3', - {'filesize': 36609, 'audio_offset': 156, 'bitrate': 32.21697198275862, 'channels': 2, + {'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 2, 'duration': 9.0, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr48.mp3', - {'filesize': 36672, 'audio_offset': 141, 'bitrate': 32.33862433862434, 'channels': 1, + {'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 1, 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), ('samples/mp3/vbr/vbr48stereo.mp3', - {'filesize': 36672, 'audio_offset': 156, 'bitrate': 32.33862433862434, 'channels': 2, + {'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 2, 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), ('samples/id3v24_genre_null_byte.mp3', {'extra': {}, 'filesize': 256, 'album': '\u79d8\u5bc6', 'albumartist': 'aiko', 'artist': 'aiko', 'disc': 1, 'genre': 'Pop', 'title': '\u661f\u306e\u306a\u3044\u4e16\u754c', 'track': 10, 'year': '2008'}), ('samples/vbr_xing_header_short.mp3', - {'filesize': 432, 'audio_offset': 133, 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, + {'filesize': 432, 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, 'extra': {}, 'samplerate': 8000}), # OGG ('samples/empty.ogg', {'extra': {}, 'duration': 3.684716553287982, - 'filesize': 4328, 'audio_offset': 0, 'bitrate': 112.0, + 'filesize': 4328, 'bitrate': 112.0, 'samplerate': 44100}), ('samples/multipage-setup.ogg', {'extra': {'transcoded': 'mp3;241', 'replaygain_album_gain': '-10.29 dB', @@ -228,19 +228,19 @@ 'replaygain_track_gain': '-10.02 dB'}, 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': 7, - 'filesize': 76983, 'audio_offset': 0, 'bitrate': 160.0, + 'filesize': 76983, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'SRCL-6240'}), ('samples/test.ogg', {'extra': {}, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': 1, - 'filesize': 7467, 'audio_offset': 0, 'bitrate': 160.0, 'samplerate': 44100, + 'filesize': 7467, 'bitrate': 160.0, 'samplerate': 44100, 'comment': 'hello!'}), ('samples/corrupt_metadata.ogg', - {'extra': {}, 'filesize': 18648, 'audio_offset': 0, 'bitrate': 80.0, + {'extra': {}, 'filesize': 18648, 'bitrate': 80.0, 'duration': 2.132358276643991, 'samplerate': 44100}), ('samples/composer.ogg', {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', - 'audio_offset': 0, 'bitrate': 112.0, 'duration': 3.684716553287982, + 'bitrate': 112.0, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2, 'year': '2007', 'composer': 'some composer', 'comment': 'A Comment'}), ('samples/test.opus', @@ -271,45 +271,45 @@ # WAV ('samples/test.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, - 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 36}), + 'samplerate': 44100, 'bitdepth': 16}), ('samples/test3sMono.wav', {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, - 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 36}), + 'samplerate': 44100, 'bitdepth': 16}), ('samples/test-tagged.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'bitdepth': 16, 'title': 'thetitle', 'track': 66, 'audio_offset': 36, 'comment': 'hello', + 'bitdepth': 16, 'title': 'thetitle', 'track': 66, 'comment': 'hello', 'year': '2014'}), ('samples/test-riff-tags.wav', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'bitdepth': 16, 'title': 'thetitle', 'audio_offset': 36, 'comment': 'hello', + 'bitdepth': 16, 'title': 'thetitle', 'comment': 'hello', 'year': '2014'}), ('samples/silence-22khz-mono-1s.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, - 'samplerate': 22050, 'bitdepth': 16, 'audio_offset': 4088}), + 'samplerate': 22050, 'bitdepth': 16}), ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, - 'samplerate': 22050, 'bitdepth': 16, 'audio_offset': 122, 'artist': 'Purpley', + 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', 'title': 'Test000', 'track': 17, 'album': 'prototypes'}), ('samples/adpcm.wav', {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, - 'bitrate': 176.4, 'samplerate': 44100, 'bitdepth': 4, 'audio_offset': 82, + 'bitrate': 176.4, 'samplerate': 44100, 'bitdepth': 4, 'artist': 'test artist', 'title': 'test title', 'track': 1, 'album': 'test album', 'comment': 'test comment', 'genre': 'test genre', 'year': '1990'}), ('samples/riff_extra_zero.wav', {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20670, - 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 182, + 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'artist': 'B.O.S.E.', 'title': 'Mission Bass', 'album': '808 Bass Express', 'genre': 'Hip-Hop/Rap', 'year': '1996', 'track': 3}), ('samples/riff_extra_zero_2.wav', {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20682, - 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'audio_offset': 194, + 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'artist': 'The Jimmy Castor Bunch', 'title': 'It\'s Just Begun', 'album': 'The Perfect Beats, Vol. 4', 'genre': 'Pop Electronica', 'track': 7}), ('samples/wav_invalid_track_number.wav', {'extra': {}, 'filesize': 8908, 'bitrate': 705.6, - 'duration': 0.1, 'samplerate': 44100, 'audio_offset': 36, 'channels': 1, + 'duration': 0.1, 'samplerate': 44100, 'channels': 1, 'bitdepth': 16}), # FLAC @@ -473,21 +473,21 @@ ('samples/test-tagged.aiff', {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'bitdepth': 16, 'track': 1, - 'title': 'thetitle', 'album': 'thealbum', 'audio_offset': 76, 'comment': 'hello', + 'title': 'thetitle', 'album': 'thealbum', 'comment': 'hello', 'year': '2014'}), ('samples/test.aiff', {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, - 'title': 'Go Out and Get Some', 'audio_offset': 156, + 'title': 'Go Out and Get Some', 'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}), ('samples/pluck-pcm8.aiff', {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', - 'bitrate': 176.4, 'samplerate': 11025, 'bitdepth': 8, 'audio_offset': 116, + 'bitrate': 176.4, 'samplerate': 11025, 'bitdepth': 8, 'comment': 'Audacity Pluck + Wahwah', 'year': '2013'}), ('samples/M1F1-mulawC-AFsp.afc', {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, - 'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16, 'audio_offset': 154, + 'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC\x00user: kabal@CAPELLA\x00program: CopyAudio'}), ('samples/invalid_sample_rate.aiff', @@ -495,7 +495,7 @@ ('samples/aiff_extra_tags.aiff', {'extra': {'copyright': 'test', 'isrc': 'CC-XXX-YY-NNNNN'}, 'channels': 1, 'duration': 2.176, 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8, - 'title': 'song title', 'artist': 'artist 1;artist 2', 'audio_offset': 46}), + 'title': 'song title', 'artist': 'artist 1;artist 2'}), ]) @@ -840,7 +840,7 @@ def test_to_str(): assert repr(tag) # since the dict is not ordered we cannot == 'somestring' assert str(tag) == ( '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", ' - '"audio_offset": 2225, "bitdepth": null, "bitrate": 160.0, "channels": 2, ' + '"bitdepth": null, "bitrate": 160.0, "channels": 2, ' '"comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, ' '"disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, ' '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": 3, ' diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index de592cc..c12224a 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -12,7 +12,7 @@ bogus_file = os.path.join(sample_folder, 'there_is_no_such_ext.bogus') assert os.path.exists(mp3_with_image) -tinytag_attributes = {'album', 'albumartist', 'artist', 'audio_offset', 'bitdepth', 'bitrate', +tinytag_attributes = {'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year'} diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index be8081b..65dac91 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -93,7 +93,6 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.album = None self.albumartist = None self.artist = None - self.audio_offset = None self.bitrate = None self.channels = None self.comment = None @@ -700,14 +699,13 @@ def _determine_duration(self, fh): # self.duration = (xframes * self.samples_per_frame / self.samplerate # / self.channels) # noqa self.bitrate = byte_count * 8 / self.duration / 1000 - self.audio_offset = fh.tell() return continue frames += 1 # it's most probably an mp3 frame bitrate_accu += frame_bitrate if frames == 1: - self.audio_offset = fh.tell() + audio_offset = fh.tell() if frames <= self._CBR_DETECTION_FRAME_COUNT: last_bitrates.append(frame_bitrate) fh.seek(4, os.SEEK_CUR) # jump over peeked bytes @@ -719,7 +717,7 @@ def _determine_duration(self, fh): if frames == max_estimation_frames or is_cbr: # try to estimate duration fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) - audio_stream_size = fh.tell() - self.audio_offset + audio_stream_size = fh.tell() - audio_offset est_frame_count = audio_stream_size / (frame_size_accu / frames) samples = est_frame_count * self.samples_per_frame self.duration = samples / self.samplerate @@ -942,7 +940,7 @@ def _determine_duration(self, fh): fh.seek(max(seekpos, 1), os.SEEK_CUR) def _parse_tag(self, fh): - page_start_pos = fh.tell() # set audio_offset later if its audio data + page_start_pos = fh.tell() check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): @@ -951,9 +949,7 @@ def _parse_tag(self, fh): if self._parse_duration: (channels, self.samplerate, max_bitrate, bitrate, min_bitrate) = struct.unpack(" Date: Tue, 27 Feb 2024 00:40:01 +0200 Subject: [PATCH 116/305] Remove unused variable --- tinytag/tinytag.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 65dac91..1a4ca59 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -940,7 +940,6 @@ def _determine_duration(self, fh): fh.seek(max(seekpos, 1), os.SEEK_CUR) def _parse_tag(self, fh): - page_start_pos = fh.tell() check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): @@ -1001,7 +1000,6 @@ def _parse_tag(self, fh): if DEBUG: stderr('Unsupported Ogg page type: ', packet[:16]) break - page_start_pos = fh.tell() self._tags_parsed = True def _parse_vorbis_comment(self, fh, contains_vendor=True): From 839cc2b3d239a7edd011a389e1550b0997c3fe07 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 01:00:40 +0200 Subject: [PATCH 117/305] Don't set fields with empty values --- tinytag/tests/test_all.py | 21 ++++++++++----------- tinytag/tinytag.py | 3 +++ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 87174e8..32ab6f8 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -31,11 +31,11 @@ testfiles = OrderedDict([ # MP3 ('samples/vbri.mp3', - {'extra': {'copyright': '', 'url': ''}, 'channels': 2, 'samplerate': 44100, + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, 'filesize': 8192, 'genre': '(3)Dance', - 'comment': 'Ripped by THSLIVE', 'composer': '', 'bitrate': 125.33333333333333}), + 'comment': 'Ripped by THSLIVE', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.49, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', @@ -60,7 +60,7 @@ {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', 'title': 'Silence', 'artist': 'piman', 'track': 2, 'filesize': 15070, - 'bitrate': 32.0, 'comment': ''}), + 'bitrate': 32.0}), ('samples/id3v1-latin1.mp3', {'extra': {}, 'genre': 'Rock', 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': 12, @@ -121,7 +121,7 @@ 'year': '2012'}), ('samples/id3_genre_id_out_of_bounds.mp3', {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', - 'comment': '', 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', + 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', 'year': '0'}), ('samples/image-text-encoding.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, @@ -129,7 +129,7 @@ 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', - 'artist': 'Blind Guardian', 'comment': '', 'extra': {'love rating': 'L'}, + 'artist': 'Blind Guardian', 'extra': {'love rating': 'L'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), ('samples/nicotinetestdata.mp3', {'extra': {}, 'filesize': 80919, 'channels': 2, @@ -380,7 +380,7 @@ 'title': 'Track01', 'track': 1, 'track_total': 5, 'year': '2018', 'comment': 'comment'}), ('samples/flac_with_image.flac', - {'extra': {'band': ''}, 'filesize': 80000, 'album': 'smilin´ in circles', + {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': 1, 'disc_total': 1, 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'bitdepth': 16, 'title': 'intro', @@ -391,7 +391,7 @@ # WMA ('samples/test2.wma', - {'extra': {'track': 0, 'lyrics': '', + {'extra': {'track': 0, 'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}', 'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145', 'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369, @@ -399,14 +399,13 @@ 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', - 'genre': 'Alternative', 'comment': '', 'composer': 'Foo Fighters'}), + 'genre': 'Alternative', 'composer': 'Foo Fighters'}), ('samples/lossless.wma', {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, - 'duration': 43.133, 'artist': '', 'comment': '', 'title': ''}), + 'duration': 43.133}), ('samples/wma_invalid_track_number.wma', {'extra': {'encodingsettings': 'Lavf60.16.100'}, 'filesize': 3940, 'bitrate': 128.0, - 'duration': 2.1409999999999996, 'samplerate': 44100, 'artist': '', 'comment': '', - 'title': ''}), + 'duration': 2.1409999999999996, 'samplerate': 44100}), # ALAC/M4A/MP4 ('samples/test.m4a', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 1a4ca59..4f27d10 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -240,6 +240,9 @@ def _set_field(self, fieldname, value, overwrite=True): write_dest = self.extra # write into the extra field instead get_func = operator.getitem set_func = operator.setitem + if isinstance(value, str) and not value: + # don't set empty value + return if get_func(write_dest, fieldname): # do not overwrite existing data return if DEBUG: From 3a0296ffdfed2403aa0639e499e57edbf0a19082 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 07:40:55 +0200 Subject: [PATCH 118/305] Add a few more extra fields (#184) --- README.md | 11 +++++- tinytag/tests/samples/test3.m4a | Bin 0 -> 6260 bytes tinytag/tests/test_all.py | 19 ++++++---- tinytag/tinytag.py | 61 ++++++++++++++++++++++++++------ 4 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 tinytag/tests/samples/test3.m4a diff --git a/README.md b/README.md index 47faae0..fd3b90f 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,16 @@ For non-common fields and fields specific to certain file formats, use `extra`: tag.extra # a dict of additional data The following standard `extra` field names are used when file formats provide relevant data: - `url`, `isrc`, `initial_key`, `lyrics`, `copyright` + + `bpm` + `copyright` + `director` + `initial_key` + `isrc` + `language` + `lyrics` + `publisher` + `url` Any other `extra` field names are not guaranteed to be consistent across audio formats. diff --git a/tinytag/tests/samples/test3.m4a b/tinytag/tests/samples/test3.m4a new file mode 100644 index 0000000000000000000000000000000000000000..8c1e15fdec55abd3ef3c09cf642310361ef47c2c GIT binary patch literal 6260 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\n\n\n\t' 'asset-info\n\t\n\t\tflavor\n\t\t' - '2:256\n\t\n\n\n')}, + '2:256\n\t\n\n\n'), + 'tool': 144255989988720642}, 'bitrate': 256.0, 'track': 1, 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', @@ -467,6 +470,10 @@ 'comment': 'test comment', 'duration': 727.1066666666667, 'extra': {'description': 'test description'}}), + ('samples/test3.m4a', + {'extra': {'publisher': 'test7', 'bpm': 99999}, 'artist': 'test1', 'composer': 'test8', + 'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1, + 'bitrate': 27.887}), # AIFF ('samples/test-tagged.aiff', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4f27d10..0aba904 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -280,6 +280,19 @@ class _MP4(TinyTag): # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html + @classmethod + def unpack_integer(cls, value, signed=True): + value_length = len(value) + if value_length == 1: + return struct.unpack('>b' if signed else '>B', value)[0] + if value_length == 2: + return struct.unpack('>h' if signed else '>H', value)[0] + if value_length == 4: + return struct.unpack('>i' if signed else '>I', value)[0] + if value_length == 8: + return struct.unpack('>q' if signed else '>Q', value)[0] + return None + class Parser: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 ATOM_DECODER_BY_TYPE = { @@ -290,8 +303,8 @@ class Parser: # 16: duration in millis 13: lambda x: x, # JPEG 14: lambda x: x, # PNG - # 21: BE Signed int - # 22: BE Unsigned int + 21: lambda x: _MP4.unpack_integer(x), # BE Signed int + 22: lambda x: _MP4.unpack_integer(x, signed=False), # BE Unsigned int 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 24: lambda x: struct.unpack('>d', x)[0], # BE Float64 # 27: lambda x: x, # BMP @@ -440,10 +453,12 @@ def debug_atom(cls, data): # b'cpil': {b'data': Parser.make_data_atom_parser('extra.compilation')}, b'\xa9day': {b'data': Parser.make_data_atom_parser('year')}, b'\xa9des': {b'data': Parser.make_data_atom_parser('extra.description')}, + b'\xa9dir': {b'data': Parser.make_data_atom_parser('extra.director')}, b'\xa9gen': {b'data': Parser.make_data_atom_parser('genre')}, b'\xa9lyr': {b'data': Parser.make_data_atom_parser('extra.lyrics')}, b'\xa9mvn': {b'data': Parser.make_data_atom_parser('movement')}, b'\xa9nam': {b'data': Parser.make_data_atom_parser('title')}, + b'\xa9pub': {b'data': Parser.make_data_atom_parser('extra.publisher')}, b'\xa9wrt': {b'data': Parser.make_data_atom_parser('composer')}, b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, b'cprt': {b'data': Parser.make_data_atom_parser('extra.copyright')}, @@ -451,9 +466,8 @@ def debug_atom(cls, data): b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, b'gnre': {b'data': Parser.parse_id3v1_genre}, b'trkn': {b'data': Parser.make_number_parser('track', 'track_total')}, + b'tmpo': {b'data': Parser.make_data_atom_parser('extra.bpm')}, b'----': Parser.parse_custom_field, - # need test-data for this - # b'tmpo': {b'data': Parser.make_data_atom_parser('extra.bmp')}, }}}}} # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html @@ -525,7 +539,9 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): class _ID3(TinyTag): - FRAME_ID_TO_FIELD = { # Mapping from Frame ID to a field of the TinyTag + FRAME_ID_TO_FIELD = { + # Mapping from Frame ID to a field of the TinyTag + # https://exiftool.org/TagNames/ID3.html 'COMM': 'comment', 'COM': 'comment', 'TRCK': 'track', 'TRK': 'track', 'TYER': 'year', 'TYE': 'year', 'TDRC': 'year', @@ -533,13 +549,17 @@ class _ID3(TinyTag): 'TPE1': 'artist', 'TP1': 'artist', 'TIT2': 'title', 'TT2': 'title', 'TCON': 'genre', 'TCO': 'genre', - 'TPOS': 'disc', - 'TPE2': 'albumartist', 'TCOM': 'composer', - 'WXXX': 'extra.url', + 'TPOS': 'disc', 'TPA': 'disc', + 'TPE2': 'albumartist', 'TP2': 'albumartist', + 'TCOM': 'composer', 'TCM': 'composer', + 'WOAR': 'extra.url', 'WAR': 'extra.url', 'TSRC': 'extra.isrc', - 'TKEY': 'extra.initial_key', - 'USLT': 'extra.lyrics', 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', + 'TBPM': 'extra.bpm', + 'TKEY': 'extra.initial_key', + 'TLAN': 'extra.language', 'TLA': 'extra.language', + 'TPUB': 'extra.publisher', 'TPB': 'extra.publisher', + 'USLT': 'extra.lyrics', 'ULT': 'extra.lyrics', } IMAGE_FRAME_IDS = {'APIC', 'PIC'} CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} @@ -1027,6 +1047,14 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): 'comment': 'comment', 'comments': 'comment', 'composer': 'composer', + 'bpm': 'extra.bpm', + 'copyright': 'extra.copyright', + 'isrc': 'extra.isrc', + 'lyrics': 'extra.lyrics', + 'publisher': 'extra.publisher', + 'language': 'extra.language', + 'director': 'extra.director', + 'website': 'extra.url', } if contains_vendor: vendor_length = struct.unpack('I', fh.read(4))[0] @@ -1100,17 +1128,22 @@ class _Wave(TinyTag): b'TITL': 'title', b'IPRD': 'album', b'IART': 'artist', + b'IBPM': 'extra.bpm', b'ICMT': 'comment', + b'IMUS': 'composer', + b'ICOP': 'extra.copyright', b'ICRD': 'year', b'IGNR': 'genre', + b'ILNG': 'extra.language', b'ISRC': 'extra.isrc', + b'IPUB': 'extra.publisher', b'IPRT': 'track', b'ITRK': 'track', b'TRCK': 'track', b'PRT1': 'track', b'PRT2': 'track_number', + b'IBSU': 'extra.url', b'YEAR': 'year', - # riff format is lacking the composer field. } def __init__(self, filehandler, filesize, *args, **kwargs): @@ -1367,6 +1400,12 @@ def _parse_tag(self, fh): 'WM/Genre': 'genre', 'WM/AlbumTitle': 'album', 'WM/Composer': 'composer', + 'WM/Publisher': 'extra.publisher', + 'WM/BeatsPerMinute': 'extra.bpm', + 'WM/InitialKey': 'extra.initial_key', + 'WM/Lyrics': 'extra.lyrics', + 'WM/Language': 'extra.language', + 'WM/AuthorURL': 'extra.url', } # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 descriptor_count = _bytes_to_int_le(fh.read(2)) From 3eba4304c968e399ba729a252202faebb17174ed Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 08:01:07 +0200 Subject: [PATCH 119/305] Move composer field to 'extra' dict --- tinytag/tests/test_all.py | 42 +++++++++++++++++++-------------------- tinytag/tests/test_cli.py | 2 +- tinytag/tinytag.py | 13 ++++++------ 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 6a09b3e..9874950 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -91,14 +91,14 @@ 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v24-long-title.mp3', {'extra': - {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett'}, + {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett', + 'composer': 'Courtney Barnett'}, 'track': 1, 'disc_total': 1, 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, 'track_total': 12, 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', 'albumartist': 'Courtney Barnett', 'disc': 1, - 'comment': 'Amazon.com Song ID: 240853806', 'composer': 'Courtney Barnett', - 'year': '2013'}), + 'comment': 'Amazon.com Song ID: 240853806', 'year': '2013'}), ('samples/utf16be.mp3', {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), @@ -161,13 +161,13 @@ 'asin': 'B000641ZIQ', 'musicbrainz album release country': 'US', 'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious', 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', - 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America'}, + 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America', + 'composer': 'Billy Howerdel/Maynard James Keenan'}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', 'artist': 'A Perfect Circle', 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', 'samplerate': 44100, 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', - 'track': 10, 'comment': ' ', - 'composer': 'Billy Howerdel/Maynard James Keenan', 'disc': 1, 'disc_total': 1, + 'track': 10, 'comment': ' ', 'disc': 1, 'disc_total': 1, 'track_total': 12, 'year': '2004'}), ('samples/mp3/vbr/vbr8.mp3', {'filesize': 9504, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, @@ -241,10 +241,11 @@ {'extra': {}, 'filesize': 18648, 'bitrate': 80.0, 'duration': 2.132358276643991, 'samplerate': 44100}), ('samples/composer.ogg', - {'extra': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', + {'extra': {'composer': 'some composer'}, 'filesize': 4480, + 'album': 'An Album', 'artist': 'An Artist', 'bitrate': 112.0, 'duration': 3.684716553287982, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2, - 'year': '2007', 'composer': 'some composer', 'comment': 'A Comment'}), + 'year': '2007', 'comment': 'A Comment'}), ('samples/test.opus', {'extra': {'encoder': 'Lavc57.24.102 libopus', 'arrange': '\u6771\u65b9', 'catalogid': 'ARCD0024', 'discid': 'A212230D', 'event': '\u4f8b\u5927\u796d5', @@ -351,13 +352,13 @@ 'organization': 'Sony Music Records (SRCP-371)', 'ripper': 'Exact Audio Copy 0.99pb5', 'replaygain_album_gain': '-8.68 dB', 'replaygain_album_peak': '1.000000', - 'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000'}, + 'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000', + 'composer': 'Boom Boom Satellites (Lyrics)'}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': 1, 'track_total': 11, 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, 'disc': 1, 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'bitdepth': 16, - 'composer': 'Boom Boom Satellites (Lyrics)', 'disc_total': 2, - 'comment': 'Original Soundtrack'}), + 'disc_total': 2, 'comment': 'Original Soundtrack'}), ('samples/106-invalid-streaminfo.flac', {'extra': {}, 'filesize': 4692}), ('samples/106-short-picture-block-size.flac', @@ -397,11 +398,11 @@ 'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}', 'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145', 'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369, - 'averagelevel': 7291}, + 'averagelevel': 7291, 'composer': 'Foo Fighters'}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', - 'genre': 'Alternative', 'composer': 'Foo Fighters'}), + 'genre': 'Alternative'}), ('samples/lossless.wma', {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, 'duration': 43.133}), @@ -431,15 +432,15 @@ '/PropertyList-1.0.dtd">\n\n\n\t' 'asset-info\n\t\n\t\tflavor\n\t\t' '2:256\n\t\n\n\n'), - 'tool': 144255989988720642}, + 'tool': 144255989988720642, + 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}, 'bitrate': 256.0, 'track': 1, 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': 9, 'disc_total': 1, 'genre': 'R&B/Soul', 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': 1, 'title': 'Go Out and Get Some', - 'comment': "Millie Jackson - Get It Out 'cha System - 1978", - 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}), + 'comment': "Millie Jackson - Get It Out 'cha System - 1978"}), ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, 'title': 'Cold Water (feat. Justin Bieber & M\uFFFD)', @@ -449,9 +450,8 @@ 'comment': '? 2016 Mad Decent'}), ('samples/alac_file.m4a', {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:', - 'upc': '0034571177380'}, - 'artist': 'Howard Shelley', 'composer': 'Clementi, Muzio (1752-1832)', - 'filesize': 20000, + 'upc': '0034571177380', 'composer': 'Clementi, Muzio (1752-1832)'}, + 'artist': 'Howard Shelley', 'filesize': 20000, 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': 14, 'track_total': 27, 'disc': 1, 'disc_total': 1, 'samplerate': 44100, @@ -471,7 +471,7 @@ 'duration': 727.1066666666667, 'extra': {'description': 'test description'}}), ('samples/test3.m4a', - {'extra': {'publisher': 'test7', 'bpm': 99999}, 'artist': 'test1', 'composer': 'test8', + {'extra': {'publisher': 'test7', 'bpm': 99999, 'composer': 'test8'}, 'artist': 'test1', 'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1, 'bitrate': 27.887}), @@ -847,7 +847,7 @@ def test_to_str(): assert str(tag) == ( '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", ' '"bitdepth": null, "bitrate": 160.0, "channels": 2, ' - '"comment": "Waterbug Records, www.anaismitchell.com", "composer": null, "disc": null, ' + '"comment": "Waterbug Records, www.anaismitchell.com", "disc": null, ' '"disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, ' '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": 3, ' '"track_total": 11, "year": "2004"}') diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index c12224a..e80f8a9 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -13,7 +13,7 @@ assert os.path.exists(mp3_with_image) tinytag_attributes = {'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', - 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', + 'channels', 'comment', 'disc', 'disc_total', 'duration', 'extra', 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year'} diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 0aba904..9d2ca53 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -96,7 +96,6 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self.bitrate = None self.channels = None self.comment = None - self.composer = None self.disc = None self.disc_total = None self.duration = None @@ -263,7 +262,7 @@ def update(self, other, all_fields=False): return for key in ['track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', - 'genre', 'disc', 'disc_total', 'comment', 'composer', + 'genre', 'disc', 'disc_total', 'comment', 'extra', '_image_data']: if not getattr(self, key) and getattr(other, key): setattr(self, key, getattr(other, key)) @@ -459,7 +458,7 @@ def debug_atom(cls, data): b'\xa9mvn': {b'data': Parser.make_data_atom_parser('movement')}, b'\xa9nam': {b'data': Parser.make_data_atom_parser('title')}, b'\xa9pub': {b'data': Parser.make_data_atom_parser('extra.publisher')}, - b'\xa9wrt': {b'data': Parser.make_data_atom_parser('composer')}, + b'\xa9wrt': {b'data': Parser.make_data_atom_parser('extra.composer')}, b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, b'cprt': {b'data': Parser.make_data_atom_parser('extra.copyright')}, b'desc': {b'data': Parser.make_data_atom_parser('extra.description')}, @@ -551,7 +550,7 @@ class _ID3(TinyTag): 'TCON': 'genre', 'TCO': 'genre', 'TPOS': 'disc', 'TPA': 'disc', 'TPE2': 'albumartist', 'TP2': 'albumartist', - 'TCOM': 'composer', 'TCM': 'composer', + 'TCOM': 'extra.composer', 'TCM': 'extra.composer', 'WOAR': 'extra.url', 'WAR': 'extra.url', 'TSRC': 'extra.isrc', 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', @@ -1046,7 +1045,7 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): 'description': 'comment', 'comment': 'comment', 'comments': 'comment', - 'composer': 'composer', + 'composer': 'extra.composer', 'bpm': 'extra.bpm', 'copyright': 'extra.copyright', 'isrc': 'extra.isrc', @@ -1130,7 +1129,7 @@ class _Wave(TinyTag): b'IART': 'artist', b'IBPM': 'extra.bpm', b'ICMT': 'comment', - b'IMUS': 'composer', + b'IMUS': 'extra.composer', b'ICOP': 'extra.copyright', b'ICRD': 'year', b'IGNR': 'genre', @@ -1399,7 +1398,7 @@ def _parse_tag(self, fh): 'WM/AlbumArtist': 'albumartist', 'WM/Genre': 'genre', 'WM/AlbumTitle': 'album', - 'WM/Composer': 'composer', + 'WM/Composer': 'extra.composer', 'WM/Publisher': 'extra.publisher', 'WM/BeatsPerMinute': 'extra.bpm', 'WM/InitialKey': 'extra.initial_key', From 294f1d4cff95f69f4a6bb1c2c3faf784dcb218c1 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 08:06:21 +0200 Subject: [PATCH 120/305] README.md: update outdated information --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fd3b90f..5029519 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ Alternatively, check if a file is supported: is_supported = TinyTag.is_supported('/some/music.mp3') -List of possible attributes you can get with TinyTag: +List of common attributes tinytag provides: tag.album # album as string tag.albumartist # album artist as string @@ -61,7 +61,6 @@ List of possible attributes you can get with TinyTag: tag.bitdepth # bit depth for lossless audio tag.bitrate # bitrate in kBits/s tag.comment # file comment as string - tag.composer # composer as string tag.disc # disc number tag.disc_total # the total number of discs tag.duration # duration of the song in seconds @@ -69,8 +68,8 @@ List of possible attributes you can get with TinyTag: tag.genre # genre as string tag.samplerate # samples per second tag.title # title of the song - tag.track # track number as string - tag.track_total # total number of tracks as string + tag.track # track number + tag.track_total # total number of tracks tag.year # year or date as string For non-common fields and fields specific to certain file formats, use `extra`: @@ -80,6 +79,7 @@ For non-common fields and fields specific to certain file formats, use `extra`: The following standard `extra` field names are used when file formats provide relevant data: `bpm` + `composer` `copyright` `director` `initial_key` From 432d7bafd13b4badfb6419c48e34d49bdb199477 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 10:14:50 +0200 Subject: [PATCH 121/305] ID3: Add unknown fields to 'extra' dict --- tinytag/tests/test_all.py | 46 +++++++++++++++++++++++++++++---------- tinytag/tinytag.py | 35 ++++++++++++++--------------- 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9874950..26fb325 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -47,12 +47,14 @@ {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731}), ('samples/vbr_xing_header_2channel.mp3', - {'extra': {}, 'filesize': 2000, 'album': "The Harpers' Masque", + {'extra': {'tsse': 'LAME 32bits version 3.99.5 (http://lame.sf.net)', 'tlen': '249976'}, + 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.138, + {'extra': {'ten': 'iTunes v4.6'}, 'channels': 2, 'samplerate': 44100, + 'track_total': 11, 'duration': 0.138, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', 'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), @@ -73,7 +75,11 @@ 'musicip puid': '6ff97581-1c73-fc05-b4e4-a4ccee12ec84', 'asin': 'B003KVNV4S', 'musicbrainz album status': 'Official', 'musicbrainz album type': 'Album', 'musicbrainz album release country': 'United States', - 'publisher': '4AD'}, + 'ufid': 'http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5', + 'publisher': '4AD', 'tdat': '1105', + 'wxxx': 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet', + 'tmed': 'Digital', 'tlen': '203733', + 'tsse': 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)'}, 'track_total': 11, 'track': 7, 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), @@ -103,15 +109,16 @@ {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), ('samples/id3v22_image.mp3', - {'extra': {}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, + {'extra': {'rva': '\x10', 'tbp': '131'}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', - {'extra': {}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', + {'extra': {'ten': 'iTunes 11.0.4'}, 'filesize': 500, 'album': 'ARTPOP', + 'artist': 'Lady GaGa', 'comment': 'engiTunPGAP\x000', 'genre': 'Pop', 'title': 'Applause'}), ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {'copyright': '(c) 2008 nin', 'isrc': 'USTC40852229', 'bpm': '60', - 'url': 'www.nin.com'}, + 'url': 'www.nin.com', 'tenc': 'LAME 3.97'}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'disc': 1, 'disc_total': 2, 'title': '1 Ghosts I', 'track': 1, 'track_total': 36, @@ -131,10 +138,11 @@ 'duration': 1.0438932496075353}), ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', - 'artist': 'Blind Guardian', 'extra': {'love rating': 'L', 'publisher': 'Century Media'}, + 'artist': 'Blind Guardian', + 'extra': {'love rating': 'L', 'publisher': 'Century Media', 'popm': 'MusicBee\x00Ä'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), ('samples/nicotinetestdata.mp3', - {'extra': {}, 'filesize': 80919, 'channels': 2, + {'extra': {'tsse': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2, 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), ('samples/chinese_id3.mp3', {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', @@ -142,7 +150,8 @@ 'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': 1}), ('samples/cut_off_titles.mp3', - {'extra': {}, 'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', + {'extra': {'tsse': 'Lavf54.29.104'}, 'filesize': 1000, 'album': 'ERB', + 'artist': 'Epic Rap Battles Of History', 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), ('samples/id3_xxx_lang.mp3', @@ -162,7 +171,12 @@ 'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious', 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America', - 'composer': 'Billy Howerdel/Maynard James Keenan'}, + 'composer': 'Billy Howerdel/Maynard James Keenan', 'tmed': 'CD', + 'tso2': 'Perfect Circle, A', + 'ufid': 'http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72', + 'tsop': 'Perfect Circle, A', 'tory': '2004', 'tdat': '0211', + 'ipls': ('producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan' + '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter')}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', 'artist': 'A Perfect Circle', 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', @@ -376,7 +390,14 @@ 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, 'title': 'title', 'track': 1, 'year': '2018', 'comment': 'comment'}), ('samples/with_padded_id3_header2.flac', - {'extra': {}, 'filesize': 19522, 'album': 'Unbekannter Titel', + {'extra': {'mcdi': ('2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5' + '\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00\x00\x00ä7\x00\x10' + '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'), + 'tlen': '297666', 'tenc': 'Exact Audio Copy (Sicherer Modus)', + 'tsse': ('flac.exe -T "artist=Unbekannter Künstler" -T "title=Track01" -T ' + '"album=Unbekannter Titel" -T "date=" -T "tracknumber=01" -T ' + '"genre=" -5')}, + 'filesize': 19522, 'album': 'Unbekannter Titel', 'artist': 'Unbekannter Künstler', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': 1, 'disc_total': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, @@ -848,6 +869,7 @@ def test_to_str(): '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", ' '"bitdepth": null, "bitrate": 160.0, "channels": 2, ' '"comment": "Waterbug Records, www.anaismitchell.com", "disc": null, ' - '"disc_total": null, "duration": 0.13836297152858082, "extra": {}, "filesize": 5120, ' + '"disc_total": null, "duration": 0.13836297152858082, "extra": {"ten": "iTunes v4.6"}, ' + '"filesize": 5120, ' '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": 3, ' '"track_total": 11, "year": "2004"}') diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 9d2ca53..637b587 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -562,7 +562,7 @@ class _ID3(TinyTag): } IMAGE_FRAME_IDS = {'APIC', 'PIC'} CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} - PARSABLE_FRAME_IDS = set(FRAME_ID_TO_FIELD.keys()).union(IMAGE_FRAME_IDS, CUSTOM_FRAME_IDS) + DISALLOWED_FRAME_IDS = {'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d'} _MAX_ESTIMATION_SEC = 30 _CBR_DETECTION_FRAME_COUNT = 5 _USE_XING_HEADER = True # much faster, but can be deactivated for testing @@ -768,7 +768,7 @@ def _parse_id3v2_header(self, fh): if tag == 'ID3': major, rev = header[1:3] if DEBUG: - stderr('Found id3 v2.{major}') + stderr(f'Found id3 v2.{major}') # unsync = (header[3] & 0x80) > 0 extended = (header[3] & 0x40) > 0 # experimental = (header[3] & 0x20) > 0 @@ -835,9 +835,6 @@ def _parse_frame(self, fh, id3version=False): (frame_id, fh.tell(), fh.tell() + frame_size, self.filesize)) if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. - if frame_id not in self.PARSABLE_FRAME_IDS: # jump over unparsable frames - fh.seek(frame_size, os.SEEK_CUR) - return frame_size content = fh.read(frame_size) fieldname = self.FRAME_ID_TO_FIELD.get(frame_id) if fieldname: @@ -869,18 +866,22 @@ def _parse_frame(self, fh, id3version=False): custom_field_name, _separator, value = custom_text.partition('\x00') if custom_field_name: self._set_field('extra.' + custom_field_name.lower(), value.lstrip('\ufeff')) - elif frame_id in self.IMAGE_FRAME_IDS and self._load_image: - # See section 4.14: http://id3.org/id3v2.4.0-frames - encoding = content[0:1] - if frame_id == 'PIC': # ID3 v2.2: - desc_start_pos = 1 + 3 + 1 # skip encoding (1), imgformat (3), pictype(1) - else: # ID3 v2.3+ - desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mimetype, pictype(1) - # latin1 and utf-8 are 1 byte - termination = b'\x00' if encoding in (b'\x00', b'\x03') else b'\x00\x00' - desc_length = self.index_utf16(content[desc_start_pos:], termination) - desc_end_pos = desc_start_pos + desc_length + len(termination) - self._image_data = content[desc_end_pos:] + elif frame_id in self.IMAGE_FRAME_IDS: + if self._load_image: + # See section 4.14: http://id3.org/id3v2.4.0-frames + encoding = content[0:1] + if frame_id == 'PIC': # ID3 v2.2: + desc_start_pos = 1 + 3 + 1 # skip encoding (1), imgformat (3), pictype(1) + else: # ID3 v2.3+ + desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mtype, pictype(1) + # latin1 and utf-8 are 1 byte + termination = b'\x00' if encoding in (b'\x00', b'\x03') else b'\x00\x00' + desc_length = self.index_utf16(content[desc_start_pos:], termination) + desc_end_pos = desc_start_pos + desc_length + len(termination) + self._image_data = content[desc_end_pos:] + elif frame_id not in self.DISALLOWED_FRAME_IDS: + # unknown, try to add to extra dict + self._set_field('extra.' + frame_id.lower(), self._decode_string(content)) return frame_size return 0 From e7cc4f88603adfcf3d0cb26e5ad53cf64581e130 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 19:11:10 +0200 Subject: [PATCH 122/305] FLAC: Apply ID3 tags after Vorbis ID3 tags are not common in FLAC files, so they should not be prioritized. --- tinytag/tests/test_all.py | 12 ++++++------ tinytag/tinytag.py | 5 ++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 26fb325..6a8e37a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -379,9 +379,9 @@ {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.68, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', - {'extra': {'id': '8591671910'}, 'filesize': 64837, 'album': ' ', - 'artist': '群星', - 'title': 'A 梦 哆啦 机器猫 短信铃声', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, + {'extra': {'id': '8591671910'}, 'filesize': 64837, 'album': 'album', + 'artist': 'artist', + 'title': 'title', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, 'year': '2018', 'comment': 'comment'}), ('samples/with_padded_id3_header.flac', @@ -397,11 +397,11 @@ 'tsse': ('flac.exe -T "artist=Unbekannter Künstler" -T "title=Track01" -T ' '"album=Unbekannter Titel" -T "date=" -T "tracknumber=01" -T ' '"genre=" -5')}, - 'filesize': 19522, 'album': 'Unbekannter Titel', - 'artist': 'Unbekannter Künstler', 'bitrate': 344.36807999999996, + 'filesize': 19522, 'album': 'album', + 'artist': 'artist', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': 1, 'disc_total': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'title': 'Track01', 'track': 1, 'track_total': 5, 'year': '2018', + 'title': 'title', 'track': 1, 'track_total': 5, 'year': '2018', 'comment': 'comment'}), ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 637b587..db5f249 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1225,16 +1225,19 @@ def load(self, tags, duration, image=False): self._parse_tags = tags self._parse_duration = duration self._load_image = image + id3 = None header = self._filehandler.peek(4) if header[:3] == b'ID3': # parse ID3 header if it exists id3 = _ID3(self._filehandler, 0) id3._parse_id3v2(self._filehandler) - self.update(id3) header = self._filehandler.peek(4) # after ID3 should be fLaC if header[:4] != b'fLaC': raise TinyTagException('Invalid flac header') self._filehandler.seek(4, os.SEEK_CUR) self._determine_duration(self._filehandler) + if id3 is not None: # apply ID3 tags after vorbis + self.update(id3) + def _determine_duration(self, fh): # for spec, see https://xiph.org/flac/ogg_mapping.html From 088bed24ea37761c9a1b19b2f74585419f072c6b Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 19:16:14 +0200 Subject: [PATCH 123/305] Fix linting error --- tinytag/tinytag.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index db5f249..8039c48 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1238,7 +1238,6 @@ def load(self, tags, duration, image=False): if id3 is not None: # apply ID3 tags after vorbis self.update(id3) - def _determine_duration(self, fh): # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) From 0268526d2e0e53f8b8dc47bda9d1458c0a2b718b Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 19:57:19 +0200 Subject: [PATCH 124/305] Support multiple fields with the same name (#193) --- README.md | 4 + tinytag/__main__.py | 6 +- .../tests/samples/flac_multiple_fields.flac | Bin 0 -> 235 bytes tinytag/tests/test_all.py | 48 +++++++---- tinytag/tinytag.py | 76 ++++++++++-------- 5 files changed, 86 insertions(+), 48 deletions(-) create mode 100644 tinytag/tests/samples/flac_multiple_fields.flac diff --git a/README.md b/README.md index 5029519..0afb09b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ List of common attributes tinytag provides: tag.track_total # total number of tracks tag.year # year or date as string +If multiple fields with the same name are provided, the values are separated with a null character: + + tag.artist == 'artist 1\x00artist 2\x00artist 3' + For non-common fields and fields specific to certain file formats, use `extra`: tag.extra # a dict of additional data diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 0a6c73b..da118bc 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -79,7 +79,11 @@ def pop_switch(name, _default): data.update(tag.as_dict()) if formatting == 'json': print(json.dumps(data)) - elif formatting == 'csv': + continue + for k, v in data.items(): + if isinstance(v, str): + data[k] = v.replace('\x00', ';') # use a more friendly separator for text output + if formatting == 'csv': print('\n'.join('%s,%s' % (k, v) for k, v in data.items())) elif formatting == 'tsv': print('\n'.join('%s\t%s' % (k, v) for k, v in data.items())) diff --git a/tinytag/tests/samples/flac_multiple_fields.flac b/tinytag/tests/samples/flac_multiple_fields.flac new file mode 100644 index 0000000000000000000000000000000000000000..33f27ebc039bd488a272ad3ccbd47dd1bbd64937 GIT binary patch literal 235 zcmYfENpxmlU{DfZ5CBr#3=F(nM;tydFbG<`Dvw Date: Tue, 27 Feb 2024 20:01:31 +0200 Subject: [PATCH 125/305] Wma: Remove unused code --- tinytag/tinytag.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3354de3..2af5f82 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1348,15 +1348,6 @@ def read_blocks(self, fh, blocks): decoded[block[0]] = val return decoded - def __bytes_to_guid(self, obj_id_bytes): - return '-'.join([ - hex(_bytes_to_int_le(obj_id_bytes[:-12]))[2:].zfill(6), - hex(_bytes_to_int_le(obj_id_bytes[-12:-10]))[2:].zfill(4), - hex(_bytes_to_int_le(obj_id_bytes[-10:-8]))[2:].zfill(4), - hex(_bytes_to_int(obj_id_bytes[-8:-6]))[2:].zfill(4), - hex(_bytes_to_int(obj_id_bytes[-6:]))[2:].zfill(12), - ]) - def __decode_string(self, bytestring): return self._unpad(bytestring.decode('utf-16')) From 36ebcbf20eba42d3cf48bdc1066c53fa789bf148 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 20:43:32 +0200 Subject: [PATCH 126/305] Various cleanups after removal of Python 2 support (#198) --- tinytag/__main__.py | 3 +- tinytag/tests/test_all.py | 31 ++++------ tinytag/tests/test_cli.py | 18 +++--- tinytag/tinytag.py | 116 +++++++++++++++----------------------- 4 files changed, 67 insertions(+), 101 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index da118bc..167567b 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,7 +1,5 @@ -from __future__ import absolute_import from os.path import splitext import os -import json import sys from tinytag.tinytag import TinyTag, TinyTagException @@ -78,6 +76,7 @@ def pop_switch(name, _default): data = {'filename': filename} data.update(tag.as_dict()) if formatting == 'json': + import json print(json.dumps(data)) continue for k, v in data.items(): diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 47d2a04..86c9ed4 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -8,8 +8,6 @@ # -from __future__ import unicode_literals - import io import operator import os @@ -22,13 +20,8 @@ from tinytag.tinytag import TinyTag, TinyTagException, _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff -try: - from collections import OrderedDict -except ImportError: - OrderedDict = dict # python 2.6 and 3.2 compat - -testfiles = OrderedDict([ +testfiles = dict([ # MP3 ('samples/vbri.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, @@ -880,16 +873,14 @@ def test_show_hint_for_wrong_usage(): def test_to_str(): tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) - assert str(tag) # since the dict is not ordered we cannot == 'somestring' - assert repr(tag) # since the dict is not ordered we cannot == 'somestring' assert str(tag) == ( - '{"album": "Hymns for the Exiled", "albumartist": null, "artist": "Anais Mitchell", ' - '"bitdepth": null, "bitrate": 160.0, "channels": 2, ' - '"comment": "Waterbug Records, www.anaismitchell.com", "disc": null, ' - '"disc_total": null, "duration": 0.13836297152858082, "extra": {"ten": "iTunes v4.6", ' - '"itunnorm": " 0000044E 00000061 00009B67 000044C3 00022478 00022182 00007FCC ' - '00007E5C 0002245E 0002214E", "itunes_cddb_1": "9D09130B+174405+11+150+14097+27391+43983+' - '65786+84877+99399+113226+132452+146426+163829", "itunes_cddb_tracknumber": "3"}, ' - '"filesize": 5120, ' - '"genre": null, "samplerate": 44100, "title": "cosmic american", "track": 3, ' - '"track_total": 11, "year": "2004"}') + "{'album': 'Hymns for the Exiled', 'albumartist': None, 'artist': 'Anais Mitchell', " + "'bitdepth': None, 'bitrate': 160.0, 'channels': 2, " + "'comment': 'Waterbug Records, www.anaismitchell.com', 'disc': None, " + "'disc_total': None, 'duration': 0.13836297152858082, 'extra': {'ten': 'iTunes v4.6', " + "'itunnorm': ' 0000044E 00000061 00009B67 000044C3 00022478 00022182 00007FCC " + "00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+174405+11+150+14097+27391+43983+" + "65786+84877+99399+113226+132452+146426+163829', 'itunes_cddb_tracknumber': '3'}, " + "'filesize': 5120, " + "'genre': None, 'samplerate': 44100, 'title': 'cosmic american', 'track': 3, " + "'track_total': 11, 'year': '2004'}") diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index e80f8a9..c48a634 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -1,6 +1,4 @@ -import json import os -import sys from subprocess import check_output, CalledProcessError from tempfile import NamedTemporaryFile @@ -19,7 +17,10 @@ def run_cli(args): + debug_env = os.environ.pop("DEBUG", None) output = check_output('python -m tinytag ' + args, cwd=project_folder, shell=True) + if debug_env: + os.environ["DEBUG"] = debug_env return output.decode('utf-8') @@ -37,11 +38,10 @@ def test_print_help(): assert 'tinytag [options] 0 with open(temp_file.name, 'rb') as fh: @@ -50,29 +50,28 @@ def test_save_image_long_opt(): assert b'JFIF' in image_data -@pytest.mark.skipif(sys.platform == "win32", - reason="NamedTemporaryFile can't be reopened on windows") def test_save_image_short_opt(): temp_file = NamedTemporaryFile() assert file_size(temp_file.name) == 0 + temp_file.close() run_cli(f'-i {temp_file.name} {mp3_with_image}') assert file_size(temp_file.name) > 0 -@pytest.mark.skipif(sys.platform == "win32", - reason="NamedTemporaryFile can't be reopened on windows") def test_save_image_bulk(): temp_file = NamedTemporaryFile(suffix='.jpg') temp_file_no_ext = temp_file.name[:-4] assert file_size(temp_file.name) == 0 + temp_file.close() run_cli(f'-i {temp_file.name} {mp3_with_image} {mp3_with_image} {mp3_with_image}') - assert file_size(temp_file.name) == 0 + assert not os.path.isfile(temp_file.name) assert file_size(temp_file_no_ext + '00000.jpg') > 0 assert file_size(temp_file_no_ext + '00001.jpg') > 0 assert file_size(temp_file_no_ext + '00002.jpg') > 0 def test_meta_data_output_default_json(): + import json output = run_cli(mp3_with_image) data = json.loads(output) assert data @@ -80,6 +79,7 @@ def test_meta_data_output_default_json(): def test_meta_data_output_format_json(): + import json output = run_cli('-f json ' + mp3_with_image) data = json.loads(output) assert data diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 2af5f82..a48f2d9 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -30,39 +30,21 @@ # SOFTWARE. -from collections import defaultdict -from collections.abc import MutableMapping from functools import reduce -from io import BytesIO +from sys import stderr import base64 import io -import json -import operator import os import re import struct -import sys -DEBUG = os.environ.get('DEBUG', False) # some of the parsers can print debug info +DEBUG = bool(os.environ.get('DEBUG')) # some of the parsers can print debug info class TinyTagException(Exception): pass -def _read(fh, nbytes): # helper function to check if we haven't reached EOF - b = fh.read(nbytes) - if len(b) < nbytes: - raise TinyTagException('Unexpected end of file') - return b - - -def stderr(*args): - args_str = ' '.join(repr(arg) for arg in args) - sys.stderr.write(f'{args_str}\n') - sys.stderr.flush() - - def _bytes_to_int_le(b): fmt = {1: 'I', data_atom[:4])[0] conversion = cls.ATOM_DECODER_BY_TYPE.get(data_type) if conversion is None: - stderr(f'Cannot convert data type: {data_type}') + if DEBUG: + print(f'Cannot convert data type: {data_type}', file=stderr) return {} # don't know how to convert data atom # skip header & null-bytes, convert rest return {fieldname: conversion(data_atom[8:])} @@ -356,7 +333,7 @@ def read_extended_descriptor(cls, esds_atom): @classmethod def parse_custom_field(cls, data): - fh = BytesIO(data) + fh = io.BytesIO(data) header_size = 8 field_name = None data_atom = b'' @@ -383,7 +360,7 @@ def parse_audio_sample_entry_mp4a(cls, data): # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt # http://sasperger.tistory.com/103 - datafh = BytesIO(data) + datafh = io.BytesIO(data) datafh.seek(16, os.SEEK_CUR) # jump over version and flags channels = struct.unpack('>H', datafh.read(2))[0] datafh.seek(2, os.SEEK_CUR) # jump over bit_depth @@ -392,7 +369,7 @@ def parse_audio_sample_entry_mp4a(cls, data): # ES Description Atom esds_atom_size = struct.unpack('>I', data[28:32])[0] - esds_atom = BytesIO(data[36:36 + esds_atom_size]) + esds_atom = io.BytesIO(data[36:36 + esds_atom_size]) esds_atom.seek(5, os.SEEK_CUR) # jump over version, flags and tag # ES Descriptor @@ -409,7 +386,7 @@ def parse_audio_sample_entry_mp4a(cls, data): def parse_audio_sample_entry_alac(cls, data): # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt alac_atom_size = struct.unpack('>I', data[28:32])[0] - alac_atom = BytesIO(data[36:36 + alac_atom_size]) + alac_atom = io.BytesIO(data[36:36 + alac_atom_size]) alac_atom.seek(9, os.SEEK_CUR) bitdepth = struct.unpack('b', alac_atom.read(1))[0] alac_atom.seek(3, os.SEEK_CUR) @@ -422,7 +399,7 @@ def parse_audio_sample_entry_alac(cls, data): @classmethod def parse_mvhd(cls, data): # http://stackoverflow.com/a/3639993/1191373 - walker = BytesIO(data) + walker = io.BytesIO(data) version = struct.unpack('b', walker.read(1))[0] walker.seek(3, os.SEEK_CUR) # jump over flags if version == 0: # uses 32 bit integers for timestamps @@ -437,7 +414,7 @@ def parse_mvhd(cls, data): @classmethod def debug_atom(cls, data): - stderr(data) # use this function to inspect atoms in an atom tree + print(data) # use this function to inspect atoms in an atom tree return {} # The parser tree: Each key is an atom name which is traversed if existing. @@ -509,16 +486,15 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): atom_header = fh.read(header_size) continue if DEBUG: - stderr('%s pos: %d atom: %s len: %d' % - (' ' * 4 * len(curr_path), fh.tell() - header_size, atom_type, - atom_size + header_size)) + print((f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' + f'atom: {atom_type} len: {atom_size + header_size}')) if atom_type in self.VERSIONED_ATOMS: # jump atom version for now fh.seek(4, os.SEEK_CUR) if atom_type in self.FLAGGED_ATOMS: # jump atom flags for now fh.seek(4, os.SEEK_CUR) sub_path = path.get(atom_type, None) # if the path leaf is a dict, traverse deeper into the tree: - if issubclass(type(sub_path), MutableMapping): + if isinstance(sub_path, dict): atom_end_pos = fh.tell() + atom_size self._traverse_atoms(fh, path=sub_path, stop_pos=atom_end_pos, curr_path=curr_path + [atom_type]) @@ -526,7 +502,7 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): elif callable(sub_path): for fieldname, value in sub_path(fh.read(atom_size)).items(): if DEBUG: - stderr(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) + print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) if fieldname: self._set_field(fieldname, value) # if no action was specified using dict or callable, jump over atom @@ -763,13 +739,13 @@ def _parse_tag(self, fh): def _parse_id3v2_header(self, fh): size, extended, major = 0, None, None # for info on the specs, see: http://id3.org/Developer%20Information - header = struct.unpack('3sBBB4B', _read(fh, 10)) + header = struct.unpack('3sBBB4B', fh.read(10)) tag = header[0].decode('ISO-8859-1') # check if there is an ID3v2 tag at the beginning of the file if tag == 'ID3': major, rev = header[1:3] if DEBUG: - stderr(f'Found id3 v2.{major}') + print(f'Found id3 v2.{major}') # unsync = (header[3] & 0x80) > 0 extended = (header[3] & 0x40) > 0 # experimental = (header[3] & 0x20) > 0 @@ -784,7 +760,7 @@ def _parse_id3v2(self, fh): end_pos = fh.tell() + size parsed_size = 0 if extended: # just read over the extended header. - size_bytes = struct.unpack('4B', _read(fh, 6)[0:4]) + size_bytes = struct.unpack('4B', fh.read(6)[0:4]) extd_size = self._calc_size(size_bytes, 7) fh.seek(extd_size - 6, os.SEEK_CUR) # jump over extended_header while parsed_size < size: @@ -839,8 +815,8 @@ def _parse_frame(self, fh, id3version=False): frame_id = self._decode_string(frame[0]) frame_size = self._calc_size(frame[1:1 + frame_size_bytes], bits_per_byte) if DEBUG: - stderr('Found id3 Frame %s at %d-%d of %d' % - (frame_id, fh.tell(), fh.tell() + frame_size, self.filesize)) + print((f'Found id3 Frame {frame_id} at {fh.tell()}-{fh.tell() + frame_size} ' + f'of {self.filesize}')) if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. content = fh.read(frame_size) @@ -869,7 +845,7 @@ def _parse_frame(self, fh, id3version=False): value = _ID3.ID3V1_GENRES[genre_id] except ValueError as exc: if DEBUG: - stderr(f'Failed to read {fieldname}: {exc}') + print(f'Failed to read {fieldname}: {exc}', file=stderr) else: if should_set_field: self._set_field(fieldname, value) @@ -976,7 +952,7 @@ def _parse_tag(self, fh): check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): - walker = BytesIO(packet) + walker = io.BytesIO(packet) if packet[0:7] == b"\x01vorbis": if self._parse_duration: (channels, self.samplerate, max_bitrate, bitrate, @@ -1031,7 +1007,7 @@ def _parse_tag(self, fh): check_speex_second_packet = False else: if DEBUG: - stderr('Unsupported Ogg page type: ', packet[:16]) + print('Unsupported Ogg page type: ', packet[:16], file=stderr) break self._tags_parsed = True @@ -1082,11 +1058,11 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): if key_lowercase == "metadata_block_picture" and self._load_image: if DEBUG: - stderr('Found Vorbis Image', key, value[:64]) - self._image_data = _Flac._parse_image(BytesIO(base64.b64decode(value))) + print('Found Vorbis Image', key, value[:64]) + self._image_data = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) else: if DEBUG: - stderr('Found Vorbis Comment', key, value[:64]) + print('Found Vorbis Comment', key, value[:64]) fieldname = comment_type_to_attr_mapping.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' try: @@ -1099,7 +1075,7 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): value = int(value) except ValueError as exc: if DEBUG: - stderr(f'Failed to read {fieldname}: {exc}') + print(f'Failed to read {fieldname}: {exc}', file=stderr) else: self._set_field(fieldname, value) @@ -1190,7 +1166,7 @@ def _determine_duration(self, fh): if is_info != b'INFO': # jump over non-INFO sections fh.seek(subchunksize - 4, os.SEEK_CUR) else: - sub_fh = BytesIO(fh.read(subchunksize - 4)) + sub_fh = io.BytesIO(fh.read(subchunksize - 4)) field = sub_fh.read(4) while len(field) == 4: data_length = struct.unpack('I', sub_fh.read(4))[0] @@ -1204,7 +1180,7 @@ def _determine_duration(self, fh): value = int(value) except ValueError as exc: if DEBUG: - stderr(f'Failed to read {fieldname}: {exc}') + print(f'Failed to read {fieldname}: {exc}', file=stderr) else: self._set_field(fieldname, value) field = sub_fh.read(4) @@ -1299,7 +1275,7 @@ def _determine_duration(self, fh): return # invalid block type else: if DEBUG: - stderr('Unknown FLAC block type', block_type) + print('Unknown FLAC block type', block_type) fh.seek(size, 1) # seek over this block if is_last_block: @@ -1431,7 +1407,7 @@ def _parse_tag(self, fh): field_value = int(field_value) except ValueError as exc: if DEBUG: - stderr(f'Failed to read {field_name}: {exc}') + print(f'Failed to read {field_name}: {exc}', file=stderr) else: self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT: From 264dd361f04ce0bae39aac61f808325fb265ef17 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 27 Feb 2024 20:46:11 +0200 Subject: [PATCH 127/305] ID3: Minor cleanup --- tinytag/tinytag.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a48f2d9..5396f2d 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -887,9 +887,8 @@ def _decode_string(self, bytestr, language=False): if bytestr[3:5] in (b'\xfe\xff', b'\xff\xfe'): bytestr = bytestr[3:] if bytestr[:3].isalpha(): - bytestr = bytestr[3:].lstrip(b'\x00') # remove language - if bytestr[:1] == b'\x00': - bytestr = bytestr[1:] # strip optional additional null byte + bytestr = bytestr[3:] # remove language + bytestr = bytestr.lstrip(b'\x00') # strip optional additional null bytes # read byte order mark to determine endianness encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' # strip the bom if it exists @@ -909,7 +908,7 @@ def _decode_string(self, bytestr, language=False): bytestr = bytestr encoding = default_encoding # wild guess if language and bytestr[:3].isalpha(): - bytestr = bytestr[3:].lstrip(b'\x00') # remove language + bytestr = bytestr[3:] # remove language errors = 'ignore' if self._ignore_errors else 'strict' return self._unpad(bytestr.decode(encoding, errors)) except UnicodeDecodeError as exc: From 0aa73002d90c106e325741cc972adebf889b6544 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 01:12:31 +0200 Subject: [PATCH 128/305] Use Pylint for linting (#199) --- .github/workflows/tests.yml | 5 +- release.py | 1 + setup.cfg | 48 +++--- setup.py | 2 + tinytag/__init__.py | 4 +- tinytag/__main__.py | 93 ++++++----- tinytag/tests/test_all.py | 228 +++++++------------------- tinytag/tests/test_cli.py | 28 ++-- tinytag/tinytag.py | 318 ++++++++++++++++++------------------ 9 files changed, 316 insertions(+), 411 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1089d4f..a5bbf08 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,7 +22,7 @@ jobs: python-version: ${{ matrix.python }} - name: Install dependencies - run: python -m pip install build .[tests] + run: python -m pip install build setuptools .[tests] - name: Downgrade importlib-metadata run: python -m pip install importlib-metadata==4.13.0 @@ -31,6 +31,9 @@ jobs: - name: Flake8 linter run: python -m flake8 + - name: Pylint linter + run: python -m pylint --recursive=y --ignore-paths=build . + - name: Unit tests run: python -m pytest --cov env: diff --git a/release.py b/release.py index ba7be98..840845d 100755 --- a/release.py +++ b/release.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# pylint: disable=missing-function-docstring,missing-module-docstring import subprocess import sys diff --git a/setup.cfg b/setup.cfg index 39cd9cf..781e677 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = tinytag -version = attr: tinytag.__version__ +version = 2.0.0 author = Tom Wallroth author_email = tomwallroth@gmail.com url = https://github.com/devsnd/tinytag @@ -40,34 +40,36 @@ install_requires = [options.extras_require] tests = + flake8 + pylint pytest pytest-cov - flake8 [options.entry_points] console_scripts = [flake8] max-line-length = 100 -exclude = .git,__pycache__,.eggs/,doc/,docs/,build/,dist/,archive/,src/ - -[coverage:run] -cover_pylib = false -omit = - */site-packages/* - */bin/* - */src/* -[coverage:report] -exclude_lines = - pragma: no cover - def __repr__ - except RuntimeError - except NotImplementedError - except ImportError - except FileNotFoundError - except CalledProcessError - logging.warning - logging.error - logging.critical - if __name__ == .__main__.: +[pylint] +enable = + consider-using-augmented-assign, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero +load-plugins = + pylint.extensions.bad_builtin, + pylint.extensions.check_elif, + pylint.extensions.code_style, + pylint.extensions.comparetozero, + pylint.extensions.comparison_placement, + pylint.extensions.consider_refactoring_into_while_condition, + pylint.extensions.emptystring, + pylint.extensions.for_any_all, + pylint.extensions.dict_init_mutate, + pylint.extensions.dunder, + pylint.extensions.eq_without_hash, + pylint.extensions.overlapping_exceptions, + pylint.extensions.private_import, + pylint.extensions.set_membership, + pylint.extensions.typing +py-version = 3.6 diff --git a/setup.py b/setup.py index c823345..0155151 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +# pylint: disable=missing-module-docstring + from setuptools import setup setup() diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 5f2cfcd..41c94b3 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,7 +1,5 @@ #!/usr/bin/python -# -*- coding: utf-8 -*- - -__version__ = '1.10.1' +# pylint: disable=missing-module-docstring import sys from .tinytag import TinyTag, TinyTagException # noqa: F401 diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 167567b..ce1aa9a 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,11 +1,14 @@ +# pylint: disable=missing-module-docstring,protected-access + from os.path import splitext +import json import os import sys from tinytag.tinytag import TinyTag, TinyTagException -def usage(): +def _usage(): print('''tinytag [options] -h, --help @@ -23,7 +26,7 @@ def usage(): ''') -def pop_param(name, _default): +def _pop_param(name, _default): if name in sys.argv: idx = sys.argv.index(name) sys.argv.pop(idx) @@ -31,7 +34,7 @@ def pop_param(name, _default): return _default -def pop_switch(name, _default): +def _pop_switch(name, _default): if name in sys.argv: idx = sys.argv.index(name) sys.argv.pop(idx) @@ -39,58 +42,62 @@ def pop_switch(name, _default): return False -try: - display_help = pop_switch('--help', False) or pop_switch('-h', False) +def _print_tag(tag, formatting, header_printed=False): + data = {'filename': tag._filename} + data.update(tag._as_dict()) + if formatting == 'json': + print(json.dumps(data)) + return header_printed + for field, value in data.items(): + if isinstance(value, str): + data[field] = value.replace('\x00', ';') # use a more friendly separator for output + if formatting == 'csv': + print('\n'.join(f'{field},{value}' for field, value in data.items())) + elif formatting == 'tsv': + print('\n'.join(f'{field}\t{value}' for field, value in data.items())) + elif formatting == 'tabularcsv': + if not header_printed: + print(','.join(field for field, value in data.items())) + header_printed = True + print(','.join(f'"{value}"' for field, value in data.items())) + return header_printed + + +def _run(): + display_help = _pop_switch('--help', False) or _pop_switch('-h', False) if display_help: - usage() - sys.exit(0) - save_image_path = pop_param('--save-image', None) or pop_param('-i', None) - formatting = (pop_param('--format', None) or pop_param('-f', None)) or 'json' - skip_unsupported = pop_switch('--skip-unsupported', False) or pop_switch('-s', False) + _usage() + return 0 + save_image_path = _pop_param('--save-image', None) or _pop_param('-i', None) + formatting = (_pop_param('--format', None) or _pop_param('-f', None)) or 'json' + skip_unsupported = _pop_switch('--skip-unsupported', False) or _pop_switch('-s', False) filenames = sys.argv[1:] -except Exception as exc: - print(exc) - usage() - sys.exit(1) - -header_printed = False + header_printed = False -for i, filename in enumerate(filenames): - try: + for i, filename in enumerate(filenames): if skip_unsupported: if os.path.isdir(filename): continue if not TinyTag.is_supported(filename): continue - tag = TinyTag.get(filename, image=save_image_path is not None) + try: + tag = TinyTag.get(filename, image=save_image_path is not None) + except TinyTagException as exc: + sys.stderr.write(f'{filename}: {exc}\n') + return 1 if save_image_path: # allow for saving the image of multiple files actual_save_image_path = save_image_path if len(filenames) > 1: actual_save_image_path, ext = splitext(actual_save_image_path) - actual_save_image_path += '%05d' % i + ext + actual_save_image_path += f'{i:05d}{ext}' image = tag.get_image() if image: - with open(actual_save_image_path, 'wb') as fh: - fh.write(image) - data = {'filename': filename} - data.update(tag.as_dict()) - if formatting == 'json': - import json - print(json.dumps(data)) - continue - for k, v in data.items(): - if isinstance(v, str): - data[k] = v.replace('\x00', ';') # use a more friendly separator for text output - if formatting == 'csv': - print('\n'.join('%s,%s' % (k, v) for k, v in data.items())) - elif formatting == 'tsv': - print('\n'.join('%s\t%s' % (k, v) for k, v in data.items())) - elif formatting == 'tabularcsv': - if not header_printed: - print(','.join(k for k, v in data.items())) - header_printed = True - print(','.join('"%s"' % v for k, v in data.items())) - except TinyTagException as e: - sys.stderr.write('%s: %s\n' % (filename, str(e))) - sys.exit(1) + with open(actual_save_image_path, 'wb') as file_handle: + file_handle.write(image) + header_printed = _print_tag(tag, formatting, header_printed) + return 0 + + +if __name__ == '__main__': + sys.exit(_run()) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 86c9ed4..df903dd 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1,22 +1,21 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - # tests can be extended using other bigger files that are not going to be # checked into git, by placing them into the custom_samples folder # # see custom_samples/instructions.txt # +# pylint: disable=missing-function-docstring,missing-module-docstring,protected-access + import io import operator import os +import pathlib import re import shutil import sys import pytest -from pytest import raises from tinytag.tinytag import TinyTag, TinyTagException, _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff @@ -588,38 +587,35 @@ def startswith(val1, val2): def error_fmt(value): - return '%s (%s)' % (repr(value), type(value)) + return f'{repr(value)} ({type(value)})' def compare(results, expected, file, prev_path=None): assert isinstance(results, dict) missing_keys = set(expected.keys()) - set(results) - assert not missing_keys, 'Missing data in fixture \n%s' % str(missing_keys) + assert not missing_keys, f'Missing data in fixture \n{missing_keys}' for key, result_val in results.items(): path = prev_path + '.' + key if prev_path else key try: expected_val = expected[key] except KeyError: - assert False, 'Missing field "%s": "%s" in fixture "%s"!' % ( - key, error_fmt(result_val), file) + assert False, f'Missing field "{key}": "{error_fmt(result_val)}" in fixture "{file}"!' # recurse if the result and expected values are a dict: if isinstance(result_val, dict) and isinstance(expected_val, dict): compare(result_val, expected_val, file, prev_path=key) else: fmt_string = 'field "%s": got %s expected %s in %s!' fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) - op = operator.eq + oper = operator.eq if path == 'duration': # allow duration to be off by 100 ms and a maximum of 1% - op = almost_equal_float + oper = almost_equal_float if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture - op = startswith - assert op(result_val, expected_val), fmt_string % fmt_values + oper = startswith + assert oper(result_val, expected_val), fmt_string % fmt_values -@pytest.mark.parametrize("testfile,expected", [ - pytest.param(testfile, expected) for testfile, expected in testfiles.items() -]) +@pytest.mark.parametrize("testfile,expected", testfiles.items()) def test_file_reading(testfile, expected): filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename) @@ -631,10 +627,6 @@ def test_file_reading(testfile, expected): def test_pathlib_compatibility(): - try: - import pathlib - except ImportError: - return testfile = next(iter(testfiles.keys())) filename = pathlib.Path(testfolder) / testfile TinyTag.get(filename) @@ -688,101 +680,49 @@ def test_unsubclassed_tinytag_parse_tag(): def test_mp3_length_estimation(): - _ID3.set_estimation_precision(0.7) + _ID3._MAX_ESTIMATION_SEC = 0.7 tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) assert 3.5 < tag.duration < 4.0 +@pytest.mark.parametrize("path,cls", [ + ('samples/silence-44-s-v1.mp3', _Flac), + ('samples/incomplete.mp3', _ID3), + ('samples/flac1.5sStereo.flac', _Ogg), + ('samples/flac1.5sStereo.flac', _Wave), + ('samples/ilbm.aiff', _Aiff), +]) @pytest.mark.xfail(raises=TinyTagException) -def test_unexpected_eof(): - _ID3.get(os.path.join(testfolder, 'samples/incomplete.mp3')) - - -@pytest.mark.xfail(raises=TinyTagException) -def test_invalid_flac_file(): - _Flac.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) - - -@pytest.mark.xfail(raises=TinyTagException) -def test_invalid_mp3_file(): - _ID3.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) - - -@pytest.mark.xfail(raises=TinyTagException) -def test_invalid_ogg_file(): - _Ogg.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) - - -@pytest.mark.xfail(raises=TinyTagException) -def test_invalid_wave_file(): - _Wave.get(os.path.join(testfolder, 'samples/flac1.5sStereo.flac')) - - -@pytest.mark.xfail(raises=TinyTagException) -def test_invalid_aiff_file(): - _Aiff.get(os.path.join(testfolder, 'samples/ilbm.aiff')) - - -def test_unpad(): - # make sure that unpad only removes trailing 0-bytes - assert TinyTag._unpad('foo\x00') == 'foo' - assert TinyTag._unpad('foo\x00bar\x00') == 'foo\x00bar' - - -def test_mp3_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/cover_img.mp3'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 140000 < len(image_data) < 150000, ('Image is %d bytes but should be around 145kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_mp3_id3v22_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22_image.mp3'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 18000 < len(image_data) < 19000, ('Image is %d bytes but should be around 18.1kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_mp3_image_loading_without_description(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/id3image_without_description.mp3'), - image=True) - image_data = tag.get_image() - assert image_data is not None - assert 28600 < len(image_data) < 28700, ('Image is %d bytes but should be around 28.6kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_mp3_image_loading_with_utf8_description(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/image-text-encoding.mp3'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 5700 < len(image_data) < 6000, ('Image is %d bytes but should be around 6kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_mp3_image_loading2(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/12oz.mp3'), image=True) +def test_invalid_file(path, cls): + cls.get(os.path.join(testfolder, path)) + + +@pytest.mark.parametrize('path,expected_size', [ + ('samples/cover_img.mp3', 146676), + ('samples/id3v22_image.mp3', 18092), + ('samples/id3image_without_description.mp3', 28680), + ('samples/image-text-encoding.mp3', 5708), + ('samples/12oz.mp3', 2210), + ('samples/iso8859_with_image.m4a', 21963), + ('samples/flac_with_image.flac', 73246), + ('samples/ogg_with_image.ogg', 1220), + ('samples/wav_with_image.wav', 4627), + ('samples/aiff_with_image.aiff', 21963), +]) +def test_image_loading(path, expected_size): + tag = TinyTag.get(os.path.join(testfolder, path), image=True) image_data = tag.get_image() + image_size = len(image_data) assert image_data is not None - assert 2000 < len(image_data) < 2500, ('Image is %d bytes but should be around 145kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') + assert image_size == expected_size, \ + f'Image is {image_size} bytes but should be {expected_size} bytes' + assert image_data.startswith(b'\xff\xd8\xff\xe0'), \ + 'The image data must start with a jpeg header' +@pytest.mark.xfail(raises=TinyTagException) def test_mp3_utf_8_invalid_string_raises_exception(): - with raises(TinyTagException): - TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) + TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) def test_mp3_utf_8_invalid_string_can_be_ignored(): @@ -793,81 +733,29 @@ def test_mp3_utf_8_invalid_string_can_be_ignored(): assert tag.title == 'ran día' -def test_mp4_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/iso8859_with_image.m4a'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 20000 < len(image_data) < 25000, ('Image is %d bytes but should be around 22kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_flac_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 70000 < len(image_data) < 80000, ('Image is %d bytes but should be around 75kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_ogg_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 1000 < len(image_data) < 2000, ('Image is %d bytes but should be around 1.2kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_wav_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/wav_with_image.wav'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 4000 < len(image_data) < 5000, ('Image is %d bytes but should be around 20kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - -def test_aiff_image_loading(): - tag = TinyTag.get(os.path.join(testfolder, 'samples/aiff_with_image.aiff'), image=True) - image_data = tag.get_image() - assert image_data is not None - assert 15000 < len(image_data) < 25000, ('Image is %d bytes but should be around 20kb' % - len(image_data)) - assert image_data.startswith(b'\xff\xd8\xff\xe0'), ('The image data must start with a jpeg ' - 'header') - - @pytest.mark.parametrize("testfile,expected", [ - pytest.param(testfile, expected) for testfile, expected in [ - ('samples/detect_mp3_id3.x', _ID3), - ('samples/detect_mp3_fffb.x', _ID3), - ('samples/detect_ogg_flac.x', _Ogg), - ('samples/detect_ogg_opus.x', _Ogg), - ('samples/detect_ogg_vorbis.x', _Ogg), - ('samples/detect_wav.x', _Wave), - ('samples/detect_flac.x', _Flac), - ('samples/detect_wma.x', _Wma), - ('samples/detect_mp4_m4a.x', _MP4), - ('samples/detect_aiff.x', _Aiff), - ] + ('samples/detect_mp3_id3.x', _ID3), + ('samples/detect_mp3_fffb.x', _ID3), + ('samples/detect_ogg_flac.x', _Ogg), + ('samples/detect_ogg_opus.x', _Ogg), + ('samples/detect_ogg_vorbis.x', _Ogg), + ('samples/detect_wav.x', _Wave), + ('samples/detect_flac.x', _Flac), + ('samples/detect_wma.x', _Wma), + ('samples/detect_mp4_m4a.x', _MP4), + ('samples/detect_aiff.x', _Aiff), ]) def test_detect_magic_headers(testfile, expected): filename = os.path.join(testfolder, testfile) - with io.open(filename, 'rb') as fh: - parser = TinyTag.get_parser_class(filename, fh) + with io.open(filename, 'rb') as file_handle: + parser = TinyTag._get_parser_class(filename, file_handle) assert parser == expected def test_show_hint_for_wrong_usage(): - with pytest.raises(Exception) as exc_info: + with pytest.raises(TinyTagException) as exc_info: TinyTag('filename.mp3', 0) - assert exc_info.type == Exception + assert exc_info.type == TinyTagException assert exc_info.value.args[0] == 'Use `TinyTag.get(filepath)` instead of `TinyTag(filepath)`' diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index c48a634..d9ea501 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -1,3 +1,6 @@ +# pylint: disable=missing-function-docstring,missing-module-docstring + +import json import os from subprocess import check_output, CalledProcessError from tempfile import NamedTemporaryFile @@ -39,30 +42,27 @@ def test_print_help(): def test_save_image_long_opt(): - temp_file = NamedTemporaryFile() - assert file_size(temp_file.name) == 0 - temp_file.close() + with NamedTemporaryFile() as temp_file: + assert file_size(temp_file.name) == 0 run_cli(f'--save-image {temp_file.name} {mp3_with_image}') assert file_size(temp_file.name) > 0 - with open(temp_file.name, 'rb') as fh: - image_data = fh.read(20) + with open(temp_file.name, 'rb') as file_handle: + image_data = file_handle.read(20) assert image_data.startswith(b'\xff') assert b'JFIF' in image_data def test_save_image_short_opt(): - temp_file = NamedTemporaryFile() - assert file_size(temp_file.name) == 0 - temp_file.close() + with NamedTemporaryFile() as temp_file: + assert file_size(temp_file.name) == 0 run_cli(f'-i {temp_file.name} {mp3_with_image}') assert file_size(temp_file.name) > 0 def test_save_image_bulk(): - temp_file = NamedTemporaryFile(suffix='.jpg') - temp_file_no_ext = temp_file.name[:-4] - assert file_size(temp_file.name) == 0 - temp_file.close() + with NamedTemporaryFile(suffix='.jpg') as temp_file: + temp_file_no_ext = temp_file.name[:-4] + assert file_size(temp_file.name) == 0 run_cli(f'-i {temp_file.name} {mp3_with_image} {mp3_with_image} {mp3_with_image}') assert not os.path.isfile(temp_file.name) assert file_size(temp_file_no_ext + '00000.jpg') > 0 @@ -71,7 +71,6 @@ def test_save_image_bulk(): def test_meta_data_output_default_json(): - import json output = run_cli(mp3_with_image) data = json.loads(output) assert data @@ -79,7 +78,6 @@ def test_meta_data_output_default_json(): def test_meta_data_output_format_json(): - import json output = run_cli('-f json ' + mp3_with_image) data = json.loads(output) assert data @@ -104,7 +102,7 @@ def test_meta_data_output_format_tsv(): def test_meta_data_output_format_tabularcsv(): output = run_cli('-f tabularcsv ' + mp3_with_image) - header, line, rest = output.split(os.linesep) + header, _line, _rest = output.split(os.linesep) assert set(header.split(',')) == tinytag_attributes diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5396f2d..c30c917 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - # tinytag - an audio meta info reader # Copyright (c) 2014-2023 Tom Wallroth # Copyright (c) 2021-2023 Mat (mathiascode) @@ -29,6 +27,12 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring +# pylint: disable=invalid-name,protected-access +# pylint: disable=too-many-lines,too-many-arguments,too-many-boolean-expressions +# pylint: disable=too-many-branches,too-many-instance-attributes,too-many-locals +# pylint: disable=too-many-nested-blocks,too-many-statements,too-few-public-methods + from functools import reduce from sys import stderr @@ -45,15 +49,6 @@ class TinyTagException(Exception): pass -def _bytes_to_int_le(b): - fmt = {1: 'b' if signed else '>B', value)[0] @@ -269,7 +275,7 @@ def unpack_integer(cls, value, signed=True): return struct.unpack('>q' if signed else '>Q', value)[0] return None - class Parser: + class _Parser: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 ATOM_DECODER_BY_TYPE = { # 0: 'reserved' @@ -279,8 +285,9 @@ class Parser: # 16: duration in millis 13: lambda x: x, # JPEG 14: lambda x: x, # PNG - 21: lambda x: _MP4.unpack_integer(x), # BE Signed int - 22: lambda x: _MP4.unpack_integer(x, signed=False), # BE Unsigned int + # pylint: disable=unnecessary-lambda + 21: lambda x: _MP4._unpack_integer(x), # BE Signed int + 22: lambda x: _MP4._unpack_integer(x, signed=False), # BE Unsigned int 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 24: lambda x: struct.unpack('>d', x)[0], # BE Float64 # 27: lambda x: x, # BMP @@ -296,8 +303,8 @@ class Parser: } @classmethod - def make_data_atom_parser(cls, fieldname): - def parse_data_atom(data_atom): + def _make_data_atom_parser(cls, fieldname): + def _parse_data_atom(data_atom): data_type = struct.unpack('>I', data_atom[:4])[0] conversion = cls.ATOM_DECODER_BY_TYPE.get(data_type) if conversion is None: @@ -306,10 +313,10 @@ def parse_data_atom(data_atom): return {} # don't know how to convert data atom # skip header & null-bytes, convert rest return {fieldname: conversion(data_atom[8:])} - return parse_data_atom + return _parse_data_atom @classmethod - def make_number_parser(cls, fieldname1, fieldname2): + def _make_number_parser(cls, fieldname1, fieldname2): def _(data_atom): number_data = data_atom[8:14] numbers = struct.unpack('>HHH', number_data) @@ -318,7 +325,7 @@ def _(data_atom): return _ @classmethod - def parse_id3v1_genre(cls, data_atom): + def _parse_id3v1_genre(cls, data_atom): # dunno why the genre is offset by -1 but that's how mutagen does it idx = struct.unpack('>H', data_atom[8:])[0] - 1 if idx < len(_ID3.ID3V1_GENRES): @@ -326,13 +333,13 @@ def parse_id3v1_genre(cls, data_atom): return {'genre': None} @classmethod - def read_extended_descriptor(cls, esds_atom): - for i in range(4): + def _read_extended_descriptor(cls, esds_atom): + for _i in range(4): if esds_atom.read(1) != b'\x80': break @classmethod - def parse_custom_field(cls, data): + def _parse_custom_field(cls, data): fh = io.BytesIO(data) header_size = 8 field_name = None @@ -351,11 +358,11 @@ def parse_custom_field(cls, data): atom_header = fh.read(header_size) # read next atom if len(data_atom) < 8: return {} - parser = cls.make_data_atom_parser(field_name) + parser = cls._make_data_atom_parser(field_name) return parser(data_atom) @classmethod - def parse_audio_sample_entry_mp4a(cls, data): + def _parse_audio_sample_entry_mp4a(cls, data): # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt @@ -373,17 +380,17 @@ def parse_audio_sample_entry_mp4a(cls, data): esds_atom.seek(5, os.SEEK_CUR) # jump over version, flags and tag # ES Descriptor - cls.read_extended_descriptor(esds_atom) + cls._read_extended_descriptor(esds_atom) esds_atom.seek(4, os.SEEK_CUR) # jump over ES id, flags and tag # Decoder Config Descriptor - cls.read_extended_descriptor(esds_atom) + cls._read_extended_descriptor(esds_atom) esds_atom.seek(9, os.SEEK_CUR) avg_br = struct.unpack('>I', esds_atom.read(4))[0] / 1000 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} @classmethod - def parse_audio_sample_entry_alac(cls, data): + def _parse_audio_sample_entry_alac(cls, data): # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt alac_atom_size = struct.unpack('>I', data[28:32])[0] alac_atom = io.BytesIO(data[36:36 + alac_atom_size]) @@ -397,7 +404,7 @@ def parse_audio_sample_entry_alac(cls, data): return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} @classmethod - def parse_mvhd(cls, data): + def _parse_mvhd(cls, data): # http://stackoverflow.com/a/3639993/1191373 walker = io.BytesIO(data) version = struct.unpack('b', walker.read(1))[0] @@ -413,7 +420,7 @@ def parse_mvhd(cls, data): return {'duration': duration / time_scale} @classmethod - def debug_atom(cls, data): + def _debug_atom(cls, data): print(data) # use this function to inspect atoms in an atom tree return {} @@ -423,43 +430,43 @@ def debug_atom(cls, data): META_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { # see: http://atomicparsley.sourceforge.net/mpeg-4files.html # and: https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 - b'\xa9ART': {b'data': Parser.make_data_atom_parser('artist')}, - b'\xa9alb': {b'data': Parser.make_data_atom_parser('album')}, - b'\xa9cmt': {b'data': Parser.make_data_atom_parser('comment')}, + b'\xa9ART': {b'data': _Parser._make_data_atom_parser('artist')}, + b'\xa9alb': {b'data': _Parser._make_data_atom_parser('album')}, + b'\xa9cmt': {b'data': _Parser._make_data_atom_parser('comment')}, # need test-data for this - # b'cpil': {b'data': Parser.make_data_atom_parser('extra.compilation')}, - b'\xa9day': {b'data': Parser.make_data_atom_parser('year')}, - b'\xa9des': {b'data': Parser.make_data_atom_parser('extra.description')}, - b'\xa9dir': {b'data': Parser.make_data_atom_parser('extra.director')}, - b'\xa9gen': {b'data': Parser.make_data_atom_parser('genre')}, - b'\xa9lyr': {b'data': Parser.make_data_atom_parser('extra.lyrics')}, - b'\xa9mvn': {b'data': Parser.make_data_atom_parser('movement')}, - b'\xa9nam': {b'data': Parser.make_data_atom_parser('title')}, - b'\xa9pub': {b'data': Parser.make_data_atom_parser('extra.publisher')}, - b'\xa9wrt': {b'data': Parser.make_data_atom_parser('extra.composer')}, - b'aART': {b'data': Parser.make_data_atom_parser('albumartist')}, - b'cprt': {b'data': Parser.make_data_atom_parser('extra.copyright')}, - b'desc': {b'data': Parser.make_data_atom_parser('extra.description')}, - b'disk': {b'data': Parser.make_number_parser('disc', 'disc_total')}, - b'gnre': {b'data': Parser.parse_id3v1_genre}, - b'trkn': {b'data': Parser.make_number_parser('track', 'track_total')}, - b'tmpo': {b'data': Parser.make_data_atom_parser('extra.bpm')}, - b'----': Parser.parse_custom_field, + # b'cpil': {b'data': _Parser._make_data_atom_parser('extra.compilation')}, + b'\xa9day': {b'data': _Parser._make_data_atom_parser('year')}, + b'\xa9des': {b'data': _Parser._make_data_atom_parser('extra.description')}, + b'\xa9dir': {b'data': _Parser._make_data_atom_parser('extra.director')}, + b'\xa9gen': {b'data': _Parser._make_data_atom_parser('genre')}, + b'\xa9lyr': {b'data': _Parser._make_data_atom_parser('extra.lyrics')}, + b'\xa9mvn': {b'data': _Parser._make_data_atom_parser('movement')}, + b'\xa9nam': {b'data': _Parser._make_data_atom_parser('title')}, + b'\xa9pub': {b'data': _Parser._make_data_atom_parser('extra.publisher')}, + b'\xa9wrt': {b'data': _Parser._make_data_atom_parser('extra.composer')}, + b'aART': {b'data': _Parser._make_data_atom_parser('albumartist')}, + b'cprt': {b'data': _Parser._make_data_atom_parser('extra.copyright')}, + b'desc': {b'data': _Parser._make_data_atom_parser('extra.description')}, + b'disk': {b'data': _Parser._make_number_parser('disc', 'disc_total')}, + b'gnre': {b'data': _Parser._parse_id3v1_genre}, + b'trkn': {b'data': _Parser._make_number_parser('track', 'track_total')}, + b'tmpo': {b'data': _Parser._make_data_atom_parser('extra.bpm')}, + b'----': _Parser._parse_custom_field, }}}}} # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html AUDIO_DATA_TREE = { b'moov': { - b'mvhd': Parser.parse_mvhd, + b'mvhd': _Parser._parse_mvhd, b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": { - b'mp4a': Parser.parse_audio_sample_entry_mp4a, - b'alac': Parser.parse_audio_sample_entry_alac + b'mp4a': _Parser._parse_audio_sample_entry_mp4a, + b'alac': _Parser._parse_audio_sample_entry_alac }}}}} } } IMAGE_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { - b'covr': {b'data': Parser.make_data_atom_parser('_image_data')}, + b'covr': {b'data': _Parser._make_data_atom_parser('_image_data')}, }}}}} VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header @@ -594,10 +601,6 @@ def __init__(self, filehandler, filesize, *args, **kwargs): # save position after the ID3 tag for duration measurement speedup self._bytepos_after_id3v2 = None - @classmethod - def set_estimation_precision(cls, estimation_in_seconds): - cls._MAX_ESTIMATION_SEC = estimation_in_seconds - # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm samplerates = [ @@ -662,7 +665,7 @@ def _determine_duration(self, fh): if frames: self.bitrate = bitrate_accu / frames break # EOF - sync, conf, bitrate_freq, rest = struct.unpack('BBBB', b[0:4]) + _sync, conf, bitrate_freq, rest = struct.unpack('BBBB', b[0:4]) br_id = (bitrate_freq >> 4) & 0x0F # biterate id sr_id = (bitrate_freq >> 2) & 0x03 # sample rate id padding = 1 if bitrate_freq & 0x02 > 0 else 0 @@ -681,8 +684,8 @@ def _determine_duration(self, fh): self.channels = self.channels_per_channel_mode[channel_mode] frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] self.samplerate = self.samplerates[mpeg_id][sr_id] - except (IndexError, TypeError): - raise TinyTagException('mp3 parsing failed') + except (IndexError, TypeError) as exc: + raise TinyTagException('mp3 parsing failed') from exc # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate @@ -690,7 +693,7 @@ def _determine_duration(self, fh): xing_header_offset = b.find(b'Xing') if xing_header_offset != -1: fh.seek(xing_header_offset, os.SEEK_CUR) - xframes, byte_count, toc, vbr_scale = self._parse_xing_header(fh) + xframes, byte_count, _toc, _vbr_scale = self._parse_xing_header(fh) if xframes and xframes != 0 and byte_count: # MPEG-2 Audio Layer III uses 576 samples per frame samples_per_frame = 576 if mpeg_id <= 2 else self.samples_per_frame @@ -743,7 +746,7 @@ def _parse_id3v2_header(self, fh): tag = header[0].decode('ISO-8859-1') # check if there is an ID3v2 tag at the beginning of the file if tag == 'ID3': - major, rev = header[1:3] + major, _rev = header[1:3] if DEBUG: print(f'Found id3 v2.{major}') # unsync = (header[3] & 0x80) > 0 @@ -788,7 +791,7 @@ def asciidecode(x): if genre_id < len(self.ID3V1_GENRES): self._set_field('genre', self.ID3V1_GENRES[genre_id]) - def _parse_custom_field(self, content): + def __parse_custom_field(self, content): custom_field_name, separator, value = content.partition('\x00') if custom_field_name and separator: self._set_field('extra.' + custom_field_name.lower(), value.lstrip('\ufeff')) @@ -796,7 +799,7 @@ def _parse_custom_field(self, content): return False @staticmethod - def index_utf16(s, search): + def _index_utf16(s, search): for i in range(0, len(s), len(search)): if s[i:i + len(search)] == search: return i @@ -828,7 +831,7 @@ def _parse_frame(self, fh, id3version=False): try: if fieldname == "comment": # check if comment is a key-value pair (used by iTunes) - should_set_field = not self._parse_custom_field(value) + should_set_field = not self.__parse_custom_field(value) elif fieldname in ('track', 'disc'): if '/' in value: value, total = value.split('/')[:2] @@ -851,7 +854,7 @@ def _parse_frame(self, fh, id3version=False): self._set_field(fieldname, value) elif frame_id in self.CUSTOM_FRAME_IDS: # custom fields - self._parse_custom_field(self._decode_string(content)) + self.__parse_custom_field(self._decode_string(content)) elif frame_id in self.IMAGE_FRAME_IDS: if self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames @@ -862,7 +865,7 @@ def _parse_frame(self, fh, id3version=False): desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mtype, pictype(1) # latin1 and utf-8 are 1 byte termination = b'\x00' if encoding in (b'\x00', b'\x03') else b'\x00\x00' - desc_length = self.index_utf16(content[desc_start_pos:], termination) + desc_length = self._index_utf16(content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_length + len(termination) self._image_data = content[desc_end_pos:] elif frame_id not in self.DISALLOWED_FRAME_IDS: @@ -905,7 +908,6 @@ def _decode_string(self, bytestr, language=False): bytestr = bytestr[1:] encoding = 'UTF-8' else: - bytestr = bytestr encoding = default_encoding # wild guess if language and bytestr[:3].isalpha(): bytestr = bytestr[3:] # remove language @@ -914,7 +916,8 @@ def _decode_string(self, bytestr, language=False): except UnicodeDecodeError as exc: raise TinyTagException('Error decoding ID3 Tag!') from exc - def _calc_size(self, bytestr, bits_per_byte): + @staticmethod + def _calc_size(bytestr, bits_per_byte): # length of some mp3 header fields is described by 7 or 8-bit-bytes return reduce(lambda accu, elem: (accu << bits_per_byte) + elem, bytestr, 0) @@ -954,8 +957,8 @@ def _parse_tag(self, fh): walker = io.BytesIO(packet) if packet[0:7] == b"\x01vorbis": if self._parse_duration: - (channels, self.samplerate, max_bitrate, bitrate, - min_bitrate) = struct.unpack(" Total samples in stream. # 16s| <128> MD5 signature # min_blk, max_blk, min_frm, max_frm = header[0:4] - # min_frm = _bytes_to_int(struct.unpack('3B', min_frm)) - # max_frm = _bytes_to_int(struct.unpack('3B', max_frm)) + # min_frm = self._bytes_to_int(struct.unpack('3B', min_frm)) + # max_frm = self._bytes_to_int(struct.unpack('3B', max_frm)) # channels--. bits total samples # |----- samplerate -----| |-||----| |---------~ ~----| # 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 # #---4---# #---5---# #---6---# #---7---# #--8-~ ~-12-# - self.samplerate = _bytes_to_int(header[4:7]) >> 4 + self.samplerate = self._bytes_to_int(header[4:7]) >> 4 self.channels = ((header[6] >> 1) & 0x07) + 1 - self.bitdepth = (((header[6] & 1) << 4) + ((header[7] & 0xF0) >> 4) + 1) + self.bitdepth = ((header[6] & 1) << 4) + ((header[7] & 0xF0) >> 4) + 1 total_sample_bytes = [(header[7] & 0x0F)] + list(header[8:12]) - total_samples = _bytes_to_int(total_sample_bytes) + total_samples = self._bytes_to_int(total_sample_bytes) self.duration = total_samples / self.samplerate if self.duration > 0: self.bitrate = self.filesize / self.duration * 8 / 1000 elif block_type == self.METADATA_VORBIS_COMMENT and self._parse_tags: oggtag = _Ogg(fh, 0) oggtag._parse_vorbis_comment(fh) - self.update(oggtag) + self._update(oggtag) elif block_type == self.METADATA_PICTURE and self._load_image: self._image_data = self._parse_image(fh) elif block_type >= 127: @@ -1281,14 +1284,17 @@ def _determine_duration(self, fh): return header_data = fh.read(4) + def _parse_tag(self, fh): + pass + @staticmethod def _parse_image(fh): # https://xiph.org/flac/format.html#metadata_block_picture - pic_type, mime_len = struct.unpack('>2I', fh.read(8)) + _pic_type, mime_len = struct.unpack('>2I', fh.read(8)) fh.read(mime_len) description_len = struct.unpack('>I', fh.read(4))[0] fh.read(description_len) - width, height, depth, colors, pic_len = struct.unpack('>5I', fh.read(20)) + _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) return fh.read(pic_len) @@ -1313,27 +1319,28 @@ def _determine_duration(self, fh): if not self.__tag_parsed: self._parse_tag(fh) - def read_blocks(self, fh, blocks): + def _read_blocks(self, fh, blocks): # blocks are a list(tuple('fieldname', byte_count, cast_int), ...) decoded = {} for block in blocks: val = fh.read(block[1]) if block[2]: - val = _bytes_to_int_le(val) + val = self._bytes_to_int_le(val) decoded[block[0]] = val return decoded - def __decode_string(self, bytestring): + def _decode_string(self, bytestring): return self._unpad(bytestring.decode('utf-16')) - def __decode_ext_desc(self, value_type, value): + def _decode_ext_desc(self, value_type, value): """ decode ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string - return self.__decode_string(value) - elif value_type == 1: # BYTE array + return self._decode_string(value) + if value_type == 1: # BYTE array return value - elif 1 < value_type < 6: # DWORD / QWORD / WORD - return _bytes_to_int_le(value) + if 1 < value_type < 6: # DWORD / QWORD / WORD + return self._bytes_to_int_le(value) + return None def _parse_tag(self, fh): self.__tag_parsed = True @@ -1341,25 +1348,24 @@ def _parse_tag(self, fh): if guid != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel': # not a valid ASF container! see: http://www.garykessler.net/library/file_sigs.html return - struct.unpack('Q', fh.read(8))[0] # size - struct.unpack('I', fh.read(4))[0] # obj_count + fh.read(12) # size and obj_count if fh.read(2) != b'\x01\x02': # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc521913958 return # not a valid asf header! while True: object_id = fh.read(16) - object_size = _bytes_to_int_le(fh.read(8)) + object_size = self._bytes_to_int_le(fh.read(8)) if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. if object_id == self.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: - len_blocks = self.read_blocks(fh, [ + len_blocks = self._read_blocks(fh, [ ('title_length', 2, True), ('author_length', 2, True), ('copyright_length', 2, True), ('description_length', 2, True), ('rating_length', 2, True), ]) - data_blocks = self.read_blocks(fh, [ + data_blocks = self._read_blocks(fh, [ ('title', len_blocks['title_length'], False), ('artist', len_blocks['author_length'], False), ('', len_blocks['copyright_length'], True), @@ -1368,7 +1374,7 @@ def _parse_tag(self, fh): ]) for field_name, bytestring in data_blocks.items(): if field_name: - self._set_field(field_name, self.__decode_string(bytestring)) + self._set_field(field_name, self._decode_string(bytestring)) elif object_id == self.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: mapping = { 'WM/TrackNumber': 'track', @@ -1386,12 +1392,12 @@ def _parse_tag(self, fh): 'WM/AuthorURL': 'extra.url', } # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 - descriptor_count = _bytes_to_int_le(fh.read(2)) + descriptor_count = self._bytes_to_int_le(fh.read(2)) for _ in range(descriptor_count): - name_len = _bytes_to_int_le(fh.read(2)) - name = self.__decode_string(fh.read(name_len)) - value_type = _bytes_to_int_le(fh.read(2)) - value_len = _bytes_to_int_le(fh.read(2)) + name_len = self._bytes_to_int_le(fh.read(2)) + name = self._decode_string(fh.read(name_len)) + value_type = self._bytes_to_int_le(fh.read(2)) + value_len = self._bytes_to_int_le(fh.read(2)) if value_type == 1: fh.seek(value_len, os.SEEK_CUR) # skip byte values continue @@ -1400,7 +1406,7 @@ def _parse_tag(self, fh): if name.startswith('WM/'): name = name[3:] field_name = 'extra.' + name.lower() - field_value = self.__decode_ext_desc(value_type, fh.read(value_len)) + field_value = self._decode_ext_desc(value_type, fh.read(value_len)) try: if field_name in ('track', 'disc'): field_value = int(field_value) @@ -1410,7 +1416,7 @@ def _parse_tag(self, fh): else: self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT: - blocks = self.read_blocks(fh, [ + blocks = self._read_blocks(fh, [ ('file_id', 16, False), ('file_size', 8, False), ('creation_date', 8, True), @@ -1428,7 +1434,7 @@ def _parse_tag(self, fh): preroll = blocks.get('preroll') / 1000 self.duration = max(blocks.get('play_duration') / 10000000 - preroll, 0.0) elif object_id == self.ASF_STREAM_PROPERTIES_OBJECT: - blocks = self.read_blocks(fh, [ + blocks = self._read_blocks(fh, [ ('stream_type', 16, False), ('error_correction_type', 16, False), ('time_offset', 8, True), @@ -1439,7 +1445,7 @@ def _parse_tag(self, fh): ]) already_read = 0 if blocks['stream_type'] == self.STREAM_TYPE_ASF_AUDIO_MEDIA: - stream_info = self.read_blocks(fh, [ + stream_info = self._read_blocks(fh, [ ('codec_id_format_tag', 2, True), ('number_of_channels', 2, True), ('samples_per_second', 4, True), @@ -1505,7 +1511,7 @@ def __init__(self, filehandler, filesize, *args, **kwargs): self._tags_parsed = False def _parse_tag(self, fh): - chunk_id, size, form = struct.unpack('>4sI4s', fh.read(12)) + chunk_id, _size, form = struct.unpack('>4sI4s', fh.read(12)) if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): raise TinyTagException('not an aiff file!') chunk_header = fh.read(8) @@ -1527,8 +1533,8 @@ def _parse_tag(self, fh): fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk elif sub_chunk_id in (b'id3 ', b'ID3 ') and self._parse_tags: id3 = _ID3(fh, 0) - id3.load(tags=True, duration=False, image=self._load_image) - self.update(id3) + id3._load(tags=True, duration=False, image=self._load_image) + self._update(id3) elif sub_chunk_id == b'SSND': fh.seek(sub_chunk_size, 1) else: # some other chunk, just skip the data From f1a99ba7fb3ab5598aa17d61129761513138118f Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 01:39:10 +0200 Subject: [PATCH 129/305] Use pycodestyle instead of flake8 With Pylint added, we only need the code style checks provided by flake8. --- .github/workflows/tests.yml | 12 ++++-------- release.py | 3 ++- setup.cfg | 6 ++++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a5bbf08..a0df534 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,15 +24,11 @@ jobs: - name: Install dependencies run: python -m pip install build setuptools .[tests] - - name: Downgrade importlib-metadata - run: python -m pip install importlib-metadata==4.13.0 - if: matrix.python == '3.7' || matrix.python == 'pypy-3.7' + - name: PEP 8 style checks + run: python -m pycodestyle - - name: Flake8 linter - run: python -m flake8 - - - name: Pylint linter - run: python -m pylint --recursive=y --ignore-paths=build . + - name: Linting + run: python -m pylint --recursive=y . - name: Unit tests run: python -m pytest --cov diff --git a/release.py b/release.py index 840845d..544c488 100755 --- a/release.py +++ b/release.py @@ -7,7 +7,8 @@ def release_package(): # Run tests - subprocess.check_call([sys.executable, "-m", "flake8"]) + subprocess.check_call([sys.executable, "-m", "pycodestyle"]) + subprocess.check_call([sys.executable, "-m", "pylint", "--recursive=y", "."]) subprocess.check_call([sys.executable, "-m", "pytest"]) # Prepare source distribution and wheel diff --git a/setup.cfg b/setup.cfg index 781e677..11a0809 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,7 +40,7 @@ install_requires = [options.extras_require] tests = - flake8 + pycodestyle pylint pytest pytest-cov @@ -48,8 +48,9 @@ tests = [options.entry_points] console_scripts = -[flake8] +[pycodestyle] max-line-length = 100 +exclude = build/ [pylint] enable = @@ -72,4 +73,5 @@ load-plugins = pylint.extensions.private_import, pylint.extensions.set_membership, pylint.extensions.typing +ignore-paths = build py-version = 3.6 From 98aa7fe53148113a971e352be37f18572200cbc0 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 01:57:06 +0200 Subject: [PATCH 130/305] setup.cfg: fix Pylint config detection --- setup.cfg | 10 +++++----- tinytag/tinytag.py | 33 +++++++++++++++++---------------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/setup.cfg b/setup.cfg index 11a0809..9ff8152 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,19 +52,16 @@ console_scripts = max-line-length = 100 exclude = build/ -[pylint] +[pylint.master] enable = consider-using-augmented-assign, - use-implicit-booleaness-not-comparison-to-string, - use-implicit-booleaness-not-comparison-to-zero + use-implicit-booleaness-not-comparison-to-string load-plugins = pylint.extensions.bad_builtin, pylint.extensions.check_elif, pylint.extensions.code_style, - pylint.extensions.comparetozero, pylint.extensions.comparison_placement, pylint.extensions.consider_refactoring_into_while_condition, - pylint.extensions.emptystring, pylint.extensions.for_any_all, pylint.extensions.dict_init_mutate, pylint.extensions.dunder, @@ -75,3 +72,6 @@ load-plugins = pylint.extensions.typing ignore-paths = build py-version = 3.6 + +[pylint.format] +max-line-length = 100 diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c30c917..a5f182b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -232,11 +232,11 @@ def _parse_tag(self, fh): def _update(self, other): # update the values of this tag with the values from another tag - for key in ['track', 'track_total', 'title', 'artist', + for key in ('track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', 'genre', 'disc', 'disc_total', 'comment', 'bitdepth', 'bitrate', 'channels', 'samplerate', - '_image_data']: + '_image_data'): new_value = getattr(other, key) if new_value: self._set_field(key, new_value) @@ -826,24 +826,25 @@ def _parse_frame(self, fh, id3version=False): fieldname = self.FRAME_ID_TO_FIELD.get(frame_id) should_set_field = True if fieldname: - language = fieldname in ('comment', 'extra.lyrics') + language = fieldname in {'comment', 'extra.lyrics'} value = self._decode_string(content, language) try: if fieldname == "comment": # check if comment is a key-value pair (used by iTunes) should_set_field = not self.__parse_custom_field(value) - elif fieldname in ('track', 'disc'): + elif fieldname in {'track', 'disc'}: if '/' in value: value, total = value.split('/')[:2] self._set_field(f'{fieldname}_total', int(total)) value = int(value) elif fieldname == 'genre': genre_id = 255 - if value.isdigit(): # funky: id3v1 genre hidden in a id3v2 field + # funky: id3v1 genre hidden in a id3v2 field + if value.isdigit(): genre_id = int(value) - else: # funkier: the TCO may contain genres in parens, e.g. '(13)' - if value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): - genre_id = int(value[1:-1]) + # funkier: the TCO may contain genres in parens, e.g. '(13)' + elif value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): + genre_id = int(value[1:-1]) if 0 <= genre_id < len(_ID3.ID3V1_GENRES): value = _ID3.ID3V1_GENRES[genre_id] except ValueError as exc: @@ -864,7 +865,7 @@ def _parse_frame(self, fh, id3version=False): else: # ID3 v2.3+ desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mtype, pictype(1) # latin1 and utf-8 are 1 byte - termination = b'\x00' if encoding in (b'\x00', b'\x03') else b'\x00\x00' + termination = b'\x00' if encoding in {b'\x00', b'\x03'} else b'\x00\x00' desc_length = self._index_utf16(content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_length + len(termination) self._image_data = content[desc_end_pos:] @@ -887,7 +888,7 @@ def _decode_string(self, bytestr, language=False): bytestr = bytestr[1:] # remove language (but leave BOM) if language: - if bytestr[3:5] in (b'\xfe\xff', b'\xff\xfe'): + if bytestr[3:5] in {b'\xfe\xff', b'\xff\xfe'}: bytestr = bytestr[3:] if bytestr[:3].isalpha(): bytestr = bytestr[3:] # remove language @@ -895,7 +896,7 @@ def _decode_string(self, bytestr, language=False): # read byte order mark to determine endianness encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' # strip the bom if it exists - if bytestr[:2] in (b'\xfe\xff', b'\xff\xfe'): + if bytestr[:2] in {b'\xfe\xff', b'\xff\xfe'}: bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] # remove ADDITIONAL EXTRA BOM :facepalm: if bytestr[:4] == b'\x00\x00\xff\xfe': @@ -1068,12 +1069,12 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): fieldname = comment_type_to_attr_mapping.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' try: - if fieldname in ('track', 'disc'): + if fieldname in {'track', 'disc'}: if '/' in value: value, total = value.split('/')[:2] self._set_field(f'{fieldname}_total', int(total)) value = int(value) - elif fieldname in ('track_total', 'disc_total'): + elif fieldname in {'track_total', 'disc_total'}: value = int(value) except ValueError as exc: if DEBUG: @@ -1186,7 +1187,7 @@ def _determine_duration(self, fh): else: self._set_field(fieldname, value) field = sub_fh.read(4) - elif subchunkid in (b'id3 ', b'ID3 ') and self._parse_tags: + elif subchunkid in {b'id3 ', b'ID3 '} and self._parse_tags: id3 = _ID3(fh, 0) id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) @@ -1408,7 +1409,7 @@ def _parse_tag(self, fh): field_name = 'extra.' + name.lower() field_value = self._decode_ext_desc(value_type, fh.read(value_len)) try: - if field_name in ('track', 'disc'): + if field_name in {'track', 'disc'}: field_value = int(field_value) except ValueError as exc: if DEBUG: @@ -1531,7 +1532,7 @@ def _parse_tag(self, fh): except OverflowError: self.samplerate = self.duration = self.bitrate = None # invalid sample rate fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk - elif sub_chunk_id in (b'id3 ', b'ID3 ') and self._parse_tags: + elif sub_chunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: id3 = _ID3(fh, 0) id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) From aad71874af447839ba6bd9765db55b8068b6412a Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 02:05:34 +0200 Subject: [PATCH 131/305] Update copyright year --- LICENSE | 2 +- tinytag/tinytag.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 9091269..d6c723c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2014-2023 Tom Wallroth, Mat (mathiascode) +Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a5f182b..a086a83 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,13 +1,13 @@ # tinytag - an audio meta info reader # Copyright (c) 2014-2023 Tom Wallroth -# Copyright (c) 2021-2023 Mat (mathiascode) +# Copyright (c) 2021-2024 Mat (mathiascode) # # Sources on GitHub: # http://github.com/devsnd/tinytag/ # MIT License -# Copyright (c) 2014-2023 Tom Wallroth, Mat (mathiascode) +# Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode) # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal From 5bcfe047e38d53a49eb3bc3ba1ee26a95bc4ef93 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 02:27:46 +0200 Subject: [PATCH 132/305] __init__.py: remove unused code --- tinytag/__init__.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 41c94b3..b4d6291 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,9 +1,2 @@ -#!/usr/bin/python # pylint: disable=missing-module-docstring - -import sys -from .tinytag import TinyTag, TinyTagException # noqa: F401 - - -if __name__ == '__main__': - print(TinyTag.get(sys.argv[1])) +from .tinytag import TinyTag, TinyTagException From 87ed9184f8b4efdf4b868ac952e3e722ad818b3b Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 02:49:53 +0200 Subject: [PATCH 133/305] __main__.py: refactor for code coverage --- tinytag/__main__.py | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index ce1aa9a..da5f66f 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -75,27 +75,24 @@ def _run(): header_printed = False for i, filename in enumerate(filenames): - if skip_unsupported: - if os.path.isdir(filename): - continue - if not TinyTag.is_supported(filename): - continue + if skip_unsupported and not (TinyTag.is_supported(filename) and os.path.isfile(filename)): + continue try: tag = TinyTag.get(filename, image=save_image_path is not None) - except TinyTagException as exc: + if save_image_path: + # allow for saving the image of multiple files + actual_save_image_path = save_image_path + if len(filenames) > 1: + actual_save_image_path, ext = splitext(actual_save_image_path) + actual_save_image_path += f'{i:05d}{ext}' + image = tag.get_image() + if image: + with open(actual_save_image_path, 'wb') as file_handle: + file_handle.write(image) + header_printed = _print_tag(tag, formatting, header_printed) + except Exception as exc: sys.stderr.write(f'{filename}: {exc}\n') return 1 - if save_image_path: - # allow for saving the image of multiple files - actual_save_image_path = save_image_path - if len(filenames) > 1: - actual_save_image_path, ext = splitext(actual_save_image_path) - actual_save_image_path += f'{i:05d}{ext}' - image = tag.get_image() - if image: - with open(actual_save_image_path, 'wb') as file_handle: - file_handle.write(image) - header_printed = _print_tag(tag, formatting, header_printed) return 0 From fe51c31f199ef4b5de69490b27d11128ac8602dd Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 02:57:36 +0200 Subject: [PATCH 134/305] Fix linting errors --- tinytag/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index da5f66f..ec51a42 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -5,7 +5,7 @@ import os import sys -from tinytag.tinytag import TinyTag, TinyTagException +from tinytag.tinytag import TinyTag def _usage(): @@ -90,7 +90,7 @@ def _run(): with open(actual_save_image_path, 'wb') as file_handle: file_handle.write(image) header_printed = _print_tag(tag, formatting, header_printed) - except Exception as exc: + except Exception as exc: # pylint: disable=broad-exception-caught sys.stderr.write(f'{filename}: {exc}\n') return 1 return 0 From a191da0ca3ade604c48c0f139bc263369e6be125 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 03:12:12 +0200 Subject: [PATCH 135/305] test_cli.py: use sys.executable --- tinytag/tests/test_cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index d9ea501..18ab54a 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -2,6 +2,8 @@ import json import os +import sys + from subprocess import check_output, CalledProcessError from tempfile import NamedTemporaryFile @@ -20,8 +22,8 @@ def run_cli(args): - debug_env = os.environ.pop("DEBUG", None) - output = check_output('python -m tinytag ' + args, cwd=project_folder, shell=True) + debug_env = str(os.environ.pop("DEBUG", None)) + output = check_output(f'{sys.executable} -m tinytag ' + args, cwd=project_folder, shell=True) if debug_env: os.environ["DEBUG"] = debug_env return output.decode('utf-8') From 7ec55d5797f721c7666997174f2cee4bec00ff70 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 03:17:11 +0200 Subject: [PATCH 136/305] Fix compatibility with Pylint in Python 3.6 --- setup.cfg | 3 +++ tinytag/__main__.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 9ff8152..5dbd116 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,8 @@ max-line-length = 100 exclude = build/ [pylint.master] +disable = + bad-plugin-value enable = consider-using-augmented-assign, use-implicit-booleaness-not-comparison-to-string @@ -62,6 +64,7 @@ load-plugins = pylint.extensions.code_style, pylint.extensions.comparison_placement, pylint.extensions.consider_refactoring_into_while_condition, + pylint.extensions.emptystring, pylint.extensions.for_any_all, pylint.extensions.dict_init_mutate, pylint.extensions.dunder, diff --git a/tinytag/__main__.py b/tinytag/__main__.py index ec51a42..6dda03d 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -90,7 +90,7 @@ def _run(): with open(actual_save_image_path, 'wb') as file_handle: file_handle.write(image) header_printed = _print_tag(tag, formatting, header_printed) - except Exception as exc: # pylint: disable=broad-exception-caught + except Exception as exc: # pylint: disable=broad-except sys.stderr.write(f'{filename}: {exc}\n') return 1 return 0 From 629774725b7e7de7a15815340979109fe9080df4 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 03:39:02 +0200 Subject: [PATCH 137/305] test_all.py: use exact values for duration --- tinytag/tests/test_all.py | 47 +++++++++++++-------------------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index df903dd..09d3413 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -29,7 +29,7 @@ 'filesize': 8192, 'genre': '(3)Dance', 'comment': 'Ripped by THSLIVE', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.49, + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.48866995073891617, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, 'filesize': 8186, 'bitrate': 128.0, 'genre': 'Dance', @@ -51,13 +51,13 @@ 'itunes_cddb_1': ('9D09130B+174405+11+150+14097+27391+43983+65786+84877+' '99399+113226+132452+146426+163829'), 'itunes_cddb_tracknumber': '3'}, - 'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.138, + 'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.13836297152858082, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', 'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120, 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), ('samples/silence-44-s-v1.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', - 'duration': 3.7355102040816326, 'album': 'Quod Libet Test Data', 'year': '2004', + 'duration': 3.738712956446946, 'album': 'Quod Libet Test Data', 'year': '2004', 'title': 'Silence', 'artist': 'piman', 'track': 2, 'filesize': 15070, 'bitrate': 32.0}), ('samples/id3v1-latin1.mp3', @@ -87,8 +87,8 @@ ('samples/empty_file.mp3', {'extra': {}, 'filesize': 0}), ('samples/silence-44khz-56k-mono-1s.mp3', - {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.018, 'filesize': 7280, - 'bitrate': 56.0}), + {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.0265261269342902, + 'filesize': 7280, 'bitrate': 56.0}), ('samples/silence-22khz-mono-1s.mp3', {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, 'bitrate': 32.0, 'duration': 1.0438932496075353}), @@ -187,26 +187,26 @@ 'track': 10, 'comment': ' ', 'disc': 1, 'disc_total': 1, 'track_total': 12, 'year': '2004'}), ('samples/mp3/vbr/vbr8.mp3', - {'filesize': 9504, 'bitrate': 8.25, 'channels': 1, 'duration': 9.2, + {'filesize': 9504, 'bitrate': 8.25, 'channels': 1, 'duration': 9.216, 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr8stereo.mp3', {'filesize': 9504, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, 'extra': {}, 'samplerate': 8000}), ('samples/mp3/vbr/vbr11.mp3', {'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 1, - 'duration': 9.2, 'extra': {}, 'samplerate': 11025}), + 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr11stereo.mp3', {'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 2, 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), ('samples/mp3/vbr/vbr16.mp3', {'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 1, - 'duration': 9.2, 'extra': {}, 'samplerate': 16000}), + 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr16stereo.mp3', {'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 2, 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), ('samples/mp3/vbr/vbr22.mp3', {'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 1, - 'duration': 9.2, 'extra': {}, 'samplerate': 22050}), + 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), ('samples/mp3/vbr/vbr22stereo.mp3', {'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 2, 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), @@ -221,7 +221,7 @@ 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr44stereo.mp3', {'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 2, - 'duration': 9.0, 'extra': {}, 'samplerate': 44100}), + 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), ('samples/mp3/vbr/vbr48.mp3', {'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 1, 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), @@ -276,7 +276,7 @@ 'comment': 'ARCD0018 - Lovelight', 'disc_total': 1, 'track_total': 13}), ('samples/8khz_5s.opus', {'extra': {'encoder': 'opusenc from opus-tools 0.2'}, 'filesize': 7251, 'channels': 1, - 'samplerate': 48000, 'duration': 5.0}), + 'samplerate': 48000, 'duration': 5.0065}), ('samples/test_flac.oga', {'extra': {'copyright': 'test3', 'isrc': 'test4', 'lyrics': 'test7'}, 'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5', @@ -306,8 +306,8 @@ 'bitdepth': 16, 'title': 'thetitle', 'comment': 'hello', 'year': '2014'}), ('samples/silence-22khz-mono-1s.wav', - {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 48160, 'bitrate': 352.8, - 'samplerate': 22050, 'bitdepth': 16}), + {'extra': {}, 'channels': 1, 'duration': 0.9991836734693877, 'filesize': 48160, + 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16}), ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', @@ -381,7 +381,7 @@ {'extra': {}, 'filesize': 4692}), ('samples/106-short-picture-block-size.flac', {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, - 'duration': 3.68, 'samplerate': 44100, 'bitdepth': 16}), + 'duration': 3.684716553287982, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', {'extra': {'id': '8591671910'}, 'filesize': 64837, 'album': 'album\x00 ', 'artist': 'artist\x00群星', @@ -448,7 +448,7 @@ '00007E90 00007BFD 00009293'), 'itunes_cddb_ids': '11++', 'ufidhttp://www.cddb.com/id3/taginfo1.html': '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1', 'bpm': 0}, - 'samplerate': 44100, 'duration': 314.97, 'bitrate': 256.0, 'channels': 2, + 'samplerate': 44100, 'duration': 314.97868480725623, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': 11, 'track': 11, 'artist': 'Marian', 'filesize': 61432}), ('samples/test2.m4a', @@ -572,16 +572,6 @@ def load_custom_samples(): testfiles.update(load_custom_samples()) -def almost_equal_float(val1, val2): - # allow duration to be off by 100 ms and a maximum of 1% - if val1 == val2: - return True - if abs(val1 - val2) < 0.100: - if val2 and min(val1, val2) / max(val1, val2) > 0.99: - return True - return False - - def startswith(val1, val2): return val1.startswith(val2) @@ -597,10 +587,7 @@ def compare(results, expected, file, prev_path=None): for key, result_val in results.items(): path = prev_path + '.' + key if prev_path else key - try: - expected_val = expected[key] - except KeyError: - assert False, f'Missing field "{key}": "{error_fmt(result_val)}" in fixture "{file}"!' + expected_val = expected[key] # recurse if the result and expected values are a dict: if isinstance(result_val, dict) and isinstance(expected_val, dict): compare(result_val, expected_val, file, prev_path=key) @@ -608,8 +595,6 @@ def compare(results, expected, file, prev_path=None): fmt_string = 'field "%s": got %s expected %s in %s!' fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) oper = operator.eq - if path == 'duration': # allow duration to be off by 100 ms and a maximum of 1% - oper = almost_equal_float if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture oper = startswith assert oper(result_val, expected_val), fmt_string % fmt_values From 1999f54ebd58713a3a6c591fa3aeaa1a36991428 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 04:09:54 +0200 Subject: [PATCH 138/305] test_all.py: use pytest.approx for floats --- tinytag/tests/test_all.py | 59 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 31 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 09d3413..127cf50 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -8,7 +8,6 @@ import io -import operator import os import pathlib import re @@ -572,36 +571,34 @@ def load_custom_samples(): testfiles.update(load_custom_samples()) -def startswith(val1, val2): - return val1.startswith(val2) - - -def error_fmt(value): - return f'{repr(value)} ({type(value)})' - - -def compare(results, expected, file, prev_path=None): - assert isinstance(results, dict) - missing_keys = set(expected.keys()) - set(results) - assert not missing_keys, f'Missing data in fixture \n{missing_keys}' - - for key, result_val in results.items(): - path = prev_path + '.' + key if prev_path else key - expected_val = expected[key] - # recurse if the result and expected values are a dict: - if isinstance(result_val, dict) and isinstance(expected_val, dict): - compare(result_val, expected_val, file, prev_path=key) - else: - fmt_string = 'field "%s": got %s expected %s in %s!' - fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) - oper = operator.eq - if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture - oper = startswith - assert oper(result_val, expected_val), fmt_string % fmt_values - - @pytest.mark.parametrize("testfile,expected", testfiles.items()) def test_file_reading(testfile, expected): + def compare(results, expected, file, prev_path=None): + def compare_values(path, result_val, expected_val): + if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture + return result_val.startswith(expected_val) + if isinstance(expected_val, float): + return result_val == pytest.approx(expected_val) + return result_val == expected_val + + def error_fmt(value): + return f'{repr(value)} ({type(value)})' + + assert isinstance(results, dict) + missing_keys = set(expected.keys()) - set(results) + assert not missing_keys, f'Missing data in fixture \n{missing_keys}' + + for key, result_val in results.items(): + path = prev_path + '.' + key if prev_path else key + expected_val = expected[key] + # recurse if the result and expected values are a dict: + if isinstance(result_val, dict) and isinstance(expected_val, dict): + compare(result_val, expected_val, file, prev_path=key) + else: + fmt_string = 'field "%s": got %s expected %s in %s!' + fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) + assert compare_values(path, result_val, expected_val), fmt_string % fmt_values + filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename) results = { @@ -621,7 +618,7 @@ def test_pathlib_compatibility(): def test_file_obj_compatibility(): testfile = next(iter(testfiles.keys())) filename = os.path.join(testfolder, testfile) - with io.open(filename, 'rb') as file_handle: + with open(filename, 'rb') as file_handle: tag = TinyTag.get(file_obj=file_handle) file_handle.seek(0) tag_bytesio = TinyTag.get(file_obj=io.BytesIO(file_handle.read())) @@ -732,7 +729,7 @@ def test_mp3_utf_8_invalid_string_can_be_ignored(): ]) def test_detect_magic_headers(testfile, expected): filename = os.path.join(testfolder, testfile) - with io.open(filename, 'rb') as file_handle: + with open(filename, 'rb') as file_handle: parser = TinyTag._get_parser_class(filename, file_handle) assert parser == expected From 255f421365d43c3edf58848053e6eb2865f03c8c Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 04:54:13 +0200 Subject: [PATCH 139/305] test_all.py: add test for file reading with tags disabled --- tinytag/tests/test_all.py | 75 +++++++++++++++++++++++---------------- tinytag/tinytag.py | 16 ++++++--- 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 127cf50..01566f7 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -571,41 +571,56 @@ def load_custom_samples(): testfiles.update(load_custom_samples()) +def compare_tag(results, expected, file, prev_path=None): + def compare_values(path, result_val, expected_val): + if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture + return result_val.startswith(expected_val) + if isinstance(expected_val, float): + return result_val == pytest.approx(expected_val) + return result_val == expected_val + + def error_fmt(value): + return f'{repr(value)} ({type(value)})' + + assert isinstance(results, dict) + missing_keys = set(expected.keys()) - set(results) + assert not missing_keys, f'Missing data in fixture \n{missing_keys}' + + for key, result_val in results.items(): + path = prev_path + '.' + key if prev_path else key + expected_val = expected[key] + # recurse if the result and expected values are a dict: + if isinstance(result_val, dict) and isinstance(expected_val, dict): + compare_tag(result_val, expected_val, file, prev_path=key) + else: + fmt_string = 'field "%s": got %s expected %s in %s!' + fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) + assert compare_values(path, result_val, expected_val), fmt_string % fmt_values + + @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading(testfile, expected): - def compare(results, expected, file, prev_path=None): - def compare_values(path, result_val, expected_val): - if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture - return result_val.startswith(expected_val) - if isinstance(expected_val, float): - return result_val == pytest.approx(expected_val) - return result_val == expected_val - - def error_fmt(value): - return f'{repr(value)} ({type(value)})' - - assert isinstance(results, dict) - missing_keys = set(expected.keys()) - set(results) - assert not missing_keys, f'Missing data in fixture \n{missing_keys}' - - for key, result_val in results.items(): - path = prev_path + '.' + key if prev_path else key - expected_val = expected[key] - # recurse if the result and expected values are a dict: - if isinstance(result_val, dict) and isinstance(expected_val, dict): - compare(result_val, expected_val, file, prev_path=key) - else: - fmt_string = 'field "%s": got %s expected %s in %s!' - fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) - assert compare_values(path, result_val, expected_val), fmt_string % fmt_values +def test_file_reading_tags(testfile, expected): + filename = os.path.join(testfolder, testfile) + tag = TinyTag.get(filename, tags=True) + results = { + key: val for key, val in tag._as_dict().items() if val is not None + } + compare_tag(results, expected, filename) + +@pytest.mark.parametrize("testfile,expected", testfiles.items()) +def test_file_reading_no_tags(testfile, expected): filename = os.path.join(testfolder, testfile) - tag = TinyTag.get(filename) + allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} + tag = TinyTag.get(filename, tags=False) results = { - key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None + key: val for key, val in tag._as_dict().items() if val is not None + } + expected = { + key: val for key, val in expected.items() if key in allowed_attrs } - compare(results, expected, filename) + expected["extra"] = {} + compare_tag(results, expected, filename) def test_pathlib_compatibility(): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a086a83..345ff95 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -826,6 +826,8 @@ def _parse_frame(self, fh, id3version=False): fieldname = self.FRAME_ID_TO_FIELD.get(frame_id) should_set_field = True if fieldname: + if not self._parse_tags: + return frame_size language = fieldname in {'comment', 'extra.lyrics'} value = self._decode_string(content, language) try: @@ -855,7 +857,8 @@ def _parse_frame(self, fh, id3version=False): self._set_field(fieldname, value) elif frame_id in self.CUSTOM_FRAME_IDS: # custom fields - self.__parse_custom_field(self._decode_string(content)) + if self._parse_tags: + self.__parse_custom_field(self._decode_string(content)) elif frame_id in self.IMAGE_FRAME_IDS: if self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames @@ -871,7 +874,8 @@ def _parse_frame(self, fh, id3version=False): self._image_data = content[desc_end_pos:] elif frame_id not in self.DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict - self._set_field('extra.' + frame_id.lower(), self._decode_string(content)) + if self._parse_tags: + self._set_field('extra.' + frame_id.lower(), self._decode_string(content)) return frame_size return 0 @@ -1218,6 +1222,8 @@ def _load(self, tags, duration, image=False): header = self._filehandler.peek(4) if header[:3] == b'ID3': # parse ID3 header if it exists id3 = _ID3(self._filehandler, 0) + id3._parse_tags = tags + id3._load_image = image id3._parse_id3v2(self._filehandler) header = self._filehandler.peek(4) # after ID3 should be fLaC if header[:4] != b'fLaC': @@ -1314,10 +1320,10 @@ class _Wma(TinyTag): def __init__(self, filehandler, filesize, *args, **kwargs): super().__init__(filehandler, filesize, *args, **kwargs) - self.__tag_parsed = False + self._tags_parsed = False def _determine_duration(self, fh): - if not self.__tag_parsed: + if not self._tags_parsed: self._parse_tag(fh) def _read_blocks(self, fh, blocks): @@ -1344,7 +1350,7 @@ def _decode_ext_desc(self, value_type, value): return None def _parse_tag(self, fh): - self.__tag_parsed = True + self._tags_parsed = True guid = fh.read(16) # 128 bit GUID if guid != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel': # not a valid ASF container! see: http://www.garykessler.net/library/file_sigs.html From 7c7a7d014d2f55bbcf593ea344424aeced1c0507 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 05:07:14 +0200 Subject: [PATCH 140/305] Do a better job at catching exceptions related to parsing --- tinytag/tinytag.py | 95 ++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 50 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 345ff95..ca8d0df 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -110,8 +110,8 @@ def get(cls, filename=None, tags=True, duration=True, image=False, tag._default_encoding = encoding try: tag._load(tags=tags, duration=duration, image=image) - except struct.error as exc: - raise TinyTagException('Unexpected file data') from exc + except Exception as exc: + raise TinyTagException(f'Failed to parse file: {exc}') from exc return tag finally: if should_open_file: @@ -193,7 +193,7 @@ def _get_parser_class(cls, filename=None, filehandle=None): parser_class = cls._get_parser_for_file_handle(filehandle) if parser_class is not None: return parser_class - raise TinyTagException('No tag reader found to support filetype! ') + raise TinyTagException('No tag reader found to support filetype') def _load(self, tags, duration, image=False): self._parse_tags = tags @@ -680,12 +680,9 @@ def _determine_duration(self, fh): idx = len(b) # not found: jump over the current peek buffer fh.seek(max(idx, 1), os.SEEK_CUR) continue - try: - self.channels = self.channels_per_channel_mode[channel_mode] - frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] - self.samplerate = self.samplerates[mpeg_id][sr_id] - except (IndexError, TypeError) as exc: - raise TinyTagException('mp3 parsing failed') from exc + self.channels = self.channels_per_channel_mode[channel_mode] + frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] + self.samplerate = self.samplerates[mpeg_id][sr_id] # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate @@ -883,43 +880,41 @@ def _decode_string(self, bytestr, language=False): default_encoding = 'ISO-8859-1' if self._default_encoding: default_encoding = self._default_encoding - try: # it's not my fault, this is the spec. - first_byte = bytestr[:1] - if first_byte == b'\x00': # ISO-8859-1 - bytestr = bytestr[1:] - encoding = default_encoding - elif first_byte == b'\x01': # UTF-16 with BOM - bytestr = bytestr[1:] - # remove language (but leave BOM) - if language: - if bytestr[3:5] in {b'\xfe\xff', b'\xff\xfe'}: - bytestr = bytestr[3:] - if bytestr[:3].isalpha(): - bytestr = bytestr[3:] # remove language - bytestr = bytestr.lstrip(b'\x00') # strip optional additional null bytes - # read byte order mark to determine endianness - encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' - # strip the bom if it exists - if bytestr[:2] in {b'\xfe\xff', b'\xff\xfe'}: - bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] - # remove ADDITIONAL EXTRA BOM :facepalm: - if bytestr[:4] == b'\x00\x00\xff\xfe': - bytestr = bytestr[4:] - elif first_byte == b'\x02': # UTF-16LE - # strip optional null byte, if byte count uneven - bytestr = bytestr[1:-1] if len(bytestr) % 2 == 0 else bytestr[1:] - encoding = 'UTF-16le' - elif first_byte == b'\x03': # UTF-8 - bytestr = bytestr[1:] - encoding = 'UTF-8' - else: - encoding = default_encoding # wild guess - if language and bytestr[:3].isalpha(): - bytestr = bytestr[3:] # remove language - errors = 'ignore' if self._ignore_errors else 'strict' - return self._unpad(bytestr.decode(encoding, errors)) - except UnicodeDecodeError as exc: - raise TinyTagException('Error decoding ID3 Tag!') from exc + # it's not my fault, this is the spec. + first_byte = bytestr[:1] + if first_byte == b'\x00': # ISO-8859-1 + bytestr = bytestr[1:] + encoding = default_encoding + elif first_byte == b'\x01': # UTF-16 with BOM + bytestr = bytestr[1:] + # remove language (but leave BOM) + if language: + if bytestr[3:5] in {b'\xfe\xff', b'\xff\xfe'}: + bytestr = bytestr[3:] + if bytestr[:3].isalpha(): + bytestr = bytestr[3:] # remove language + bytestr = bytestr.lstrip(b'\x00') # strip optional additional null bytes + # read byte order mark to determine endianness + encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' + # strip the bom if it exists + if bytestr[:2] in {b'\xfe\xff', b'\xff\xfe'}: + bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] + # remove ADDITIONAL EXTRA BOM :facepalm: + if bytestr[:4] == b'\x00\x00\xff\xfe': + bytestr = bytestr[4:] + elif first_byte == b'\x02': # UTF-16LE + # strip optional null byte, if byte count uneven + bytestr = bytestr[1:-1] if len(bytestr) % 2 == 0 else bytestr[1:] + encoding = 'UTF-16le' + elif first_byte == b'\x03': # UTF-8 + bytestr = bytestr[1:] + encoding = 'UTF-8' + else: + encoding = default_encoding # wild guess + if language and bytestr[:3].isalpha(): + bytestr = bytestr[3:] # remove language + errors = 'ignore' if self._ignore_errors else 'strict' + return self._unpad(bytestr.decode(encoding, errors)) @staticmethod def _calc_size(bytestr, bits_per_byte): @@ -1096,7 +1091,7 @@ def _parse_pages(self, fh): oggs, version, _flags, pos, _serial, _pageseq, _crc, segments = header self._max_samplenum = max(self._max_samplenum, pos) if oggs != b'OggS' or version != 0: - raise TinyTagException('Not a valid ogg file!') + raise TinyTagException('Invalid OGG file') segsizes = struct.unpack('B' * segments, fh.read(segments)) total = 0 for segsize in segsizes: # read all segments @@ -1148,7 +1143,7 @@ def _determine_duration(self, fh): # and: https://en.wikipedia.org/wiki/WAV riff, _size, fformat = struct.unpack('4sI4s', fh.read(12)) if riff != b'RIFF' or fformat != b'WAVE': - raise TinyTagException('not a wave file!') + raise TinyTagException('Invalid WAV file') self.bitdepth = 16 # assume 16bit depth (CD quality) chunk_header = fh.read(8) while len(chunk_header) == 8: @@ -1227,7 +1222,7 @@ def _load(self, tags, duration, image=False): id3._parse_id3v2(self._filehandler) header = self._filehandler.peek(4) # after ID3 should be fLaC if header[:4] != b'fLaC': - raise TinyTagException('Invalid flac header') + raise TinyTagException('Invalid FLAC file') self._filehandler.seek(4, os.SEEK_CUR) self._determine_duration(self._filehandler) if id3 is not None: # apply ID3 tags after vorbis @@ -1520,7 +1515,7 @@ def __init__(self, filehandler, filesize, *args, **kwargs): def _parse_tag(self, fh): chunk_id, _size, form = struct.unpack('>4sI4s', fh.read(12)) if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): - raise TinyTagException('not an aiff file!') + raise TinyTagException('Invalid AIFF file') chunk_header = fh.read(8) while len(chunk_header) == 8: sub_chunk_id, sub_chunk_size = struct.unpack('>4sI', chunk_header) From 6fdc0cd9492f432a309b48db5196986c79782721 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 05:32:56 +0200 Subject: [PATCH 141/305] MP4: optimize image parsing --- tinytag/tests/test_all.py | 2 ++ tinytag/tinytag.py | 10 +++------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 01566f7..627f94f 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -606,6 +606,7 @@ def test_file_reading_tags(testfile, expected): key: val for key, val in tag._as_dict().items() if val is not None } compare_tag(results, expected, filename) + assert tag._image_data is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -621,6 +622,7 @@ def test_file_reading_no_tags(testfile, expected): } expected["extra"] = {} compare_tag(results, expected, filename) + assert tag._image_data is None def test_pathlib_compatibility(): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ca8d0df..5a17f40 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -451,6 +451,7 @@ def _debug_atom(cls, data): b'gnre': {b'data': _Parser._parse_id3v1_genre}, b'trkn': {b'data': _Parser._make_number_parser('track', 'track_total')}, b'tmpo': {b'data': _Parser._make_data_atom_parser('extra.bpm')}, + b'covr': {b'data': _Parser._make_data_atom_parser('_image_data')}, b'----': _Parser._parse_custom_field, }}}}} @@ -465,10 +466,6 @@ def _debug_atom(cls, data): } } - IMAGE_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { - b'covr': {b'data': _Parser._make_data_atom_parser('_image_data')}, - }}}}} - VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header @@ -477,9 +474,6 @@ def _determine_duration(self, fh): def _parse_tag(self, fh): self._traverse_atoms(fh, path=self.META_DATA_TREE) - if self._load_image: # A bit inefficient, we rewind the file - self._filehandler.seek(0) # to parse it again for the image - self._traverse_atoms(fh, path=self.IMAGE_DATA_TREE) def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): header_size = 8 @@ -510,6 +504,8 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): for fieldname, value in sub_path(fh.read(atom_size)).items(): if DEBUG: print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) + if fieldname == '_image_data' and not self._load_image: + continue if fieldname: self._set_field(fieldname, value) # if no action was specified using dict or callable, jump over atom From ecb777c1bad70e4592bca6e7e5086737648fc095 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 05:50:53 +0200 Subject: [PATCH 142/305] Consistent tag reading across formats --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 72 +++++++++++++++------------------------ 2 files changed, 29 insertions(+), 45 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 627f94f..336bf24 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -310,7 +310,7 @@ ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', - 'title': 'Test000\x00Stacked\x00Test000\x00Stacked', 'track': 17, + 'title': 'Test000\x00Stacked', 'track': 17, 'album': 'prototypes'}), ('samples/adpcm.wav', {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5a17f40..d158755 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -89,6 +89,7 @@ def __init__(self, filehandler, filesize, ignore_errors=False): self._load_image = False self._image_data = None self._ignore_errors = ignore_errors + self._tags_parsed = False @classmethod def get(cls, filename=None, tags=True, duration=True, image=False, @@ -592,8 +593,8 @@ class _ID3(TinyTag): 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', ] - def __init__(self, filehandler, filesize, *args, **kwargs): - super().__init__(filehandler, filesize, *args, **kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) # save position after the ID3 tag for duration measurement speedup self._bytepos_after_id3v2 = None @@ -919,9 +920,8 @@ def _calc_size(bytestr, bits_per_byte): class _Ogg(TinyTag): - def __init__(self, filehandler, filesize, *args, **kwargs): - super().__init__(filehandler, filesize, *args, **kwargs) - self._tags_parsed = False + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self._max_samplenum = 0 # maximum sample position ever read def _determine_duration(self, fh): @@ -1130,11 +1130,11 @@ class _Wave(TinyTag): b'YEAR': 'year', } - def __init__(self, filehandler, filesize, *args, **kwargs): - super().__init__(filehandler, filesize, *args, **kwargs) - self._duration_parsed = False - def _determine_duration(self, fh): + if not self._tags_parsed: + self._parse_tag(fh) + + def _parse_tag(self, fh): # see: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html # and: https://en.wikipedia.org/wiki/WAV riff, _size, fformat = struct.unpack('4sI4s', fh.read(12)) @@ -1189,11 +1189,7 @@ def _determine_duration(self, fh): else: # some other chunk, just skip the data fh.seek(subchunksize, 1) chunk_header = fh.read(8) - self._duration_parsed = True - - def _parse_tag(self, fh): - if not self._duration_parsed: - self._determine_duration(fh) # parse whole file to determine tags:( + self._tags_parsed = True class _Flac(TinyTag): @@ -1205,26 +1201,22 @@ class _Flac(TinyTag): METADATA_CUESHEET = 5 METADATA_PICTURE = 6 - def _load(self, tags, duration, image=False): - self._parse_tags = tags - self._parse_duration = duration - self._load_image = image + def _determine_duration(self, fh): + if not self._tags_parsed: + self._parse_tag(fh) + + def _parse_tag(self, fh): id3 = None - header = self._filehandler.peek(4) + header = fh.peek(4) if header[:3] == b'ID3': # parse ID3 header if it exists - id3 = _ID3(self._filehandler, 0) - id3._parse_tags = tags - id3._load_image = image - id3._parse_id3v2(self._filehandler) - header = self._filehandler.peek(4) # after ID3 should be fLaC + id3 = _ID3(fh, 0) + id3._parse_tags = self._parse_tags + id3._load_image = self._load_image + id3._parse_id3v2(fh) + header = fh.peek(4) # after ID3 should be fLaC if header[:4] != b'fLaC': raise TinyTagException('Invalid FLAC file') - self._filehandler.seek(4, os.SEEK_CUR) - self._determine_duration(self._filehandler) - if id3 is not None: # apply ID3 tags after vorbis - self._update(id3) - - def _determine_duration(self, fh): + fh.seek(4, os.SEEK_CUR) # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data) == 4: @@ -1236,7 +1228,7 @@ def _determine_duration(self, fh): if block_type == self.METADATA_STREAMINFO and self._parse_duration: stream_info_header = fh.read(size) if len(stream_info_header) < 34: # invalid streaminfo - return + break header = struct.unpack('HH3s3s8B16s', stream_info_header) # From the xiph documentation: # py | @@ -1272,18 +1264,18 @@ def _determine_duration(self, fh): elif block_type == self.METADATA_PICTURE and self._load_image: self._image_data = self._parse_image(fh) elif block_type >= 127: - return # invalid block type + break # invalid block type else: if DEBUG: print('Unknown FLAC block type', block_type) fh.seek(size, 1) # seek over this block if is_last_block: - return + break header_data = fh.read(4) - - def _parse_tag(self, fh): - pass + if id3 is not None: # apply ID3 tags after vorbis + self._update(id3) + self._tags_parsed = True @staticmethod def _parse_image(fh): @@ -1309,10 +1301,6 @@ class _Wma(TinyTag): # and (japanese, but none the less helpful) # http://uguisu.skr.jp/Windows/format_asf.html - def __init__(self, filehandler, filesize, *args, **kwargs): - super().__init__(filehandler, filesize, *args, **kwargs) - self._tags_parsed = False - def _determine_duration(self, fh): if not self._tags_parsed: self._parse_tag(fh) @@ -1504,10 +1492,6 @@ class _Aiff(TinyTag): b'(c) ': 'extra.copyright', } - def __init__(self, filehandler, filesize, *args, **kwargs): - super().__init__(filehandler, filesize, *args, **kwargs) - self._tags_parsed = False - def _parse_tag(self, fh): chunk_id, _size, form = struct.unpack('>4sI4s', fh.read(12)) if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): From acd7b039027095e4c4881a9c4941dc3520bc4239 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 07:50:43 +0200 Subject: [PATCH 143/305] ID3: Ignore ID3v1 fields if ID3v2 fields were found Fixes #200 --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 28 ++++++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 336bf24..5189cbe 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -141,7 +141,7 @@ {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', 'extra': {'love rating': 'L', 'publisher': 'Century Media', 'popm': 'MusicBee\x00Ä'}, - 'genre': 'Power Metal\x00Other', 'title': 'Time What Is Time', 'track': 1, + 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), ('samples/nicotinetestdata.mp3', {'extra': {'tsse': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d158755..4960b81 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -727,9 +727,7 @@ def _determine_duration(self, fh): def _parse_tag(self, fh): self._parse_id3v2(fh) - attrs = ['track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'genre'] - has_all_tags = all(getattr(self, attr) for attr in attrs) - if not has_all_tags and self.filesize > 128: + if self.filesize > 128: fh.seek(-128, os.SEEK_END) # try parsing id3v1 in last 128 bytes self._parse_id3v1(fh) @@ -768,19 +766,29 @@ def _parse_id3v2(self, fh): fh.seek(end_pos, os.SEEK_SET) def _parse_id3v1(self, fh): - if fh.read(3) == b'TAG': # check if this is an ID3 v1 tag - def asciidecode(x): - return self._unpad(x.decode(self._default_encoding or 'latin1')) - fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) + if fh.read(3) != b'TAG': # check if this is an ID3 v1 tag + return + def asciidecode(x): + return self._unpad(x.decode(self._default_encoding or 'latin1')) + # Only set fields that were not set by ID3v2 tags, as ID3v1 + # tags are more likely to be outdated or have encoding issues + fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) + if not self.title: self._set_field('title', asciidecode(fields[:30])) + if not self.artist: self._set_field('artist', asciidecode(fields[30:60])) + if not self.album: self._set_field('album', asciidecode(fields[60:90])) + if not self.year: self._set_field('year', asciidecode(fields[90:94])) - comment = fields[94:124] - if b'\x00\x00' < comment[-2:] < b'\x01\x00': + comment = fields[94:124] + if b'\x00\x00' < comment[-2:] < b'\x01\x00': + if self.track is None: self._set_field('track', ord(comment[-1:])) - comment = comment[:-2] + comment = comment[:-2] + if not self.comment: self._set_field('comment', asciidecode(comment)) + if not self.genre: genre_id = ord(fields[124:125]) if genre_id < len(self.ID3V1_GENRES): self._set_field('genre', self.ID3V1_GENRES[genre_id]) From 122c279119ed0a9558de5ddd943801cea6f11cb8 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 10:03:19 +0200 Subject: [PATCH 144/305] Stop using BufferedReader and peek() --- tinytag/tinytag.py | 47 ++++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4960b81..4615db9 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -97,8 +97,6 @@ def get(cls, filename=None, tags=True, duration=True, image=False, should_open_file = file_obj is None if should_open_file: file_obj = open(filename, 'rb') # pylint: disable=consider-using-with - elif isinstance(file_obj, io.BytesIO): - file_obj = io.BufferedReader(file_obj) # buffered reader to support peeking try: file_obj.seek(0, os.SEEK_END) filesize = file_obj.tell() @@ -175,7 +173,8 @@ def _get_parser_for_file_handle(cls, fh): b'^FORM....AIFF': _Aiff, b'^FORM....AIFC': _Aiff, } - header = fh.peek(max(len(sig) for sig in cls._magic_bytes_mapping)) + header = fh.read(max(len(sig) for sig in cls._magic_bytes_mapping)) + fh.seek(0) for magic, parser in cls._magic_bytes_mapping.items(): if re.match(magic, header): return parser @@ -655,9 +654,12 @@ def _determine_duration(self, fh): last_bitrates = [] # CBR mp3s (multiple frames with same bitrates) # seek to first position after id3 tag (speedup for large header) fh.seek(self._bytepos_after_id3v2) + file_offset = fh.tell() + walker = io.BytesIO(fh.read()) while True: # reading through garbage until 11 '1' sync-bits are found - b = fh.peek(4) + b = walker.read() + walker.seek(-len(b), os.SEEK_CUR) if len(b) < 4: if frames: self.bitrate = bitrate_accu / frames @@ -675,7 +677,7 @@ def _determine_duration(self, fh): idx = b.find(b'\xFF', 1) # invalid frame, find next sync header if idx == -1: idx = len(b) # not found: jump over the current peek buffer - fh.seek(max(idx, 1), os.SEEK_CUR) + walker.seek(max(idx, 1), os.SEEK_CUR) continue self.channels = self.channels_per_channel_mode[channel_mode] frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] @@ -686,8 +688,8 @@ def _determine_duration(self, fh): if frames == 0 and self._USE_XING_HEADER: xing_header_offset = b.find(b'Xing') if xing_header_offset != -1: - fh.seek(xing_header_offset, os.SEEK_CUR) - xframes, byte_count, _toc, _vbr_scale = self._parse_xing_header(fh) + walker.seek(xing_header_offset, os.SEEK_CUR) + xframes, byte_count, _toc, _vbr_scale = self._parse_xing_header(walker) if xframes and xframes != 0 and byte_count: # MPEG-2 Audio Layer III uses 576 samples per frame samples_per_frame = 576 if mpeg_id <= 2 else self.samples_per_frame @@ -701,10 +703,10 @@ def _determine_duration(self, fh): frames += 1 # it's most probably an mp3 frame bitrate_accu += frame_bitrate if frames == 1: - audio_offset = fh.tell() + audio_offset = file_offset + walker.tell() if frames <= self._CBR_DETECTION_FRAME_COUNT: last_bitrates.append(frame_bitrate) - fh.seek(4, os.SEEK_CUR) # jump over peeked bytes + walker.seek(4, os.SEEK_CUR) # jump over peeked bytes frame_length = (144000 * frame_bitrate) // self.samplerate + padding frame_size_accu += frame_length @@ -721,7 +723,7 @@ def _determine_duration(self, fh): return if frame_length > 1: # jump over current frame body - fh.seek(frame_length - header_bytes, os.SEEK_CUR) + walker.seek(frame_length - header_bytes, os.SEEK_CUR) if self.samplerate: self.duration = frames * self.samples_per_frame / self.samplerate @@ -942,17 +944,20 @@ def _determine_duration(self, fh): if self.filesize > max_page_size: fh.seek(-max_page_size, 2) # go to last possible page position while True: - b = fh.peek(4) - if len(b) == 0: + file_offset = fh.tell() + b = fh.read() + if len(b) < 4: return # EOF if b[:4] == b'OggS': # look for an ogg header + fh.seek(file_offset) for _ in self._parse_pages(fh): pass # parse all remaining pages self.duration = self._max_samplenum / self.samplerate - else: - idx = b.find(b'OggS') # try to find header in peeked data - seekpos = idx if idx != -1 else len(b) - 3 - fh.seek(max(seekpos, 1), os.SEEK_CUR) + break + idx = b.find(b'OggS') # try to find header in peeked data + if idx != -1: + fh.seek(file_offset + idx) + def _parse_tag(self, fh): check_flac_second_packet = False @@ -984,7 +989,7 @@ def _parse_tag(self, fh): elif packet[0:5] == b'\x7fFLAC': # https://xiph.org/flac/ogg_mapping.html walker.seek(9, os.SEEK_CUR) # jump over header name, version and number of headers - flactag = _Flac(io.BufferedReader(walker), self.filesize) + flactag = _Flac(walker, self.filesize) flactag._load(tags=self._parse_tags, duration=self._parse_duration, image=self._load_image) self._update(flactag) @@ -1215,16 +1220,16 @@ def _determine_duration(self, fh): def _parse_tag(self, fh): id3 = None - header = fh.peek(4) + header = fh.read(4) if header[:3] == b'ID3': # parse ID3 header if it exists + fh.seek(-4, os.SEEK_CUR) id3 = _ID3(fh, 0) id3._parse_tags = self._parse_tags id3._load_image = self._load_image id3._parse_id3v2(fh) - header = fh.peek(4) # after ID3 should be fLaC + header = fh.read(4) # after ID3 should be fLaC if header[:4] != b'fLaC': raise TinyTagException('Invalid FLAC file') - fh.seek(4, os.SEEK_CUR) # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data) == 4: @@ -1525,8 +1530,6 @@ def _parse_tag(self, fh): id3 = _ID3(fh, 0) id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) - elif sub_chunk_id == b'SSND': - fh.seek(sub_chunk_size, 1) else: # some other chunk, just skip the data fh.seek(sub_chunk_size, 1) chunk_header = fh.read(8) From 384f6f6119072b93631ac609f8a9bac4e9efd57d Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 10:50:00 +0200 Subject: [PATCH 145/305] OGG: set missing 'channels' attribute --- tinytag/tests/test_all.py | 11 +++++------ tinytag/tinytag.py | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5189cbe..5ed403b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -238,8 +238,7 @@ # OGG ('samples/empty.ogg', {'extra': {}, 'duration': 3.684716553287982, - 'filesize': 4328, 'bitrate': 112.0, - 'samplerate': 44100}), + 'filesize': 4328, 'bitrate': 112.0, 'samplerate': 44100, 'channels': 2}), ('samples/multipage-setup.ogg', {'extra': {'transcoded': 'mp3;241', 'replaygain_album_gain': '-10.29 dB', 'replaygain_album_peak': '1.50579047', 'replaygain_track_peak': '1.17979193', @@ -247,19 +246,19 @@ 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': 7, 'filesize': 76983, 'bitrate': 160.0, - 'samplerate': 44100, 'comment': 'SRCL-6240'}), + 'samplerate': 44100, 'comment': 'SRCL-6240', 'channels': 2}), ('samples/test.ogg', {'extra': {}, 'duration': 1.0, 'album': 'the boss', 'year': '2006', 'title': 'the boss', 'artist': 'james brown', 'track': 1, - 'filesize': 7467, 'bitrate': 160.0, 'samplerate': 44100, + 'filesize': 7467, 'bitrate': 160.0, 'samplerate': 44100, 'channels': 2, 'comment': 'hello!'}), ('samples/corrupt_metadata.ogg', {'extra': {}, 'filesize': 18648, 'bitrate': 80.0, - 'duration': 2.132358276643991, 'samplerate': 44100}), + 'duration': 2.132358276643991, 'samplerate': 44100, 'channels': 1}), ('samples/composer.ogg', {'extra': {'composer': 'some composer'}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', - 'bitrate': 112.0, 'duration': 3.684716553287982, + 'bitrate': 112.0, 'duration': 3.684716553287982, 'channels': 2, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2, 'year': '2007', 'comment': 'A Comment'}), ('samples/test.opus', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4615db9..4db710c 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -966,7 +966,7 @@ def _parse_tag(self, fh): walker = io.BytesIO(packet) if packet[0:7] == b"\x01vorbis": if self._parse_duration: - (_channels, self.samplerate, _max_bitrate, bitrate, + (self.channels, self.samplerate, _max_bitrate, bitrate, _min_bitrate) = struct.unpack(" Date: Wed, 28 Feb 2024 10:53:16 +0200 Subject: [PATCH 146/305] Fix linting error --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4db710c..6ade556 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -770,6 +770,7 @@ def _parse_id3v2(self, fh): def _parse_id3v1(self, fh): if fh.read(3) != b'TAG': # check if this is an ID3 v1 tag return + def asciidecode(x): return self._unpad(x.decode(self._default_encoding or 'latin1')) # Only set fields that were not set by ID3v2 tags, as ID3v1 @@ -958,7 +959,6 @@ def _determine_duration(self, fh): if idx != -1: fh.seek(file_offset + idx) - def _parse_tag(self, fh): check_flac_second_packet = False check_speex_second_packet = False From 8630cbca6eb4ab80a5cd5c0f5315871905ef24f2 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 20:06:22 +0200 Subject: [PATCH 147/305] WMA: raise exception if file is invalid --- tinytag/tests/test_all.py | 1 + tinytag/tinytag.py | 16 +++++++--------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5ed403b..4fa08a6 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -688,6 +688,7 @@ def test_mp3_length_estimation(): ('samples/incomplete.mp3', _ID3), ('samples/flac1.5sStereo.flac', _Ogg), ('samples/flac1.5sStereo.flac', _Wave), + ('samples/flac1.5sStereo.flac', _Wma), ('samples/ilbm.aiff', _Aiff), ]) @pytest.mark.xfail(raises=TinyTagException) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 6ade556..b7bc3c6 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1342,15 +1342,12 @@ def _decode_ext_desc(self, value_type, value): return None def _parse_tag(self, fh): - self._tags_parsed = True - guid = fh.read(16) # 128 bit GUID - if guid != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel': - # not a valid ASF container! see: http://www.garykessler.net/library/file_sigs.html - return - fh.read(12) # size and obj_count - if fh.read(2) != b'\x01\x02': - # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc521913958 - return # not a valid asf header! + header = fh.read(30) + # http://www.garykessler.net/library/file_sigs.html + # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc521913958 + if (header[:16] != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' # 128 bit GUID + or header[-1:] != b'\x02'): + raise TinyTagException('Invalid WMA file') while True: object_id = fh.read(16) object_size = self._bytes_to_int_le(fh.read(8)) @@ -1461,6 +1458,7 @@ def _parse_tag(self, fh): fh.seek(blocks['error_correction_data_length'], os.SEEK_CUR) else: fh.seek(object_size - 24, os.SEEK_CUR) # read over onknown object ids + self._tags_parsed = True class _Aiff(TinyTag): From 7bef08a63f6daa2640115c83327a546835664512 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 20:34:03 +0200 Subject: [PATCH 148/305] MP4: don't return None as genre --- tinytag/tinytag.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b7bc3c6..fec863e 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -328,9 +328,10 @@ def _(data_atom): def _parse_id3v1_genre(cls, data_atom): # dunno why the genre is offset by -1 but that's how mutagen does it idx = struct.unpack('>H', data_atom[8:])[0] - 1 + result = {} if idx < len(_ID3.ID3V1_GENRES): - return {'genre': _ID3.ID3V1_GENRES[idx]} - return {'genre': None} + result['genre'] = _ID3.ID3V1_GENRES[idx] + return result @classmethod def _read_extended_descriptor(cls, esds_atom): @@ -419,11 +420,6 @@ def _parse_mvhd(cls, data): duration = struct.unpack('>q', walker.read(8))[0] return {'duration': duration / time_scale} - @classmethod - def _debug_atom(cls, data): - print(data) # use this function to inspect atoms in an atom tree - return {} - # The parser tree: Each key is an atom name which is traversed if existing. # Leaves of the parser tree are callables which receive the atom data. # callables return {fieldname: value} which is updates the TinyTag. From 81f1b99a8c8a32bc1614aa13d1bd3ebb955fce06 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 28 Feb 2024 20:51:59 +0200 Subject: [PATCH 149/305] Add test file for GSM 6.10 WAV --- tinytag/tests/samples/gsm_6_10.wav | Bin 0 -> 1246 bytes tinytag/tests/test_all.py | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 tinytag/tests/samples/gsm_6_10.wav diff --git a/tinytag/tests/samples/gsm_6_10.wav b/tinytag/tests/samples/gsm_6_10.wav new file mode 100644 index 0000000000000000000000000000000000000000..b66884077c32c01a51dc64b61783da04bbfa81ae GIT binary patch literal 1246 zcmeH{y-ve05Xb)sEgupCAOx$0IxsfvfXdV~X{gmEsf-1)NmEflsFb)8BSI`J40#To zr*DJAnN}=xV~wTL-OoPX=}z_u9t6u8aO>XmrOs{5^FjXo4o3W1=JZ2(6vRAo z!)>y4u964vvG5J4$Gb%a?h3hUDA%(>lfM_rBjRG7r7W3eS^A{eeVV6qvbxu5I!*Gs ziiC;MQ$;}}#n8-yP6wDx+N>m-`|TRKfMvq(TdZy$SkD(;9EQXvBxe0dBfU7H{W^&W oEq&pw(d3oF8xqpAp;Xcx^~m_GMRM6%^G~I6+;@m~NLJN-0|wGp9RL6T literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 4fa08a6..ae0cd8f 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -330,6 +330,11 @@ {'extra': {}, 'filesize': 8908, 'bitrate': 705.6, 'duration': 0.1, 'samplerate': 44100, 'channels': 1, 'bitdepth': 16}), + ('samples/gsm_6_10.wav', + {'extra': {}, 'bitdepth': 1, 'bitrate': 44.1, 'channels': 1, + 'duration': 0.16507936507936508, 'filesize': 1246, 'samplerate': 44100, + 'album': 'album', 'artist': 'artist', 'title': 'track', 'track': 99, + 'year': '2010', 'comment': 'some comment here', 'genre': 'Bass'}), # FLAC ('samples/flac1sMono.flac', From 26c01af4f6db8e8cf5c1ad805da6b2205a6e2852 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 01:50:42 +0200 Subject: [PATCH 150/305] Add type hints to codebase --- .github/workflows/tests.yml | 6 + release.py | 3 +- setup.cfg | 3 + tinytag/__main__.py | 21 +- tinytag/tests/test_all.py | 73 +++-- tinytag/tests/test_cli.py | 30 +- tinytag/tinytag.py | 548 +++++++++++++++++++----------------- 7 files changed, 366 insertions(+), 318 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a0df534..d9c9641 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,12 @@ jobs: - name: Linting run: python -m pylint --recursive=y . + - name: Typing + if: matrix.python != 'pypy-3.7' + run: | + python -m pip install mypy + python -m mypy -p tinytag + - name: Unit tests run: python -m pytest --cov env: diff --git a/release.py b/release.py index 544c488..2b92db6 100755 --- a/release.py +++ b/release.py @@ -5,10 +5,11 @@ import sys -def release_package(): +def release_package() -> None: # Run tests subprocess.check_call([sys.executable, "-m", "pycodestyle"]) subprocess.check_call([sys.executable, "-m", "pylint", "--recursive=y", "."]) + subprocess.check_call([sys.executable, "-m", "mypy", "-p", "tinytag"]) subprocess.check_call([sys.executable, "-m", "pytest"]) # Prepare source distribution and wheel diff --git a/setup.cfg b/setup.cfg index 5dbd116..9219ad7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -78,3 +78,6 @@ py-version = 3.6 [pylint.format] max-line-length = 100 + +[mypy] +strict = True diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 6dda03d..0576b06 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,6 +1,7 @@ # pylint: disable=missing-module-docstring,protected-access from os.path import splitext +from typing import Optional import json import os import sys @@ -8,7 +9,7 @@ from tinytag.tinytag import TinyTag -def _usage(): +def _usage() -> None: print('''tinytag [options] -h, --help @@ -26,7 +27,7 @@ def _usage(): ''') -def _pop_param(name, _default): +def _pop_param(name: str, _default: Optional[str]) -> Optional[str]: if name in sys.argv: idx = sys.argv.index(name) sys.argv.pop(idx) @@ -34,7 +35,7 @@ def _pop_param(name, _default): return _default -def _pop_switch(name, _default): +def _pop_switch(name: str) -> bool: if name in sys.argv: idx = sys.argv.index(name) sys.argv.pop(idx) @@ -42,7 +43,7 @@ def _pop_switch(name, _default): return False -def _print_tag(tag, formatting, header_printed=False): +def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool: data = {'filename': tag._filename} data.update(tag._as_dict()) if formatting == 'json': @@ -52,25 +53,25 @@ def _print_tag(tag, formatting, header_printed=False): if isinstance(value, str): data[field] = value.replace('\x00', ';') # use a more friendly separator for output if formatting == 'csv': - print('\n'.join(f'{field},{value}' for field, value in data.items())) + print('\n'.join(f'{field},{value!r}' for field, value in data.items())) elif formatting == 'tsv': - print('\n'.join(f'{field}\t{value}' for field, value in data.items())) + print('\n'.join(f'{field}\t{value!r}' for field, value in data.items())) elif formatting == 'tabularcsv': if not header_printed: print(','.join(field for field, value in data.items())) header_printed = True - print(','.join(f'"{value}"' for field, value in data.items())) + print(','.join(f'"{value!r}"' for field, value in data.items())) return header_printed -def _run(): - display_help = _pop_switch('--help', False) or _pop_switch('-h', False) +def _run() -> int: + display_help = _pop_switch('--help') or _pop_switch('-h') if display_help: _usage() return 0 save_image_path = _pop_param('--save-image', None) or _pop_param('-i', None) formatting = (_pop_param('--format', None) or _pop_param('-f', None)) or 'json' - skip_unsupported = _pop_switch('--skip-unsupported', False) or _pop_switch('-s', False) + skip_unsupported = _pop_switch('--skip-unsupported') or _pop_switch('-s') filenames = sys.argv[1:] header_printed = False diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index ae0cd8f..16c8f5c 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -7,6 +7,8 @@ # pylint: disable=missing-function-docstring,missing-module-docstring,protected-access +from typing import Any, Dict, Optional, Type, Union + import io import os import pathlib @@ -541,7 +543,7 @@ testfolder = os.path.join(os.path.dirname(__file__)) -def load_custom_samples(): +def load_custom_samples() -> Dict[str, Dict[str, Any]]: retval = {} custom_samples_folder = os.path.join(testfolder, 'custom_samples') pattern_field_name_type = [ @@ -575,15 +577,19 @@ def load_custom_samples(): testfiles.update(load_custom_samples()) -def compare_tag(results, expected, file, prev_path=None): - def compare_values(path, result_val, expected_val): - if path == 'extra.lyrics': # lets not copy *all* the lyrics inside the fixture +def compare_tag(results: Dict[str, Dict[str, Any]], expected: Dict[str, Dict[str, Any]], + file: str, prev_path: Optional[str] = None) -> None: + def compare_values(path: str, result_val: Union[int, float, str, Dict[str, Any]], + expected_val: Union[int, float, str, Dict[str, Any]]) -> bool: + # lets not copy *all* the lyrics inside the fixture + if (path == 'extra.lyrics' + and isinstance(expected_val, str) and isinstance(result_val, str)): return result_val.startswith(expected_val) if isinstance(expected_val, float): return result_val == pytest.approx(expected_val) return result_val == expected_val - def error_fmt(value): + def error_fmt(value: Union[int, float, str, Dict[str, Any]]) -> str: return f'{repr(value)} ({type(value)})' assert isinstance(results, dict) @@ -603,7 +609,7 @@ def error_fmt(value): @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_tags(testfile, expected): +def test_file_reading_tags(testfile: str, expected: Dict[str, Dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True) results = { @@ -614,7 +620,7 @@ def test_file_reading_tags(testfile, expected): @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_no_tags(testfile, expected): +def test_file_reading_no_tags(testfile: str, expected: Dict[str, Dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False) @@ -629,14 +635,14 @@ def test_file_reading_no_tags(testfile, expected): assert tag._image_data is None -def test_pathlib_compatibility(): +def test_pathlib_compatibility() -> None: testfile = next(iter(testfiles.keys())) filename = pathlib.Path(testfolder) / testfile TinyTag.get(filename) assert TinyTag.is_supported(filename) -def test_file_obj_compatibility(): +def test_file_obj_compatibility() -> None: testfile = next(iter(testfiles.keys())) filename = os.path.join(testfolder, testfile) with open(filename, 'rb') as file_handle: @@ -647,9 +653,9 @@ def test_file_obj_compatibility(): @pytest.mark.skipif(sys.platform == "win32", reason='Windows does not support binary paths') -def test_binary_path_compatibility(): +def test_binary_path_compatibility() -> None: binary_file_path = os.path.join(os.path.dirname(__file__).encode('utf-8'), b'\x01.mp3') - testfile = os.path.join(testfolder, next(iter(testfiles.keys()))) + testfile = os.path.join(testfolder, next(iter(testfiles.keys()))).encode('utf-8') shutil.copy(testfile, binary_file_path) assert os.path.exists(binary_file_path) TinyTag.get(binary_file_path) @@ -658,33 +664,40 @@ def test_binary_path_compatibility(): @pytest.mark.xfail(raises=TinyTagException) -def test_unsupported_extension(): +def test_unsupported_extension() -> None: bogus_file = os.path.join(testfolder, 'samples/there_is_no_such_ext.bogus') TinyTag.get(bogus_file) -def test_override_encoding(): +def test_override_encoding() -> None: chinese_id3 = os.path.join(testfolder, 'samples/chinese_id3.mp3') tag = TinyTag.get(chinese_id3, encoding='gbk') assert tag.artist == '苏云' assert tag.album == '角落之歌' +@pytest.mark.xfail(raises=TinyTagException) +def test_unsubclassed_tinytag_load() -> None: + tag = TinyTag() + tag._load(tags=True, duration=True) + + @pytest.mark.xfail(raises=NotImplementedError) -def test_unsubclassed_tinytag_duration(): - tag = TinyTag(None, 0) - tag._determine_duration(None) +def test_unsubclassed_tinytag_duration() -> None: + tag = TinyTag() + tag._determine_duration(None) # type: ignore @pytest.mark.xfail(raises=NotImplementedError) -def test_unsubclassed_tinytag_parse_tag(): - tag = TinyTag(None, 0) - tag._parse_tag(None) +def test_unsubclassed_tinytag_parse_tag() -> None: + tag = TinyTag() + tag._parse_tag(None) # type: ignore -def test_mp3_length_estimation(): +def test_mp3_length_estimation() -> None: _ID3._MAX_ESTIMATION_SEC = 0.7 tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) + assert tag.duration is not None assert 3.5 < tag.duration < 4.0 @@ -697,7 +710,7 @@ def test_mp3_length_estimation(): ('samples/ilbm.aiff', _Aiff), ]) @pytest.mark.xfail(raises=TinyTagException) -def test_invalid_file(path, cls): +def test_invalid_file(path: str, cls: Type[TinyTag]) -> None: cls.get(os.path.join(testfolder, path)) @@ -713,11 +726,11 @@ def test_invalid_file(path, cls): ('samples/wav_with_image.wav', 4627), ('samples/aiff_with_image.aiff', 21963), ]) -def test_image_loading(path, expected_size): +def test_image_loading(path: str, expected_size: int) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) image_data = tag.get_image() - image_size = len(image_data) assert image_data is not None + image_size = len(image_data) assert image_size == expected_size, \ f'Image is {image_size} bytes but should be {expected_size} bytes' assert image_data.startswith(b'\xff\xd8\xff\xe0'), \ @@ -725,11 +738,11 @@ def test_image_loading(path, expected_size): @pytest.mark.xfail(raises=TinyTagException) -def test_mp3_utf_8_invalid_string_raises_exception(): +def test_mp3_utf_8_invalid_string_raises_exception() -> None: TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) -def test_mp3_utf_8_invalid_string_can_be_ignored(): +def test_mp3_utf_8_invalid_string_can_be_ignored() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3'), ignore_errors=True) # the title used to be Gran dia, but I replaced the first byte with 0xFF, @@ -749,21 +762,21 @@ def test_mp3_utf_8_invalid_string_can_be_ignored(): ('samples/detect_mp4_m4a.x', _MP4), ('samples/detect_aiff.x', _Aiff), ]) -def test_detect_magic_headers(testfile, expected): +def test_detect_magic_headers(testfile: str, expected: Type[TinyTag]) -> None: filename = os.path.join(testfolder, testfile) with open(filename, 'rb') as file_handle: parser = TinyTag._get_parser_class(filename, file_handle) assert parser == expected -def test_show_hint_for_wrong_usage(): +def test_show_hint_for_wrong_usage() -> None: with pytest.raises(TinyTagException) as exc_info: - TinyTag('filename.mp3', 0) + TinyTag.get() assert exc_info.type == TinyTagException - assert exc_info.value.args[0] == 'Use `TinyTag.get(filepath)` instead of `TinyTag(filepath)`' + assert exc_info.value.args[0] == 'Either filename or file_obj argument is required' -def test_to_str(): +def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert str(tag) == ( "{'album': 'Hymns for the Exiled', 'albumartist': None, 'artist': 'Anais Mitchell', " diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 18ab54a..de77b2c 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -21,7 +21,7 @@ 'track_total', 'year'} -def run_cli(args): +def run_cli(args: str) -> str: debug_env = str(os.environ.pop("DEBUG", None)) output = check_output(f'{sys.executable} -m tinytag ' + args, cwd=project_folder, shell=True) if debug_env: @@ -29,21 +29,21 @@ def run_cli(args): return output.decode('utf-8') -def file_size(filename): +def file_size(filename: str) -> int: return os.stat(filename).st_size @pytest.mark.xfail(raises=CalledProcessError) -def test_wrong_params(): +def test_wrong_params() -> None: assert 'tinytag [options] None: assert 'tinytag [options] None: with NamedTemporaryFile() as temp_file: assert file_size(temp_file.name) == 0 run_cli(f'--save-image {temp_file.name} {mp3_with_image}') @@ -54,14 +54,14 @@ def test_save_image_long_opt(): assert b'JFIF' in image_data -def test_save_image_short_opt(): +def test_save_image_short_opt() -> None: with NamedTemporaryFile() as temp_file: assert file_size(temp_file.name) == 0 run_cli(f'-i {temp_file.name} {mp3_with_image}') assert file_size(temp_file.name) > 0 -def test_save_image_bulk(): +def test_save_image_bulk() -> None: with NamedTemporaryFile(suffix='.jpg') as temp_file: temp_file_no_ext = temp_file.name[:-4] assert file_size(temp_file.name) == 0 @@ -72,21 +72,21 @@ def test_save_image_bulk(): assert file_size(temp_file_no_ext + '00002.jpg') > 0 -def test_meta_data_output_default_json(): +def test_meta_data_output_default_json() -> None: output = run_cli(mp3_with_image) data = json.loads(output) assert data assert set(data.keys()) == tinytag_attributes -def test_meta_data_output_format_json(): +def test_meta_data_output_format_json() -> None: output = run_cli('-f json ' + mp3_with_image) data = json.loads(output) assert data assert set(data.keys()) == tinytag_attributes -def test_meta_data_output_format_csv(): +def test_meta_data_output_format_csv() -> None: output = run_cli('-f csv ' + mp3_with_image) lines = [line for line in output.split(os.linesep) if line] assert all(',' in line for line in lines) @@ -94,7 +94,7 @@ def test_meta_data_output_format_csv(): assert set(attributes) == tinytag_attributes -def test_meta_data_output_format_tsv(): +def test_meta_data_output_format_tsv() -> None: output = run_cli('-f tsv ' + mp3_with_image) lines = [line for line in output.split(os.linesep) if line] assert all('\t' in line for line in lines) @@ -102,20 +102,20 @@ def test_meta_data_output_format_tsv(): assert set(attributes) == tinytag_attributes -def test_meta_data_output_format_tabularcsv(): +def test_meta_data_output_format_tabularcsv() -> None: output = run_cli('-f tabularcsv ' + mp3_with_image) header, _line, _rest = output.split(os.linesep) assert set(header.split(',')) == tinytag_attributes @pytest.mark.xfail(raises=CalledProcessError) -def test_fail_on_unsupported_file(): +def test_fail_on_unsupported_file() -> None: run_cli(bogus_file) -def test_fail_skip_unsupported_file_long_opt(): +def test_fail_skip_unsupported_file_long_opt() -> None: run_cli('--skip-unsupported ' + bogus_file) -def test_fail_skip_unsupported_file_short_opt(): +def test_fail_skip_unsupported_file_short_opt() -> None: run_cli('-s ' + bogus_file) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index fec863e..d36d675 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -36,6 +36,8 @@ from functools import reduce from sys import stderr +from typing import Any, BinaryIO, Callable, Dict, Iterator, List, Optional, Tuple, Type, Union + import base64 import io import os @@ -57,80 +59,92 @@ class TinyTag: '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' ] - _file_extension_mapping = None - _magic_bytes_mapping = None - - def __init__(self, filehandler, filesize, ignore_errors=False): - if isinstance(filehandler, str): - raise TinyTagException('Use `TinyTag.get(filepath)` instead of `TinyTag(filepath)`') - self._filehandler = filehandler - self._filename = None # for debugging purposes - self._default_encoding = None # allow override for some file formats - self.filesize = filesize - self.album = None - self.albumartist = None - self.artist = None - self.bitrate = None - self.channels = None - self.comment = None - self.disc = None - self.disc_total = None - self.duration = None - self.extra = {} - self.genre = None - self.samplerate = None - self.bitdepth = None - self.title = None - self.track = None - self.track_total = None - self.year = None + _file_extension_mapping: Optional[Dict[Tuple[bytes, ...], Type["TinyTag"]]] = None + _magic_bytes_mapping: Optional[Dict[bytes, Type["TinyTag"]]] = None + + def __init__(self) -> None: + self.album: Optional[str] = None + self.albumartist: Optional[str] = None + self.artist: Optional[str] = None + self.bitrate: Optional[float] = None + self.channels: Optional[int] = None + self.comment: Optional[str] = None + self.disc: Optional[int] = None + self.disc_total: Optional[int] = None + self.duration: Optional[float] = None + self.extra: Dict[str, Union[bytes, str, int, float]] = {} + self.genre: Optional[str] = None + self.samplerate: Optional[int] = None + self.bitdepth: Optional[int] = None + self.title: Optional[str] = None + self.track: Optional[int] = None + self.track_total: Optional[int] = None + self.year: Optional[str] = None + self.filesize = 0 + self._filehandler: Optional[BinaryIO] = None + self._filename: Optional[Union[bytes, str, 'os.PathLike[Any]']] = None # for debugging + self._default_encoding: Optional[str] = None # allow override for some file formats self._parse_tags = True self._parse_duration = True self._load_image = False - self._image_data = None - self._ignore_errors = ignore_errors + self._image_data: Optional[bytes] = None self._tags_parsed = False + self._ignore_errors = False @classmethod - def get(cls, filename=None, tags=True, duration=True, image=False, - ignore_errors=False, encoding=None, file_obj=None): - should_open_file = file_obj is None - if should_open_file: - file_obj = open(filename, 'rb') # pylint: disable=consider-using-with + def get( + cls, + filename: Optional[Union[bytes, str, 'os.PathLike[Any]']] = None, + tags: bool = True, + duration: bool = True, + image: bool = False, + ignore_errors: bool = False, + encoding: Optional[str] = None, + file_obj: Optional[BinaryIO] = None + ) -> "TinyTag": + should_close_file = file_obj is None + if filename and file_obj is None: + file_obj = open(filename, 'rb') # pylint: disable=consider-using-with # type: ignore + if file_obj is None: + raise TinyTagException('Either filename or file_obj argument is required') try: file_obj.seek(0, os.SEEK_END) filesize = file_obj.tell() file_obj.seek(0) - if filesize <= 0: - return TinyTag(None, filesize) parser_class = cls._get_parser_class(filename, file_obj) - tag = parser_class(file_obj, filesize, ignore_errors=ignore_errors) + tag = parser_class() + tag._filehandler = file_obj tag._filename = filename tag._default_encoding = encoding - try: - tag._load(tags=tags, duration=duration, image=image) - except Exception as exc: - raise TinyTagException(f'Failed to parse file: {exc}') from exc + tag._ignore_errors = ignore_errors + tag.filesize = filesize + if filesize > 0: + try: + tag._load(tags=tags, duration=duration, image=image) + except Exception as exc: + raise TinyTagException(f'Failed to parse file: {exc}') from exc return tag finally: - if should_open_file: + if should_close_file: file_obj.close() - def get_image(self): + def get_image(self) -> Optional[bytes]: return self._image_data @classmethod - def is_supported(cls, filename): + def is_supported(cls, filename: Union[bytes, str, 'os.PathLike[Any]']) -> bool: return cls._get_parser_for_filename(filename) is not None - def __repr__(self): + def __repr__(self) -> str: return str(self._as_dict()) - def _as_dict(self): + def _as_dict(self) -> Dict[str, Any]: return {k: v for k, v in sorted(self.__dict__.items()) if not k.startswith('_')} @classmethod - def _get_parser_for_filename(cls, filename): + def _get_parser_for_filename( + cls, filename: Union[bytes, str, 'os.PathLike[Any]'] + ) -> Optional[Type["TinyTag"]]: if cls._file_extension_mapping is None: cls._file_extension_mapping = { (b'.mp1', b'.mp2', b'.mp3'): _ID3, @@ -141,19 +155,18 @@ def _get_parser_for_filename(cls, filename): (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): _MP4, (b'.aiff', b'.aifc', b'.aif', b'.afc'): _Aiff, } - if not isinstance(filename, bytes): # convert filename to binary - try: - filename = filename.encode('ASCII', errors='ignore') - except AttributeError: - filename = bytes(filename) # pathlib - filename = filename.lower() + filename = os.fspath(filename).lower() + if isinstance(filename, str): + filename_bytes = filename.encode('ascii') + else: + filename_bytes = filename for ext, tagclass in cls._file_extension_mapping.items(): - if filename.endswith(ext): + if filename_bytes.endswith(ext): return tagclass return None @classmethod - def _get_parser_for_file_handle(cls, fh): + def _get_parser_for_file_handle(cls, fh: BinaryIO) -> Optional[Type["TinyTag"]]: # https://en.wikipedia.org/wiki/List_of_file_signatures if cls._magic_bytes_mapping is None: cls._magic_bytes_mapping = { @@ -181,7 +194,8 @@ def _get_parser_for_file_handle(cls, fh): return None @classmethod - def _get_parser_class(cls, filename=None, filehandle=None): + def _get_parser_class(cls, filename: Optional[Union[bytes, str, 'os.PathLike[Any]']] = None, + filehandle: Optional[BinaryIO] = None) -> Type["TinyTag"]: if cls != TinyTag: # if `get` is invoked on TinyTag, find parser by ext return cls # otherwise use the class on which `get` was invoked if filename: @@ -195,10 +209,12 @@ def _get_parser_class(cls, filename=None, filehandle=None): return parser_class raise TinyTagException('No tag reader found to support filetype') - def _load(self, tags, duration, image=False): + def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._parse_tags = tags self._parse_duration = duration self._load_image = image + if not self._filehandler: + raise TinyTagException('No file object set') if tags: self._parse_tag(self._filehandler) if duration: @@ -206,7 +222,7 @@ def _load(self, tags, duration, image=False): self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname, value): + def _set_field(self, fieldname: str, value: Union[float, int, bytes, str]) -> None: """convenience function to set fields of the tinytag by name""" write_dest = self.__dict__ # write into the TinyTag by default is_str = isinstance(value, str) @@ -221,16 +237,16 @@ def _set_field(self, fieldname, value): # Combine same field with a null character value = old_value + '\x00' + value if DEBUG: - print(f'Setting field "{fieldname}" to "{value}"') + print(f'Setting field "{fieldname}" to "{value!r}"') write_dest[fieldname] = value - def _determine_duration(self, fh): - raise NotImplementedError() + def _determine_duration(self, fh: BinaryIO) -> None: + raise NotImplementedError - def _parse_tag(self, fh): - raise NotImplementedError() + def _parse_tag(self, fh: BinaryIO) -> None: + raise NotImplementedError - def _update(self, other): + def _update(self, other: "TinyTag") -> None: # update the values of this tag with the values from another tag for key in ('track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', @@ -244,16 +260,17 @@ def _update(self, other): self._set_field("extra." + key, value) @staticmethod - def _bytes_to_int_le(b): + def _bytes_to_int_le(b: bytes) -> int: fmt = {1: ' int: return reduce(lambda accu, elem: (accu << 8) + elem, b, 0) @staticmethod - def _unpad(s): + def _unpad(s: str) -> str: # strings in mp3 and asf *may* be terminated with a zero byte at the end return s.strip('\x00') @@ -262,51 +279,59 @@ class _MP4(TinyTag): # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html - @classmethod - def _unpack_integer(cls, value, signed=True): - value_length = len(value) - if value_length == 1: - return struct.unpack('>b' if signed else '>B', value)[0] - if value_length == 2: - return struct.unpack('>h' if signed else '>H', value)[0] - if value_length == 4: - return struct.unpack('>i' if signed else '>I', value)[0] - if value_length == 8: - return struct.unpack('>q' if signed else '>Q', value)[0] - return None - class _Parser: - # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 - ATOM_DECODER_BY_TYPE = { - # 0: 'reserved' - 1: lambda x: x.decode('utf-8', 'replace'), # UTF-8 - 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 - 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS - # 16: duration in millis - 13: lambda x: x, # JPEG - 14: lambda x: x, # PNG - # pylint: disable=unnecessary-lambda - 21: lambda x: _MP4._unpack_integer(x), # BE Signed int - 22: lambda x: _MP4._unpack_integer(x, signed=False), # BE Unsigned int - 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 - 24: lambda x: struct.unpack('>d', x)[0], # BE Float64 - # 27: lambda x: x, # BMP - # 28: lambda x: x, # QuickTime Metadata atom - 65: lambda x: struct.unpack('b', x)[0], # 8-bit Signed int - 66: lambda x: struct.unpack('>h', x)[0], # BE 16-bit Signed int - 67: lambda x: struct.unpack('>i', x)[0], # BE 32-bit Signed int - 74: lambda x: struct.unpack('>q', x)[0], # BE 64-bit Signed int - 75: lambda x: struct.unpack('B', x)[0], # 8-bit Unsigned int - 76: lambda x: struct.unpack('>H', x)[0], # BE 16-bit Unsigned int - 77: lambda x: struct.unpack('>I', x)[0], # BE 32-bit Unsigned int - 78: lambda x: struct.unpack('>Q', x)[0], # BE 64-bit Unsigned int - } + atom_decoder_by_type: Optional[Dict[int, Callable[[bytes], Union[int, str, bytes]]]] = None + + @classmethod + def _unpack_integer(cls, value: bytes, signed: bool = True) -> int: + value_length = len(value) + result = -1 + if value_length == 1: + result = struct.unpack('>b' if signed else '>B', value)[0] + elif value_length == 2: + result = struct.unpack('>h' if signed else '>H', value)[0] + elif value_length == 4: + result = struct.unpack('>i' if signed else '>I', value)[0] + elif value_length == 8: + result = struct.unpack('>q' if signed else '>Q', value)[0] + return result @classmethod - def _make_data_atom_parser(cls, fieldname): - def _parse_data_atom(data_atom): + def _unpack_integer_unsigned(cls, value: bytes) -> int: + return cls._unpack_integer(value, signed=False) + + @classmethod + def _make_data_atom_parser( + cls, fieldname: str + ) -> Callable[[bytes], Dict[str, Union[int, str, bytes]]]: + def _parse_data_atom(data_atom: bytes) -> Dict[str, Union[int, str, bytes]]: data_type = struct.unpack('>I', data_atom[:4])[0] - conversion = cls.ATOM_DECODER_BY_TYPE.get(data_type) + if cls.atom_decoder_by_type is None: + # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 + cls.atom_decoder_by_type = { + # 0: 'reserved' + 1: lambda x: x.decode('utf-8', 'replace'), # UTF-8 + 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 + 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS + # 16: duration in millis + 13: lambda x: x, # JPEG + 14: lambda x: x, # PNG + 21: cls._unpack_integer, # BE Signed int + 22: cls._unpack_integer_unsigned, # BE Unsigned int + # 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 + # 24: lambda x: struct.unpack('>d', x)[0], # BE Float64 + # 27: lambda x: x, # BMP + # 28: lambda x: x, # QuickTime Metadata atom + 65: cls._unpack_integer, # 8-bit Signed int + 66: cls._unpack_integer, # BE 16-bit Signed int + 67: cls._unpack_integer, # BE 32-bit Signed int + 74: cls._unpack_integer, # BE 64-bit Signed int + 75: cls._unpack_integer_unsigned, # 8-bit Unsigned int + 76: cls._unpack_integer_unsigned, # BE 16-bit Unsigned int + 77: cls._unpack_integer_unsigned, # BE 32-bit Unsigned int + 78: cls._unpack_integer_unsigned, # BE 64-bit Unsigned int + } + conversion = cls.atom_decoder_by_type.get(data_type) if conversion is None: if DEBUG: print(f'Cannot convert data type: {data_type}', file=stderr) @@ -316,8 +341,10 @@ def _parse_data_atom(data_atom): return _parse_data_atom @classmethod - def _make_number_parser(cls, fieldname1, fieldname2): - def _(data_atom): + def _make_number_parser( + cls, fieldname1: str, fieldname2: str + ) -> Callable[[bytes], Dict[str, int]]: + def _(data_atom: bytes) -> Dict[str, int]: number_data = data_atom[8:14] numbers = struct.unpack('>HHH', number_data) # for some reason the first number is always irrelevant. @@ -325,7 +352,7 @@ def _(data_atom): return _ @classmethod - def _parse_id3v1_genre(cls, data_atom): + def _parse_id3v1_genre(cls, data_atom: bytes) -> Dict[str, int]: # dunno why the genre is offset by -1 but that's how mutagen does it idx = struct.unpack('>H', data_atom[8:])[0] - 1 result = {} @@ -334,13 +361,13 @@ def _parse_id3v1_genre(cls, data_atom): return result @classmethod - def _read_extended_descriptor(cls, esds_atom): + def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: for _i in range(4): if esds_atom.read(1) != b'\x80': break @classmethod - def _parse_custom_field(cls, data): + def _parse_custom_field(cls, data: bytes) -> Dict[str, Union[int, str, bytes]]: fh = io.BytesIO(data) header_size = 8 field_name = None @@ -357,13 +384,13 @@ def _parse_custom_field(cls, data): else: fh.seek(atom_size, os.SEEK_CUR) atom_header = fh.read(header_size) # read next atom - if len(data_atom) < 8: + if len(data_atom) < 8 or field_name is None: return {} parser = cls._make_data_atom_parser(field_name) return parser(data_atom) @classmethod - def _parse_audio_sample_entry_mp4a(cls, data): + def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> Dict[str, int]: # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt @@ -391,7 +418,7 @@ def _parse_audio_sample_entry_mp4a(cls, data): return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} @classmethod - def _parse_audio_sample_entry_alac(cls, data): + def _parse_audio_sample_entry_alac(cls, data: bytes) -> Dict[str, int]: # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt alac_atom_size = struct.unpack('>I', data[28:32])[0] alac_atom = io.BytesIO(data[36:36 + alac_atom_size]) @@ -405,7 +432,7 @@ def _parse_audio_sample_entry_alac(cls, data): return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} @classmethod - def _parse_mvhd(cls, data): + def _parse_mvhd(cls, data: bytes) -> Dict[str, float]: # http://stackoverflow.com/a/3639993/1191373 walker = io.BytesIO(data) version = struct.unpack('b', walker.read(1))[0] @@ -465,13 +492,15 @@ def _parse_mvhd(cls, data): VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: self._traverse_atoms(fh, path=self.AUDIO_DATA_TREE) - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: self._traverse_atoms(fh, path=self.META_DATA_TREE) - def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): + def _traverse_atoms(self, fh: BinaryIO, path: Dict[bytes, Any], + stop_pos: Optional[int] = None, + curr_path: Optional[List[bytes]] = None) -> None: header_size = 8 atom_header = fh.read(header_size) while len(atom_header) == header_size: @@ -484,7 +513,7 @@ def _traverse_atoms(self, fh, path, stop_pos=None, curr_path=None): continue if DEBUG: print((f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' - f'atom: {atom_type} len: {atom_size + header_size}')) + f'atom: {atom_type!r} len: {atom_size + header_size}')) if atom_type in self.VERSIONED_ATOMS: # jump atom version for now fh.seek(4, os.SEEK_CUR) if atom_type in self.FLAGGED_ATOMS: # jump atom flags for now @@ -539,7 +568,7 @@ class _ID3(TinyTag): IMAGE_FRAME_IDS = {'APIC', 'PIC'} CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} DISALLOWED_FRAME_IDS = {'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d'} - _MAX_ESTIMATION_SEC = 30 + _MAX_ESTIMATION_SEC = 30.0 _CBR_DETECTION_FRAME_COUNT = 5 _USE_XING_HEADER = True # much faster, but can be deactivated for testing @@ -588,10 +617,10 @@ class _ID3(TinyTag): 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', ] - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) # save position after the ID3 tag for duration measurement speedup - self._bytepos_after_id3v2 = None + self._bytepos_after_id3v2 = -1 # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm @@ -622,24 +651,24 @@ def __init__(self, *args, **kwargs): ] @staticmethod - def _parse_xing_header(fh): + def _parse_xing_header(fh: BinaryIO) -> Tuple[int, int]: # see: http://www.mp3-tech.org/programmer/sources/vbrheadersdk.zip fh.seek(4, os.SEEK_CUR) # read over Xing header header_flags = struct.unpack('>i', fh.read(4))[0] - frames = byte_count = toc = vbr_scale = None + frames = byte_count = 0 if header_flags & 1: # FRAMES FLAG frames = struct.unpack('>i', fh.read(4))[0] if header_flags & 2: # BYTES FLAG byte_count = struct.unpack('>i', fh.read(4))[0] if header_flags & 4: # TOC FLAG - toc = [struct.unpack('>i', fh.read(4))[0] for _ in range(25)] # 100 bytes + fh.seek(100, os.SEEK_CUR) if header_flags & 8: # VBR SCALE FLAG - vbr_scale = struct.unpack('>i', fh.read(4))[0] - return frames, byte_count, toc, vbr_scale + fh.seek(4, os.SEEK_CUR) + return frames, byte_count - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: # if tag reading was disabled, find start position of audio data - if self._bytepos_after_id3v2 is None: + if self._bytepos_after_id3v2 == -1: self._parse_id3v2_header(fh) max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3.samples_per_frame @@ -677,7 +706,7 @@ def _determine_duration(self, fh): continue self.channels = self.channels_per_channel_mode[channel_mode] frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] - self.samplerate = self.samplerates[mpeg_id][sr_id] + self.samplerate = samplerate = self.samplerates[mpeg_id][sr_id] # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate @@ -685,14 +714,14 @@ def _determine_duration(self, fh): xing_header_offset = b.find(b'Xing') if xing_header_offset != -1: walker.seek(xing_header_offset, os.SEEK_CUR) - xframes, byte_count, _toc, _vbr_scale = self._parse_xing_header(walker) - if xframes and xframes != 0 and byte_count: + xframes, byte_count = self._parse_xing_header(walker) + if xframes > 0 and byte_count > 0: # MPEG-2 Audio Layer III uses 576 samples per frame samples_per_frame = 576 if mpeg_id <= 2 else self.samples_per_frame - self.duration = xframes * samples_per_frame / self.samplerate - # self.duration = (xframes * self.samples_per_frame / self.samplerate + self.duration = duration = xframes * samples_per_frame / samplerate + # self.duration = (xframes * self.samples_per_frame / samplerate # / self.channels) # noqa - self.bitrate = byte_count * 8 / self.duration / 1000 + self.bitrate = byte_count * 8 / duration / 1000 return continue @@ -704,7 +733,7 @@ def _determine_duration(self, fh): last_bitrates.append(frame_bitrate) walker.seek(4, os.SEEK_CUR) # jump over peeked bytes - frame_length = (144000 * frame_bitrate) // self.samplerate + padding + frame_length = (144000 * frame_bitrate) // samplerate + padding frame_size_accu += frame_length # if bitrate does not change over time its probably CBR is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(set(last_bitrates)) == 1) @@ -714,7 +743,7 @@ def _determine_duration(self, fh): audio_stream_size = fh.tell() - audio_offset est_frame_count = audio_stream_size / (frame_size_accu / frames) samples = est_frame_count * self.samples_per_frame - self.duration = samples / self.samplerate + self.duration = samples / samplerate self.bitrate = bitrate_accu / frames return @@ -723,14 +752,15 @@ def _determine_duration(self, fh): if self.samplerate: self.duration = frames * self.samples_per_frame / self.samplerate - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: self._parse_id3v2(fh) if self.filesize > 128: fh.seek(-128, os.SEEK_END) # try parsing id3v1 in last 128 bytes self._parse_id3v1(fh) - def _parse_id3v2_header(self, fh): - size, extended, major = 0, None, None + def _parse_id3v2_header(self, fh: BinaryIO) -> Tuple[int, bool, int]: + size = major = 0 + extended = False # for info on the specs, see: http://id3.org/Developer%20Information header = struct.unpack('3sBBB4B', fh.read(10)) tag = header[0].decode('ISO-8859-1') @@ -747,7 +777,7 @@ def _parse_id3v2_header(self, fh): self._bytepos_after_id3v2 = size return size, extended, major - def _parse_id3v2(self, fh): + def _parse_id3v2(self, fh: BinaryIO) -> None: size, extended, major = self._parse_id3v2_header(fh) if size: end_pos = fh.tell() + size @@ -763,11 +793,11 @@ def _parse_id3v2(self, fh): parsed_size += frame_size fh.seek(end_pos, os.SEEK_SET) - def _parse_id3v1(self, fh): + def _parse_id3v1(self, fh: BinaryIO) -> None: if fh.read(3) != b'TAG': # check if this is an ID3 v1 tag return - def asciidecode(x): + def asciidecode(x: bytes) -> str: return self._unpad(x.decode(self._default_encoding or 'latin1')) # Only set fields that were not set by ID3v2 tags, as ID3v1 # tags are more likely to be outdated or have encoding issues @@ -792,7 +822,7 @@ def asciidecode(x): if genre_id < len(self.ID3V1_GENRES): self._set_field('genre', self.ID3V1_GENRES[genre_id]) - def __parse_custom_field(self, content): + def __parse_custom_field(self, content: str) -> bool: custom_field_name, separator, value = content.partition('\x00') if custom_field_name and separator: self._set_field('extra.' + custom_field_name.lower(), value.lstrip('\ufeff')) @@ -800,13 +830,13 @@ def __parse_custom_field(self, content): return False @staticmethod - def _index_utf16(s, search): + def _index_utf16(s: bytes, search: bytes) -> int: for i in range(0, len(s), len(search)): if s[i:i + len(search)] == search: return i return -1 - def _parse_frame(self, fh, id3version=False): + def _parse_frame(self, fh: BinaryIO, id3version: Optional[int] = None) -> int: # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 frame_header_size = 6 if id3version == 2 else 10 frame_size_bytes = 3 if id3version == 2 else 4 @@ -839,7 +869,8 @@ def _parse_frame(self, fh, id3version=False): if '/' in value: value, total = value.split('/')[:2] self._set_field(f'{fieldname}_total', int(total)) - value = int(value) + self._set_field(fieldname, int(value)) + should_set_field = False elif fieldname == 'genre': genre_id = 255 # funky: id3v1 genre hidden in a id3v2 field @@ -880,7 +911,7 @@ def _parse_frame(self, fh, id3version=False): return frame_size return 0 - def _decode_string(self, bytestr, language=False): + def _decode_string(self, bytestr: bytes, language: bool = False) -> str: default_encoding = 'ISO-8859-1' if self._default_encoding: default_encoding = self._default_encoding @@ -921,17 +952,17 @@ def _decode_string(self, bytestr, language=False): return self._unpad(bytestr.decode(encoding, errors)) @staticmethod - def _calc_size(bytestr, bits_per_byte): + def _calc_size(bytestr: Tuple[int, ...], bits_per_byte: int) -> int: # length of some mp3 header fields is described by 7 or 8-bit-bytes return reduce(lambda accu, elem: (accu << bits_per_byte) + elem, bytestr, 0) class _Ogg(TinyTag): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._max_samplenum = 0 # maximum sample position ever read - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: max_page_size = 65536 # https://xiph.org/ogg/doc/libogg/ogg_page.html if not self._tags_parsed: self._parse_tag(fh) # determine sample rate @@ -955,7 +986,7 @@ def _determine_duration(self, fh): if idx != -1: fh.seek(file_offset + idx) - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): @@ -985,7 +1016,9 @@ def _parse_tag(self, fh): elif packet[0:5] == b'\x7fFLAC': # https://xiph.org/flac/ogg_mapping.html walker.seek(9, os.SEEK_CUR) # jump over header name, version and number of headers - flactag = _Flac(walker, self.filesize) + flactag = _Flac() + flactag._filehandler = walker + flactag.filesize = self.filesize flactag._load(tags=self._parse_tags, duration=self._parse_duration, image=self._load_image) self._update(flactag) @@ -1018,7 +1051,7 @@ def _parse_tag(self, fh): break self._tags_parsed = True - def _parse_vorbis_comment(self, fh, contains_vendor=True): + def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> None: # for the spec, see: http://xiph.org/vorbis/doc/v-comment.html # discnumber tag based on: https://en.wikipedia.org/wiki/Vorbis_comment # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/Vorbis.html @@ -1072,21 +1105,25 @@ def _parse_vorbis_comment(self, fh, contains_vendor=True): print('Found Vorbis Comment', key, value[:64]) fieldname = comment_type_to_attr_mapping.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' + should_set_field = True try: if fieldname in {'track', 'disc'}: if '/' in value: value, total = value.split('/')[:2] self._set_field(f'{fieldname}_total', int(total)) - value = int(value) + self._set_field(fieldname, int(value)) + should_set_field = False elif fieldname in {'track_total', 'disc_total'}: - value = int(value) + self._set_field(fieldname, int(value)) + should_set_field = False except ValueError as exc: if DEBUG: print(f'Failed to read {fieldname}: {exc}', file=stderr) else: - self._set_field(fieldname, value) + if should_set_field: + self._set_field(fieldname, value) - def _parse_pages(self, fh): + def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: # for the spec, see: https://wiki.xiph.org/Ogg previous_page = b'' # contains data from previous (continuing) pages header_data = fh.read(27) # read ogg page header @@ -1139,11 +1176,11 @@ class _Wave(TinyTag): b'YEAR': 'year', } - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: self._parse_tag(fh) - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: # see: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html # and: https://en.wikipedia.org/wiki/WAV riff, _size, fformat = struct.unpack('4sI4s', fh.read(12)) @@ -1155,18 +1192,22 @@ def _parse_tag(self, fh): subchunkid, subchunksize = struct.unpack('4sI', chunk_header) subchunksize += subchunksize % 2 # IFF chunks are padded to an even number of bytes if subchunkid == b'fmt ': - _, self.channels, self.samplerate = struct.unpack('HHI', fh.read(8)) - _, _, self.bitdepth = struct.unpack(' 0: fh.seek(remaining_size, 1) # skip remaining data in chunk elif subchunkid == b'data': - self.duration = subchunksize / self.channels / self.samplerate / (self.bitdepth / 8) + if (self.channels is not None and self.samplerate is not None + and self.bitdepth is not None): + self.duration = ( + subchunksize / self.channels / self.samplerate / (self.bitdepth / 8)) fh.seek(subchunksize, 1) elif subchunkid == b'LIST' and self._parse_tags: is_info = fh.read(4) # check INFO header @@ -1184,15 +1225,18 @@ def _parse_tag(self, fh): value = data.decode('utf-8') try: if fieldname == 'track': - value = int(value) + self._set_field(fieldname, int(value)) + value = '' except ValueError as exc: if DEBUG: print(f'Failed to read {fieldname}: {exc}', file=stderr) else: - self._set_field(fieldname, value) + if value: + self._set_field(fieldname, value) field = sub_fh.read(4) elif subchunkid in {b'id3 ', b'ID3 '} and self._parse_tags: - id3 = _ID3(fh, 0) + id3 = _ID3() + id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) else: # some other chunk, just skip the data @@ -1210,16 +1254,17 @@ class _Flac(TinyTag): METADATA_CUESHEET = 5 METADATA_PICTURE = 6 - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: self._parse_tag(fh) - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: id3 = None header = fh.read(4) if header[:3] == b'ID3': # parse ID3 header if it exists fh.seek(-4, os.SEEK_CUR) - id3 = _ID3(fh, 0) + id3 = _ID3() + id3._filehandler = fh id3._parse_tags = self._parse_tags id3._load_image = self._load_image id3._parse_id3v2(fh) @@ -1238,7 +1283,7 @@ def _parse_tag(self, fh): stream_info_header = fh.read(size) if len(stream_info_header) < 34: # invalid streaminfo break - header = struct.unpack('HH3s3s8B16s', stream_info_header) + header_values = struct.unpack('HH3s3s8B16s', stream_info_header) # From the xiph documentation: # py | # ---------------------------------------------- @@ -1258,16 +1303,18 @@ def _parse_tag(self, fh): # |----- samplerate -----| |-||----| |---------~ ~----| # 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 # #---4---# #---5---# #---6---# #---7---# #--8-~ ~-12-# - self.samplerate = self._bytes_to_int(header[4:7]) >> 4 - self.channels = ((header[6] >> 1) & 0x07) + 1 - self.bitdepth = ((header[6] & 1) << 4) + ((header[7] & 0xF0) >> 4) + 1 - total_sample_bytes = [(header[7] & 0x0F)] + list(header[8:12]) + self.samplerate = self._bytes_to_int(header_values[4:7]) >> 4 + self.channels = ((header_values[6] >> 1) & 0x07) + 1 + self.bitdepth = ( + ((header_values[6] & 1) << 4) + ((header_values[7] & 0xF0) >> 4) + 1) + total_sample_bytes = ((header_values[7] & 0x0F),) + header_values[8:12] total_samples = self._bytes_to_int(total_sample_bytes) self.duration = total_samples / self.samplerate if self.duration > 0: self.bitrate = self.filesize / self.duration * 8 / 1000 elif block_type == self.METADATA_VORBIS_COMMENT and self._parse_tags: - oggtag = _Ogg(fh, 0) + oggtag = _Ogg() + oggtag._filehandler = fh oggtag._parse_vorbis_comment(fh) self._update(oggtag) elif block_type == self.METADATA_PICTURE and self._load_image: @@ -1287,7 +1334,7 @@ def _parse_tag(self, fh): self._tags_parsed = True @staticmethod - def _parse_image(fh): + def _parse_image(fh: BinaryIO) -> bytes: # https://xiph.org/flac/format.html#metadata_block_picture _pic_type, mime_len = struct.unpack('>2I', fh.read(8)) fh.read(mime_len) @@ -1310,24 +1357,14 @@ class _Wma(TinyTag): # and (japanese, but none the less helpful) # http://uguisu.skr.jp/Windows/format_asf.html - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: self._parse_tag(fh) - def _read_blocks(self, fh, blocks): - # blocks are a list(tuple('fieldname', byte_count, cast_int), ...) - decoded = {} - for block in blocks: - val = fh.read(block[1]) - if block[2]: - val = self._bytes_to_int_le(val) - decoded[block[0]] = val - return decoded - - def _decode_string(self, bytestring): + def _decode_string(self, bytestring: bytes) -> str: return self._unpad(bytestring.decode('utf-16')) - def _decode_ext_desc(self, value_type, value): + def _decode_ext_desc(self, value_type: int, value: bytes) -> Optional[Union[bytes, int, str]]: """ decode ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string return self._decode_string(value) @@ -1337,7 +1374,7 @@ def _decode_ext_desc(self, value_type, value): return self._bytes_to_int_le(value) return None - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: header = fh.read(30) # http://www.garykessler.net/library/file_sigs.html # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc521913958 @@ -1350,23 +1387,22 @@ def _parse_tag(self, fh): if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. if object_id == self.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: - len_blocks = self._read_blocks(fh, [ - ('title_length', 2, True), - ('author_length', 2, True), - ('copyright_length', 2, True), - ('description_length', 2, True), - ('rating_length', 2, True), - ]) - data_blocks = self._read_blocks(fh, [ - ('title', len_blocks['title_length'], False), - ('artist', len_blocks['author_length'], False), - ('', len_blocks['copyright_length'], True), - ('comment', len_blocks['description_length'], False), - ('', len_blocks['rating_length'], True), - ]) - for field_name, bytestring in data_blocks.items(): - if field_name: - self._set_field(field_name, self._decode_string(bytestring)) + title_length = self._bytes_to_int_le(fh.read(2)) + author_length = self._bytes_to_int_le(fh.read(2)) + copyright_length = self._bytes_to_int_le(fh.read(2)) + description_length = self._bytes_to_int_le(fh.read(2)) + rating_length = self._bytes_to_int_le(fh.read(2)) + data_blocks = { + 'title': title_length, + 'artist': author_length, + '_copyright': copyright_length, + 'comment': description_length, + '_rating': rating_length, + } + for i_field_name, length in data_blocks.items(): + bytestring = fh.read(length) + if not i_field_name.startswith('_'): + self._set_field(i_field_name, self._decode_string(bytestring)) elif object_id == self.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: mapping = { 'WM/TrackNumber': 'track', @@ -1400,58 +1436,43 @@ def _parse_tag(self, fh): field_name = 'extra.' + name.lower() field_value = self._decode_ext_desc(value_type, fh.read(value_len)) try: - if field_name in {'track', 'disc'}: + if field_name in {'track', 'disc'} and field_value is not None: field_value = int(field_value) except ValueError as exc: if DEBUG: print(f'Failed to read {field_name}: {exc}', file=stderr) else: - self._set_field(field_name, field_value) + if field_value is not None: + self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT: - blocks = self._read_blocks(fh, [ - ('file_id', 16, False), - ('file_size', 8, False), - ('creation_date', 8, True), - ('data_packets_count', 8, True), - ('play_duration', 8, True), - ('send_duration', 8, True), - ('preroll', 8, True), - ('flags', 4, False), - ('minimum_data_packet_size', 4, True), - ('maximum_data_packet_size', 4, True), - ('maximum_bitrate', 4, False), - ]) + fh.seek(40, os.SEEK_CUR) + play_duration = self._bytes_to_int_le(fh.read(8)) / 10000000 + fh.seek(8, os.SEEK_CUR) + preroll = self._bytes_to_int_le(fh.read(8)) / 1000 + fh.seek(16, os.SEEK_CUR) # According to the specification, we need to subtract the preroll from play_duration # to get the actual duration of the file - preroll = blocks.get('preroll') / 1000 - self.duration = max(blocks.get('play_duration') / 10000000 - preroll, 0.0) + self.duration = max(play_duration - preroll, 0.0) elif object_id == self.ASF_STREAM_PROPERTIES_OBJECT: - blocks = self._read_blocks(fh, [ - ('stream_type', 16, False), - ('error_correction_type', 16, False), - ('time_offset', 8, True), - ('type_specific_data_length', 4, True), - ('error_correction_data_length', 4, True), - ('flags', 2, True), - ('reserved', 4, False) - ]) + stream_type = fh.read(16) + fh.seek(24, os.SEEK_CUR) # skip irrelevant fields + type_specific_data_length = self._bytes_to_int_le(fh.read(4)) + error_correction_data_length = self._bytes_to_int_le(fh.read(4)) + fh.seek(6, os.SEEK_CUR) # skip irrelevant fields already_read = 0 - if blocks['stream_type'] == self.STREAM_TYPE_ASF_AUDIO_MEDIA: - stream_info = self._read_blocks(fh, [ - ('codec_id_format_tag', 2, True), - ('number_of_channels', 2, True), - ('samples_per_second', 4, True), - ('avg_bytes_per_second', 4, True), - ('block_alignment', 2, True), - ('bits_per_sample', 2, True), - ]) - self.samplerate = stream_info['samples_per_second'] - self.bitrate = stream_info['avg_bytes_per_second'] * 8 / 1000 - if stream_info['codec_id_format_tag'] == 355: # lossless - self.bitdepth = stream_info['bits_per_sample'] + if stream_type == self.STREAM_TYPE_ASF_AUDIO_MEDIA: + codec_id_format_tag = self._bytes_to_int_le(fh.read(2)) + _channels = self._bytes_to_int_le(fh.read(2)) + self.samplerate = self._bytes_to_int_le(fh.read(4)) + avg_bytes_per_second = self._bytes_to_int_le(fh.read(4)) + self.bitrate = avg_bytes_per_second * 8 / 1000 + fh.seek(2, os.SEEK_CUR) # skip irrelevant field + bits_per_sample = self._bytes_to_int_le(fh.read(2)) + if codec_id_format_tag == 355: # lossless + self.bitdepth = bits_per_sample already_read = 16 - fh.seek(blocks['type_specific_data_length'] - already_read, os.SEEK_CUR) - fh.seek(blocks['error_correction_data_length'], os.SEEK_CUR) + fh.seek(type_specific_data_length - already_read, os.SEEK_CUR) + fh.seek(error_correction_data_length, os.SEEK_CUR) else: fh.seek(object_size - 24, os.SEEK_CUR) # read over onknown object ids self._tags_parsed = True @@ -1499,7 +1520,7 @@ class _Aiff(TinyTag): b'(c) ': 'extra.copyright', } - def _parse_tag(self, fh): + def _parse_tag(self, fh: BinaryIO) -> None: chunk_id, _size, form = struct.unpack('>4sI4s', fh.read(12)) if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): raise TinyTagException('Invalid AIFF file') @@ -1511,17 +1532,20 @@ def _parse_tag(self, fh): value = self._unpad(fh.read(sub_chunk_size).decode('utf-8')) self._set_field(self.aiff_mapping[sub_chunk_id], value) elif sub_chunk_id == b'COMM': - self.channels, num_frames, self.bitdepth = struct.unpack('>hLh', fh.read(8)) + channels, num_frames, bitdepth = struct.unpack('>hLh', fh.read(8)) + self.channels, self.bitdepth = channels, bitdepth try: exponent, mantissa = struct.unpack('>HQ', fh.read(10)) # Extended precision - self.samplerate = int(mantissa * (2 ** (exponent - 0x3FFF - 63))) - self.duration = num_frames / self.samplerate - self.bitrate = self.samplerate * self.channels * self.bitdepth / 1000 + samplerate = int(mantissa * (2 ** (exponent - 0x3FFF - 63))) + duration = num_frames / samplerate + bitrate = samplerate * channels * bitdepth / 1000 + self.samplerate, self.duration, self.bitrate = samplerate, duration, bitrate except OverflowError: - self.samplerate = self.duration = self.bitrate = None # invalid sample rate + pass fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk elif sub_chunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: - id3 = _ID3(fh, 0) + id3 = _ID3() + id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) else: # some other chunk, just skip the data @@ -1529,6 +1553,6 @@ def _parse_tag(self, fh): chunk_header = fh.read(8) self._tags_parsed = True - def _determine_duration(self, fh): + def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: self._parse_tag(fh) From aa321e18b2821fc615ee7b842a437da84a513b38 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 08:52:07 +0200 Subject: [PATCH 151/305] Modern syntax for type hints Drops support for Python 3.6. --- .github/workflows/tests.yml | 4 +- README.md | 2 +- setup.cfg | 5 +- tinytag/__main__.py | 8 +- tinytag/tests/test_all.py | 23 +++--- tinytag/tinytag.py | 154 ++++++++++++++++++------------------ 6 files changed, 97 insertions(+), 99 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d9c9641..8a0cfaa 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,8 +8,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04, macos-latest, windows-latest] - python: ['3.6', '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + os: [ubuntu-latest, macos-latest, windows-latest] + python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/README.md b/README.md index 0afb09b..e78b522 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ tinytag is a library for reading music meta data of most common audio files in p * WMA * AIFF / AIFF-C * Pure Python, no dependencies - * Supports Python 3.6 or higher + * Supports Python 3.7 or higher * High test coverage * Just a few hundred lines of code (just include it in your project!) diff --git a/setup.cfg b/setup.cfg index 9219ad7..d87dd5a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,6 @@ keywords = classifiers = Programming Language :: Python Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -33,7 +32,7 @@ long_description = file: README.md long_description_content_type = text/markdown [options] -python_requires = >= 3.6 +python_requires = >= 3.7 include_package_data = True packages = find: install_requires = @@ -74,7 +73,7 @@ load-plugins = pylint.extensions.set_membership, pylint.extensions.typing ignore-paths = build -py-version = 3.6 +py-version = 3.7 [pylint.format] max-line-length = 100 diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 0576b06..f900ba7 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,12 +1,12 @@ # pylint: disable=missing-module-docstring,protected-access +from __future__ import annotations from os.path import splitext -from typing import Optional import json import os import sys -from tinytag.tinytag import TinyTag +from tinytag.tinytag import TinyTag, TinyTagException def _usage() -> None: @@ -27,7 +27,7 @@ def _usage() -> None: ''') -def _pop_param(name: str, _default: Optional[str]) -> Optional[str]: +def _pop_param(name: str, _default: str | None) -> str | None: if name in sys.argv: idx = sys.argv.index(name) sys.argv.pop(idx) @@ -91,7 +91,7 @@ def _run() -> int: with open(actual_save_image_path, 'wb') as file_handle: file_handle.write(image) header_printed = _print_tag(tag, formatting, header_printed) - except Exception as exc: # pylint: disable=broad-except + except (OSError, TinyTagException) as exc: sys.stderr.write(f'{filename}: {exc}\n') return 1 return 0 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 16c8f5c..25258d7 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -7,7 +7,8 @@ # pylint: disable=missing-function-docstring,missing-module-docstring,protected-access -from typing import Any, Dict, Optional, Type, Union +from __future__ import annotations +from typing import Any import io import os @@ -543,7 +544,7 @@ testfolder = os.path.join(os.path.dirname(__file__)) -def load_custom_samples() -> Dict[str, Dict[str, Any]]: +def load_custom_samples() -> dict[str, dict[str, Any]]: retval = {} custom_samples_folder = os.path.join(testfolder, 'custom_samples') pattern_field_name_type = [ @@ -577,10 +578,10 @@ def load_custom_samples() -> Dict[str, Dict[str, Any]]: testfiles.update(load_custom_samples()) -def compare_tag(results: Dict[str, Dict[str, Any]], expected: Dict[str, Dict[str, Any]], - file: str, prev_path: Optional[str] = None) -> None: - def compare_values(path: str, result_val: Union[int, float, str, Dict[str, Any]], - expected_val: Union[int, float, str, Dict[str, Any]]) -> bool: +def compare_tag(results: dict[str, dict[str, Any]], expected: dict[str, dict[str, Any]], + file: str, prev_path: str | None = None) -> None: + def compare_values(path: str, result_val: int | float | str | dict[str, Any], + expected_val: int | float | str | dict[str, Any]) -> bool: # lets not copy *all* the lyrics inside the fixture if (path == 'extra.lyrics' and isinstance(expected_val, str) and isinstance(result_val, str)): @@ -589,7 +590,7 @@ def compare_values(path: str, result_val: Union[int, float, str, Dict[str, Any]] return result_val == pytest.approx(expected_val) return result_val == expected_val - def error_fmt(value: Union[int, float, str, Dict[str, Any]]) -> str: + def error_fmt(value: int | float | str | dict[str, Any]) -> str: return f'{repr(value)} ({type(value)})' assert isinstance(results, dict) @@ -609,7 +610,7 @@ def error_fmt(value: Union[int, float, str, Dict[str, Any]]) -> str: @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_tags(testfile: str, expected: Dict[str, Dict[str, Any]]) -> None: +def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True) results = { @@ -620,7 +621,7 @@ def test_file_reading_tags(testfile: str, expected: Dict[str, Dict[str, Any]]) - @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_no_tags(testfile: str, expected: Dict[str, Dict[str, Any]]) -> None: +def test_file_reading_no_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False) @@ -710,7 +711,7 @@ def test_mp3_length_estimation() -> None: ('samples/ilbm.aiff', _Aiff), ]) @pytest.mark.xfail(raises=TinyTagException) -def test_invalid_file(path: str, cls: Type[TinyTag]) -> None: +def test_invalid_file(path: str, cls: type[TinyTag]) -> None: cls.get(os.path.join(testfolder, path)) @@ -762,7 +763,7 @@ def test_mp3_utf_8_invalid_string_can_be_ignored() -> None: ('samples/detect_mp4_m4a.x', _MP4), ('samples/detect_aiff.x', _Aiff), ]) -def test_detect_magic_headers(testfile: str, expected: Type[TinyTag]) -> None: +def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None: filename = os.path.join(testfolder, testfile) with open(filename, 'rb') as file_handle: parser = TinyTag._get_parser_class(filename, file_handle) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d36d675..a1a8fe4 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -34,9 +34,12 @@ # pylint: disable=too-many-nested-blocks,too-many-statements,too-few-public-methods +from __future__ import annotations +from collections.abc import Callable, Iterator from functools import reduce +from os import PathLike from sys import stderr -from typing import Any, BinaryIO, Callable, Dict, Iterator, List, Optional, Tuple, Type, Union +from typing import Any, BinaryIO import base64 import io @@ -59,52 +62,50 @@ class TinyTag: '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' ] - _file_extension_mapping: Optional[Dict[Tuple[bytes, ...], Type["TinyTag"]]] = None - _magic_bytes_mapping: Optional[Dict[bytes, Type["TinyTag"]]] = None + _file_extension_mapping: dict[tuple[bytes, ...], type[TinyTag]] | None = None + _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None def __init__(self) -> None: - self.album: Optional[str] = None - self.albumartist: Optional[str] = None - self.artist: Optional[str] = None - self.bitrate: Optional[float] = None - self.channels: Optional[int] = None - self.comment: Optional[str] = None - self.disc: Optional[int] = None - self.disc_total: Optional[int] = None - self.duration: Optional[float] = None - self.extra: Dict[str, Union[bytes, str, int, float]] = {} - self.genre: Optional[str] = None - self.samplerate: Optional[int] = None - self.bitdepth: Optional[int] = None - self.title: Optional[str] = None - self.track: Optional[int] = None - self.track_total: Optional[int] = None - self.year: Optional[str] = None + self.album: str | None = None + self.albumartist: str | None = None + self.artist: str | None = None + self.bitrate: float | None = None + self.channels: int | None = None + self.comment: str | None = None + self.disc: int | None = None + self.disc_total: int | None = None + self.duration: float | None = None + self.extra: dict[str, bytes | str | int | float] = {} self.filesize = 0 - self._filehandler: Optional[BinaryIO] = None - self._filename: Optional[Union[bytes, str, 'os.PathLike[Any]']] = None # for debugging - self._default_encoding: Optional[str] = None # allow override for some file formats - self._parse_tags = True + self.genre: str | None = None + self.samplerate: int | None = None + self.bitdepth: int | None = None + self.title: str | None = None + self.track: int | None = None + self.track_total: int | None = None + self.year: str | None = None + self._filehandler: BinaryIO | None = None + self._filename: bytes | str | PathLike[Any] | None = None # for debugging + self._image_data: bytes | None = None + self._default_encoding: str | None = None # allow override for some file formats + self._ignore_errors = False self._parse_duration = True + self._parse_tags = True self._load_image = False - self._image_data: Optional[bytes] = None self._tags_parsed = False - self._ignore_errors = False @classmethod - def get( - cls, - filename: Optional[Union[bytes, str, 'os.PathLike[Any]']] = None, - tags: bool = True, - duration: bool = True, - image: bool = False, - ignore_errors: bool = False, - encoding: Optional[str] = None, - file_obj: Optional[BinaryIO] = None - ) -> "TinyTag": + def get(cls, + filename: bytes | str | PathLike[Any] | None = None, + tags: bool = True, + duration: bool = True, + image: bool = False, + ignore_errors: bool = False, + encoding: str | None = None, + file_obj: BinaryIO | None = None) -> TinyTag: should_close_file = file_obj is None - if filename and file_obj is None: - file_obj = open(filename, 'rb') # pylint: disable=consider-using-with # type: ignore + if filename and should_close_file: + file_obj = open(filename, 'rb') # pylint: disable=consider-using-with if file_obj is None: raise TinyTagException('Either filename or file_obj argument is required') try: @@ -128,23 +129,22 @@ def get( if should_close_file: file_obj.close() - def get_image(self) -> Optional[bytes]: + def get_image(self) -> bytes | None: return self._image_data @classmethod - def is_supported(cls, filename: Union[bytes, str, 'os.PathLike[Any]']) -> bool: + def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: return cls._get_parser_for_filename(filename) is not None def __repr__(self) -> str: return str(self._as_dict()) - def _as_dict(self) -> Dict[str, Any]: + def _as_dict(self) -> dict[str, Any]: return {k: v for k, v in sorted(self.__dict__.items()) if not k.startswith('_')} @classmethod def _get_parser_for_filename( - cls, filename: Union[bytes, str, 'os.PathLike[Any]'] - ) -> Optional[Type["TinyTag"]]: + cls, filename: bytes | str | PathLike[Any]) -> type[TinyTag] | None: if cls._file_extension_mapping is None: cls._file_extension_mapping = { (b'.mp1', b'.mp2', b'.mp3'): _ID3, @@ -166,7 +166,7 @@ def _get_parser_for_filename( return None @classmethod - def _get_parser_for_file_handle(cls, fh: BinaryIO) -> Optional[Type["TinyTag"]]: + def _get_parser_for_file_handle(cls, fh: BinaryIO) -> type[TinyTag] | None: # https://en.wikipedia.org/wiki/List_of_file_signatures if cls._magic_bytes_mapping is None: cls._magic_bytes_mapping = { @@ -194,8 +194,8 @@ def _get_parser_for_file_handle(cls, fh: BinaryIO) -> Optional[Type["TinyTag"]]: return None @classmethod - def _get_parser_class(cls, filename: Optional[Union[bytes, str, 'os.PathLike[Any]']] = None, - filehandle: Optional[BinaryIO] = None) -> Type["TinyTag"]: + def _get_parser_class(cls, filename: bytes | str | PathLike[Any] | None = None, + filehandle: BinaryIO | None = None) -> type[TinyTag]: if cls != TinyTag: # if `get` is invoked on TinyTag, find parser by ext return cls # otherwise use the class on which `get` was invoked if filename: @@ -222,7 +222,7 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: Union[float, int, bytes, str]) -> None: + def _set_field(self, fieldname: str, value: float | int | bytes | str) -> None: """convenience function to set fields of the tinytag by name""" write_dest = self.__dict__ # write into the TinyTag by default is_str = isinstance(value, str) @@ -246,7 +246,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _parse_tag(self, fh: BinaryIO) -> None: raise NotImplementedError - def _update(self, other: "TinyTag") -> None: + def _update(self, other: TinyTag) -> None: # update the values of this tag with the values from another tag for key in ('track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', @@ -266,7 +266,7 @@ def _bytes_to_int_le(b: bytes) -> int: return result @staticmethod - def _bytes_to_int(b: Tuple[int, ...]) -> int: + def _bytes_to_int(b: tuple[int, ...]) -> int: return reduce(lambda accu, elem: (accu << 8) + elem, b, 0) @staticmethod @@ -280,7 +280,7 @@ class _MP4(TinyTag): # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html class _Parser: - atom_decoder_by_type: Optional[Dict[int, Callable[[bytes], Union[int, str, bytes]]]] = None + atom_decoder_by_type: dict[int, Callable[[bytes], int | str | bytes]] | None = None @classmethod def _unpack_integer(cls, value: bytes, signed: bool = True) -> int: @@ -302,9 +302,8 @@ def _unpack_integer_unsigned(cls, value: bytes) -> int: @classmethod def _make_data_atom_parser( - cls, fieldname: str - ) -> Callable[[bytes], Dict[str, Union[int, str, bytes]]]: - def _parse_data_atom(data_atom: bytes) -> Dict[str, Union[int, str, bytes]]: + cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes]]: + def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes]: data_type = struct.unpack('>I', data_atom[:4])[0] if cls.atom_decoder_by_type is None: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 @@ -342,9 +341,8 @@ def _parse_data_atom(data_atom: bytes) -> Dict[str, Union[int, str, bytes]]: @classmethod def _make_number_parser( - cls, fieldname1: str, fieldname2: str - ) -> Callable[[bytes], Dict[str, int]]: - def _(data_atom: bytes) -> Dict[str, int]: + cls, fieldname1: str, fieldname2: str) -> Callable[[bytes], dict[str, int]]: + def _(data_atom: bytes) -> dict[str, int]: number_data = data_atom[8:14] numbers = struct.unpack('>HHH', number_data) # for some reason the first number is always irrelevant. @@ -352,7 +350,7 @@ def _(data_atom: bytes) -> Dict[str, int]: return _ @classmethod - def _parse_id3v1_genre(cls, data_atom: bytes) -> Dict[str, int]: + def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, int]: # dunno why the genre is offset by -1 but that's how mutagen does it idx = struct.unpack('>H', data_atom[8:])[0] - 1 result = {} @@ -367,7 +365,7 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: break @classmethod - def _parse_custom_field(cls, data: bytes) -> Dict[str, Union[int, str, bytes]]: + def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes]: fh = io.BytesIO(data) header_size = 8 field_name = None @@ -390,7 +388,7 @@ def _parse_custom_field(cls, data: bytes) -> Dict[str, Union[int, str, bytes]]: return parser(data_atom) @classmethod - def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> Dict[str, int]: + def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: # this atom also contains the esds atom: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt @@ -418,7 +416,7 @@ def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> Dict[str, int]: return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} @classmethod - def _parse_audio_sample_entry_alac(cls, data: bytes) -> Dict[str, int]: + def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt alac_atom_size = struct.unpack('>I', data[28:32])[0] alac_atom = io.BytesIO(data[36:36 + alac_atom_size]) @@ -432,7 +430,7 @@ def _parse_audio_sample_entry_alac(cls, data: bytes) -> Dict[str, int]: return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} @classmethod - def _parse_mvhd(cls, data: bytes) -> Dict[str, float]: + def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # http://stackoverflow.com/a/3639993/1191373 walker = io.BytesIO(data) version = struct.unpack('b', walker.read(1))[0] @@ -498,9 +496,9 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _parse_tag(self, fh: BinaryIO) -> None: self._traverse_atoms(fh, path=self.META_DATA_TREE) - def _traverse_atoms(self, fh: BinaryIO, path: Dict[bytes, Any], - stop_pos: Optional[int] = None, - curr_path: Optional[List[bytes]] = None) -> None: + def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], + stop_pos: int | None = None, + curr_path: list[bytes] | None = None) -> None: header_size = 8 atom_header = fh.read(header_size) while len(atom_header) == header_size: @@ -512,8 +510,8 @@ def _traverse_atoms(self, fh: BinaryIO, path: Dict[bytes, Any], atom_header = fh.read(header_size) continue if DEBUG: - print((f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' - f'atom: {atom_type!r} len: {atom_size + header_size}')) + print(f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' + f'atom: {atom_type!r} len: {atom_size + header_size}') if atom_type in self.VERSIONED_ATOMS: # jump atom version for now fh.seek(4, os.SEEK_CUR) if atom_type in self.FLAGGED_ATOMS: # jump atom flags for now @@ -592,7 +590,7 @@ class _ID3(TinyTag): 'Folk', 'Folk-Rock', 'National Folk', 'Swing', 'Fast Fusion', 'Bebob', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock', 'Progressive Rock', 'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock', - 'Big Band', 'Chorus', 'Easy Listening', 'Acoustic', 'Humour', 'Speech', + 'Big Band', 'Chorus', 'Easy listening', 'Acoustic', 'Humour', 'Speech', 'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', 'Booty Bass', 'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power Ballad', 'Rhythmic Soul', 'Freestyle', @@ -617,8 +615,8 @@ class _ID3(TinyTag): 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', ] - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) + def __init__(self) -> None: + super().__init__() # save position after the ID3 tag for duration measurement speedup self._bytepos_after_id3v2 = -1 @@ -651,7 +649,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: ] @staticmethod - def _parse_xing_header(fh: BinaryIO) -> Tuple[int, int]: + def _parse_xing_header(fh: BinaryIO) -> tuple[int, int]: # see: http://www.mp3-tech.org/programmer/sources/vbrheadersdk.zip fh.seek(4, os.SEEK_CUR) # read over Xing header header_flags = struct.unpack('>i', fh.read(4))[0] @@ -758,7 +756,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: fh.seek(-128, os.SEEK_END) # try parsing id3v1 in last 128 bytes self._parse_id3v1(fh) - def _parse_id3v2_header(self, fh: BinaryIO) -> Tuple[int, bool, int]: + def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: size = major = 0 extended = False # for info on the specs, see: http://id3.org/Developer%20Information @@ -836,7 +834,7 @@ def _index_utf16(s: bytes, search: bytes) -> int: return i return -1 - def _parse_frame(self, fh: BinaryIO, id3version: Optional[int] = None) -> int: + def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 frame_header_size = 6 if id3version == 2 else 10 frame_size_bytes = 3 if id3version == 2 else 4 @@ -849,8 +847,8 @@ def _parse_frame(self, fh: BinaryIO, id3version: Optional[int] = None) -> int: frame_id = self._decode_string(frame[0]) frame_size = self._calc_size(frame[1:1 + frame_size_bytes], bits_per_byte) if DEBUG: - print((f'Found id3 Frame {frame_id} at {fh.tell()}-{fh.tell() + frame_size} ' - f'of {self.filesize}')) + print(f'Found id3 Frame {frame_id} at {fh.tell()}-{fh.tell() + frame_size} ' + f'of {self.filesize}') if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. content = fh.read(frame_size) @@ -952,14 +950,14 @@ def _decode_string(self, bytestr: bytes, language: bool = False) -> str: return self._unpad(bytestr.decode(encoding, errors)) @staticmethod - def _calc_size(bytestr: Tuple[int, ...], bits_per_byte: int) -> int: + def _calc_size(bytestr: tuple[int, ...], bits_per_byte: int) -> int: # length of some mp3 header fields is described by 7 or 8-bit-bytes return reduce(lambda accu, elem: (accu << bits_per_byte) + elem, bytestr, 0) class _Ogg(TinyTag): - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) + def __init__(self) -> None: + super().__init__() self._max_samplenum = 0 # maximum sample position ever read def _determine_duration(self, fh: BinaryIO) -> None: @@ -1364,7 +1362,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _decode_string(self, bytestring: bytes) -> str: return self._unpad(bytestring.decode('utf-16')) - def _decode_ext_desc(self, value_type: int, value: bytes) -> Optional[Union[bytes, int, str]]: + def _decode_ext_desc(self, value_type: int, value: bytes) -> bytes | int | str | None: """ decode ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string return self._decode_string(value) From 38575c890a0c8405ae24c8ce57f948af76572881 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 11:58:10 +0200 Subject: [PATCH 152/305] Add test for file reading with duration disabled --- tinytag/tests/test_all.py | 21 ++++++++++++++++++--- tinytag/tinytag.py | 13 +++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 25258d7..5b12dad 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -609,22 +609,37 @@ def error_fmt(value: int | float | str | dict[str, Any]) -> str: assert compare_values(path, result_val, expected_val), fmt_string % fmt_values +@pytest.mark.parametrize("testfile,expected", testfiles.items()) +def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: + filename = os.path.join(testfolder, testfile) + tag = TinyTag.get(filename, tags=True, duration=True) + results = { + key: val for key, val in tag._as_dict().items() if val is not None + } + compare_tag(results, expected, filename) + assert tag._image_data is None + + @pytest.mark.parametrize("testfile,expected", testfiles.items()) def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) - tag = TinyTag.get(filename, tags=True) + excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"} + tag = TinyTag.get(filename, tags=True, duration=False) results = { key: val for key, val in tag._as_dict().items() if val is not None } + expected = { + key: val for key, val in expected.items() if key not in excluded_attrs + } compare_tag(results, expected, filename) assert tag._image_data is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_no_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: +def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} - tag = TinyTag.get(filename, tags=False) + tag = TinyTag.get(filename, tags=False, duration=True) results = { key: val for key, val in tag._as_dict().items() if val is not None } diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a1a8fe4..ab45a44 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1184,12 +1184,13 @@ def _parse_tag(self, fh: BinaryIO) -> None: riff, _size, fformat = struct.unpack('4sI4s', fh.read(12)) if riff != b'RIFF' or fformat != b'WAVE': raise TinyTagException('Invalid WAV file') - self.bitdepth = 16 # assume 16bit depth (CD quality) + if self._parse_duration: + self.bitdepth = 16 # assume 16bit depth (CD quality) chunk_header = fh.read(8) while len(chunk_header) == 8: subchunkid, subchunksize = struct.unpack('4sI', chunk_header) subchunksize += subchunksize % 2 # IFF chunks are padded to an even number of bytes - if subchunkid == b'fmt ': + if subchunkid == b'fmt ' and self._parse_duration: _, channels, samplerate = struct.unpack('HHI', fh.read(8)) _, _, bitdepth = struct.unpack(' None: remaining_size = subchunksize - 16 if remaining_size > 0: fh.seek(remaining_size, 1) # skip remaining data in chunk - elif subchunkid == b'data': + elif subchunkid == b'data' and self._parse_duration: if (self.channels is not None and self.samplerate is not None and self.bitdepth is not None): self.duration = ( @@ -1442,7 +1443,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: else: if field_value is not None: self._set_field(field_name, field_value) - elif object_id == self.ASF_FILE_PROPERTY_OBJECT: + elif object_id == self.ASF_FILE_PROPERTY_OBJECT and self._parse_duration: fh.seek(40, os.SEEK_CUR) play_duration = self._bytes_to_int_le(fh.read(8)) / 10000000 fh.seek(8, os.SEEK_CUR) @@ -1451,7 +1452,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: # According to the specification, we need to subtract the preroll from play_duration # to get the actual duration of the file self.duration = max(play_duration - preroll, 0.0) - elif object_id == self.ASF_STREAM_PROPERTIES_OBJECT: + elif object_id == self.ASF_STREAM_PROPERTIES_OBJECT and self._parse_duration: stream_type = fh.read(16) fh.seek(24, os.SEEK_CUR) # skip irrelevant fields type_specific_data_length = self._bytes_to_int_le(fh.read(4)) @@ -1529,7 +1530,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: if sub_chunk_id in self.aiff_mapping and self._parse_tags: value = self._unpad(fh.read(sub_chunk_size).decode('utf-8')) self._set_field(self.aiff_mapping[sub_chunk_id], value) - elif sub_chunk_id == b'COMM': + elif sub_chunk_id == b'COMM' and self._parse_duration: channels, num_frames, bitdepth = struct.unpack('>hLh', fh.read(8)) self.channels, self.bitdepth = channels, bitdepth try: From dc4e01aff35c372f70f339e06f5748aa717a76ba Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 13:03:54 +0200 Subject: [PATCH 153/305] Avoid try except for integer value handling --- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 97 ++++++++++++++++----------------------- 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5b12dad..0eabec5 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -28,7 +28,7 @@ {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, - 'filesize': 8192, 'genre': '(3)Dance', + 'filesize': 8192, 'genre': 'Dance', 'comment': 'Ripped by THSLIVE', 'bitrate': 125.33333333333333}), ('samples/cbr.mp3', {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.48866995073891617, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ab45a44..5a7b7a3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -859,32 +859,32 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: return frame_size language = fieldname in {'comment', 'extra.lyrics'} value = self._decode_string(content, language) - try: - if fieldname == "comment": - # check if comment is a key-value pair (used by iTunes) - should_set_field = not self.__parse_custom_field(value) - elif fieldname in {'track', 'disc'}: - if '/' in value: - value, total = value.split('/')[:2] + if fieldname == "comment": + # check if comment is a key-value pair (used by iTunes) + should_set_field = not self.__parse_custom_field(value) + elif fieldname in {'track', 'disc'}: + if '/' in value: + value, total = value.split('/')[:2] + if total.isdecimal(): self._set_field(f'{fieldname}_total', int(total)) + if value.isdecimal(): self._set_field(fieldname, int(value)) - should_set_field = False - elif fieldname == 'genre': - genre_id = 255 - # funky: id3v1 genre hidden in a id3v2 field - if value.isdigit(): - genre_id = int(value) - # funkier: the TCO may contain genres in parens, e.g. '(13)' - elif value[:1] == '(' and value[-1:] == ')' and value[1:-1].isdigit(): - genre_id = int(value[1:-1]) - if 0 <= genre_id < len(_ID3.ID3V1_GENRES): - value = _ID3.ID3V1_GENRES[genre_id] - except ValueError as exc: - if DEBUG: - print(f'Failed to read {fieldname}: {exc}', file=stderr) - else: - if should_set_field: - self._set_field(fieldname, value) + should_set_field = False + elif fieldname == 'genre': + genre_id = 255 + # funky: id3v1 genre hidden in a id3v2 field + if value.isdecimal(): + genre_id = int(value) + # funkier: the TCO may contain genres in parens, e.g. '(13)' + elif value[:1] == '(': + end_pos = value.find(')') + parens_text = value[1:end_pos] + if end_pos > 0 and parens_text.isdecimal(): + genre_id = int(parens_text) + if 0 <= genre_id < len(_ID3.ID3V1_GENRES): + value = _ID3.ID3V1_GENRES[genre_id] + if should_set_field: + self._set_field(fieldname, value) elif frame_id in self.CUSTOM_FRAME_IDS: # custom fields if self._parse_tags: @@ -1086,10 +1086,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N elements = struct.unpack('I', fh.read(4))[0] for _i in range(elements): length = struct.unpack('I', fh.read(4))[0] - try: - keyvalpair = fh.read(length).decode('UTF-8') - except UnicodeDecodeError: - continue + keyvalpair = fh.read(length).decode('utf-8', 'ignore') if '=' in keyvalpair: key, value = keyvalpair.split('=', 1) key_lowercase = key.lower() @@ -1103,23 +1100,15 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N print('Found Vorbis Comment', key, value[:64]) fieldname = comment_type_to_attr_mapping.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' - should_set_field = True - try: - if fieldname in {'track', 'disc'}: - if '/' in value: - value, total = value.split('/')[:2] + if fieldname in {'track', 'disc', 'track_total', 'disc_total'}: + if fieldname in {'track', 'disc'} and '/' in value: + value, total = value.split('/')[:2] + if total.isdecimal(): self._set_field(f'{fieldname}_total', int(total)) + if value.isdecimal(): self._set_field(fieldname, int(value)) - should_set_field = False - elif fieldname in {'track_total', 'disc_total'}: - self._set_field(fieldname, int(value)) - should_set_field = False - except ValueError as exc: - if DEBUG: - print(f'Failed to read {fieldname}: {exc}', file=stderr) else: - if should_set_field: - self._set_field(fieldname, value) + self._set_field(fieldname, value) def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: # for the spec, see: https://wiki.xiph.org/Ogg @@ -1222,16 +1211,11 @@ def _parse_tag(self, fh: BinaryIO) -> None: fieldname = self.riff_mapping.get(field) if fieldname: value = data.decode('utf-8') - try: - if fieldname == 'track': + if fieldname == 'track': + if value.isdecimal(): self._set_field(fieldname, int(value)) - value = '' - except ValueError as exc: - if DEBUG: - print(f'Failed to read {fieldname}: {exc}', file=stderr) else: - if value: - self._set_field(fieldname, value) + self._set_field(fieldname, value) field = sub_fh.read(4) elif subchunkid in {b'id3 ', b'ID3 '} and self._parse_tags: id3 = _ID3() @@ -1434,14 +1418,11 @@ def _parse_tag(self, fh: BinaryIO) -> None: name = name[3:] field_name = 'extra.' + name.lower() field_value = self._decode_ext_desc(value_type, fh.read(value_len)) - try: - if field_name in {'track', 'disc'} and field_value is not None: - field_value = int(field_value) - except ValueError as exc: - if DEBUG: - print(f'Failed to read {field_name}: {exc}', file=stderr) - else: - if field_value is not None: + if field_value is not None: + if field_name in {'track', 'disc'}: + if isinstance(field_value, int) or field_value.isdecimal(): + self._set_field(field_name, int(field_value)) + else: self._set_field(field_name, field_value) elif object_id == self.ASF_FILE_PROPERTY_OBJECT and self._parse_duration: fh.seek(40, os.SEEK_CUR) From 34afe763bc78687c3d566b5380ee63cfebc1c977 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 13:16:36 +0200 Subject: [PATCH 154/305] Don't allow bytes in tag attributes --- tinytag/tinytag.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5a7b7a3..75cb86b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -75,7 +75,7 @@ def __init__(self) -> None: self.disc: int | None = None self.disc_total: int | None = None self.duration: float | None = None - self.extra: dict[str, bytes | str | int | float] = {} + self.extra: dict[str, str | int | float] = {} self.filesize = 0 self.genre: str | None = None self.samplerate: int | None = None @@ -222,7 +222,7 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: float | int | bytes | str) -> None: + def _set_field(self, fieldname: str, value: str | int | float) -> None: """convenience function to set fields of the tinytag by name""" write_dest = self.__dict__ # write into the TinyTag by default is_str = isinstance(value, str) @@ -1347,12 +1347,10 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _decode_string(self, bytestring: bytes) -> str: return self._unpad(bytestring.decode('utf-16')) - def _decode_ext_desc(self, value_type: int, value: bytes) -> bytes | int | str | None: + def _decode_ext_desc(self, value_type: int, value: bytes) -> int | str | None: """ decode ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string return self._decode_string(value) - if value_type == 1: # BYTE array - return value if 1 < value_type < 6: # DWORD / QWORD / WORD return self._bytes_to_int_le(value) return None From 1ae41aeff2ecd304b2c19d5fcb08744bdf318aa1 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 14:44:14 +0200 Subject: [PATCH 155/305] Mark class constants as private --- tinytag/tinytag.py | 248 +++++++++++++++++++++++---------------------- 1 file changed, 125 insertions(+), 123 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 75cb86b..3ded541 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -55,13 +55,13 @@ class TinyTagException(Exception): class TinyTag: - SUPPORTED_FILE_EXTENSIONS = [ + SUPPORTED_FILE_EXTENSIONS = ( '.mp1', '.mp2', '.mp3', '.oga', '.ogg', '.opus', '.spx', '.wav', '.flac', '.wma', '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' - ] + ) _file_extension_mapping: dict[tuple[bytes, ...], type[TinyTag]] | None = None _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None @@ -350,12 +350,12 @@ def _(data_atom: bytes) -> dict[str, int]: return _ @classmethod - def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, int]: + def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: # dunno why the genre is offset by -1 but that's how mutagen does it idx = struct.unpack('>H', data_atom[8:])[0] - 1 result = {} - if idx < len(_ID3.ID3V1_GENRES): - result['genre'] = _ID3.ID3V1_GENRES[idx] + if idx < len(_ID3._ID3V1_GENRES): + result['genre'] = _ID3._ID3V1_GENRES[idx] return result @classmethod @@ -448,7 +448,7 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # The parser tree: Each key is an atom name which is traversed if existing. # Leaves of the parser tree are callables which receive the atom data. # callables return {fieldname: value} which is updates the TinyTag. - META_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { + _META_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { # see: http://atomicparsley.sourceforge.net/mpeg-4files.html # and: https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 b'\xa9ART': {b'data': _Parser._make_data_atom_parser('artist')}, @@ -477,7 +477,7 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: }}}}} # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html - AUDIO_DATA_TREE = { + _AUDIO_DATA_TREE = { b'moov': { b'mvhd': _Parser._parse_mvhd, b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": { @@ -487,14 +487,14 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: } } - VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header - FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header + _VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header + _FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header def _determine_duration(self, fh: BinaryIO) -> None: - self._traverse_atoms(fh, path=self.AUDIO_DATA_TREE) + self._traverse_atoms(fh, path=self._AUDIO_DATA_TREE) def _parse_tag(self, fh: BinaryIO) -> None: - self._traverse_atoms(fh, path=self.META_DATA_TREE) + self._traverse_atoms(fh, path=self._META_DATA_TREE) def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], stop_pos: int | None = None, @@ -512,9 +512,9 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], if DEBUG: print(f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' f'atom: {atom_type!r} len: {atom_size + header_size}') - if atom_type in self.VERSIONED_ATOMS: # jump atom version for now + if atom_type in self._VERSIONED_ATOMS: # jump atom version for now fh.seek(4, os.SEEK_CUR) - if atom_type in self.FLAGGED_ATOMS: # jump atom flags for now + if atom_type in self._FLAGGED_ATOMS: # jump atom flags for now fh.seek(4, os.SEEK_CUR) sub_path = path.get(atom_type, None) # if the path leaf is a dict, traverse deeper into the tree: @@ -541,7 +541,7 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], class _ID3(TinyTag): - FRAME_ID_TO_FIELD = { + _ID3_MAPPING = { # Mapping from Frame ID to a field of the TinyTag # https://exiftool.org/TagNames/ID3.html 'COMM': 'comment', 'COM': 'comment', @@ -563,14 +563,14 @@ class _ID3(TinyTag): 'TPUB': 'extra.publisher', 'TPB': 'extra.publisher', 'USLT': 'extra.lyrics', 'ULT': 'extra.lyrics', } - IMAGE_FRAME_IDS = {'APIC', 'PIC'} - CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} - DISALLOWED_FRAME_IDS = {'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d'} + _IMAGE_FRAME_IDS = {'APIC', 'PIC'} + _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} + _DISALLOWED_FRAME_IDS = {'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d'} _MAX_ESTIMATION_SEC = 30.0 _CBR_DETECTION_FRAME_COUNT = 5 _USE_XING_HEADER = True # much faster, but can be deactivated for testing - ID3V1_GENRES = [ + _ID3V1_GENRES = ( 'Blues', 'Classic Rock', 'Country', 'Dance', 'Disco', 'Funk', 'Grunge', 'Hip-Hop', 'Jazz', 'Metal', 'New Age', 'Oldies', 'Other', 'Pop', 'R&B', 'Rap', 'Reggae', 'Rock', 'Techno', 'Industrial', @@ -613,40 +613,41 @@ class _ID3(TinyTag): 'Psytrance', 'Shoegaze', 'Space Rock', 'Trop Rock', 'World Music', 'Neoclassical', 'Audiobook', 'Audio Theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', - ] - - def __init__(self) -> None: - super().__init__() - # save position after the ID3 tag for duration measurement speedup - self._bytepos_after_id3v2 = -1 + ) # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm - samplerates = [ - [11025, 12000, 8000], # MPEG 2.5 - [], # reserved - [22050, 24000, 16000], # MPEG 2 - [44100, 48000, 32000], # MPEG 1 - ] - v1l1 = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0] - v1l2 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0] - v1l3 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0] - v2l1 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0] - v2l2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0] - v2l3 = v2l2 - bitrate_by_version_by_layer = [ - [None, v2l3, v2l2, v2l1], # MPEG Version 2.5 # note that the layers go - None, # reserved # from 3 to 1 by design. - [None, v2l3, v2l2, v2l1], # MPEG Version 2 # the first layer id is - [None, v1l3, v1l2, v1l1], # MPEG Version 1 # reserved - ] - samples_per_frame = 1152 # the default frame size for mp3 - channels_per_channel_mode = [ + _SAMPLE_RATES = ( + (11025, 12000, 8000), # MPEG 2.5 + (0, 0, 0), # reserved + (22050, 24000, 16000), # MPEG 2 + (44100, 48000, 32000), # MPEG 1 + ) + _V1L1 = (0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0) + _V1L2 = (0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0) + _V1L3 = (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0) + _V2L1 = (0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0) + _V2L2 = (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0) + _V2L3 = _V2L2 + _NONE = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + _BITRATE_BY_VERSION_BY_LAYER = ( + (_NONE, _V2L3, _V2L2, _V2L1), # MPEG Version 2.5 # note that the layers go + (_NONE, _NONE, _NONE, _NONE), # reserved # from 3 to 1 by design. + (_NONE, _V2L3, _V2L2, _V2L1), # MPEG Version 2 # the first layer id is + (_NONE, _V1L3, _V1L2, _V1L1), # MPEG Version 1 # reserved + ) + _SAMPLES_PER_FRAME = 1152 # the default frame size for mp3 + _CHANNELS_PER_CHANNEL_MODE = ( 2, # 00 Stereo 2, # 01 Joint stereo (Stereo) 2, # 10 Dual channel (2 mono channels) 1, # 11 Single channel (Mono) - ] + ) + + def __init__(self) -> None: + super().__init__() + # save position after the ID3 tag for duration measurement speedup + self._bytepos_after_id3v2 = -1 @staticmethod def _parse_xing_header(fh: BinaryIO) -> tuple[int, int]: @@ -669,7 +670,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: if self._bytepos_after_id3v2 == -1: self._parse_id3v2_header(fh) - max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3.samples_per_frame + max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3._SAMPLES_PER_FRAME frame_size_accu = 0 header_bytes = 4 frames = 0 # count frames for determining mp3 duration @@ -702,9 +703,9 @@ def _determine_duration(self, fh: BinaryIO) -> None: idx = len(b) # not found: jump over the current peek buffer walker.seek(max(idx, 1), os.SEEK_CUR) continue - self.channels = self.channels_per_channel_mode[channel_mode] - frame_bitrate = self.bitrate_by_version_by_layer[mpeg_id][layer_id][br_id] - self.samplerate = samplerate = self.samplerates[mpeg_id][sr_id] + self.channels = self._CHANNELS_PER_CHANNEL_MODE[channel_mode] + frame_bitrate = self._BITRATE_BY_VERSION_BY_LAYER[mpeg_id][layer_id][br_id] + self.samplerate = samplerate = self._SAMPLE_RATES[mpeg_id][sr_id] # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate @@ -715,9 +716,9 @@ def _determine_duration(self, fh: BinaryIO) -> None: xframes, byte_count = self._parse_xing_header(walker) if xframes > 0 and byte_count > 0: # MPEG-2 Audio Layer III uses 576 samples per frame - samples_per_frame = 576 if mpeg_id <= 2 else self.samples_per_frame + samples_per_frame = 576 if mpeg_id <= 2 else self._SAMPLES_PER_FRAME self.duration = duration = xframes * samples_per_frame / samplerate - # self.duration = (xframes * self.samples_per_frame / samplerate + # self.duration = (xframes * self._SAMPLES_PER_FRAME / samplerate # / self.channels) # noqa self.bitrate = byte_count * 8 / duration / 1000 return @@ -740,7 +741,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) audio_stream_size = fh.tell() - audio_offset est_frame_count = audio_stream_size / (frame_size_accu / frames) - samples = est_frame_count * self.samples_per_frame + samples = est_frame_count * self._SAMPLES_PER_FRAME self.duration = samples / samplerate self.bitrate = bitrate_accu / frames return @@ -748,7 +749,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: if frame_length > 1: # jump over current frame body walker.seek(frame_length - header_bytes, os.SEEK_CUR) if self.samplerate: - self.duration = frames * self.samples_per_frame / self.samplerate + self.duration = frames * self._SAMPLES_PER_FRAME / self.samplerate def _parse_tag(self, fh: BinaryIO) -> None: self._parse_id3v2(fh) @@ -817,8 +818,8 @@ def asciidecode(x: bytes) -> str: self._set_field('comment', asciidecode(comment)) if not self.genre: genre_id = ord(fields[124:125]) - if genre_id < len(self.ID3V1_GENRES): - self._set_field('genre', self.ID3V1_GENRES[genre_id]) + if genre_id < len(self._ID3V1_GENRES): + self._set_field('genre', self._ID3V1_GENRES[genre_id]) def __parse_custom_field(self, content: str) -> bool: custom_field_name, separator, value = content.partition('\x00') @@ -852,7 +853,7 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. content = fh.read(frame_size) - fieldname = self.FRAME_ID_TO_FIELD.get(frame_id) + fieldname = self._ID3_MAPPING.get(frame_id) should_set_field = True if fieldname: if not self._parse_tags: @@ -881,15 +882,15 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: parens_text = value[1:end_pos] if end_pos > 0 and parens_text.isdecimal(): genre_id = int(parens_text) - if 0 <= genre_id < len(_ID3.ID3V1_GENRES): - value = _ID3.ID3V1_GENRES[genre_id] + if 0 <= genre_id < len(_ID3._ID3V1_GENRES): + value = _ID3._ID3V1_GENRES[genre_id] if should_set_field: self._set_field(fieldname, value) - elif frame_id in self.CUSTOM_FRAME_IDS: + elif frame_id in self._CUSTOM_FRAME_IDS: # custom fields if self._parse_tags: self.__parse_custom_field(self._decode_string(content)) - elif frame_id in self.IMAGE_FRAME_IDS: + elif frame_id in self._IMAGE_FRAME_IDS: if self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames encoding = content[0:1] @@ -902,7 +903,7 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: desc_length = self._index_utf16(content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_length + len(termination) self._image_data = content[desc_end_pos:] - elif frame_id not in self.DISALLOWED_FRAME_IDS: + elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: self._set_field('extra.' + frame_id.lower(), self._decode_string(content)) @@ -956,6 +957,34 @@ def _calc_size(bytestr: tuple[int, ...], bits_per_byte: int) -> int: class _Ogg(TinyTag): + _VORBIS_MAPPING = { + 'album': 'album', + 'albumartist': 'albumartist', + 'title': 'title', + 'artist': 'artist', + 'author': 'artist', + 'date': 'year', + 'tracknumber': 'track', + 'tracktotal': 'track_total', + 'totaltracks': 'track_total', + 'discnumber': 'disc', + 'disctotal': 'disc_total', + 'totaldiscs': 'disc_total', + 'genre': 'genre', + 'description': 'comment', + 'comment': 'comment', + 'comments': 'comment', + 'composer': 'extra.composer', + 'bpm': 'extra.bpm', + 'copyright': 'extra.copyright', + 'isrc': 'extra.isrc', + 'lyrics': 'extra.lyrics', + 'publisher': 'extra.publisher', + 'language': 'extra.language', + 'director': 'extra.director', + 'website': 'extra.url', + } + def __init__(self) -> None: super().__init__() self._max_samplenum = 0 # maximum sample position ever read @@ -1053,33 +1082,6 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N # for the spec, see: http://xiph.org/vorbis/doc/v-comment.html # discnumber tag based on: https://en.wikipedia.org/wiki/Vorbis_comment # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/Vorbis.html - comment_type_to_attr_mapping = { - 'album': 'album', - 'albumartist': 'albumartist', - 'title': 'title', - 'artist': 'artist', - 'author': 'artist', - 'date': 'year', - 'tracknumber': 'track', - 'tracktotal': 'track_total', - 'totaltracks': 'track_total', - 'discnumber': 'disc', - 'disctotal': 'disc_total', - 'totaldiscs': 'disc_total', - 'genre': 'genre', - 'description': 'comment', - 'comment': 'comment', - 'comments': 'comment', - 'composer': 'extra.composer', - 'bpm': 'extra.bpm', - 'copyright': 'extra.copyright', - 'isrc': 'extra.isrc', - 'lyrics': 'extra.lyrics', - 'publisher': 'extra.publisher', - 'language': 'extra.language', - 'director': 'extra.director', - 'website': 'extra.url', - } if contains_vendor: vendor_length = struct.unpack('I', fh.read(4))[0] fh.seek(vendor_length, os.SEEK_CUR) # jump over vendor @@ -1098,7 +1100,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N else: if DEBUG: print('Found Vorbis Comment', key, value[:64]) - fieldname = comment_type_to_attr_mapping.get( + fieldname = self._VORBIS_MAPPING.get( key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' if fieldname in {'track', 'disc', 'track_total', 'disc_total'}: if fieldname in {'track', 'disc'} and '/' in value: @@ -1140,7 +1142,7 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: class _Wave(TinyTag): # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html - riff_mapping = { + _RIFF_MAPPING = { b'INAM': 'title', b'TITL': 'title', b'IPRD': 'album', @@ -1208,7 +1210,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: data_length = struct.unpack('I', sub_fh.read(4))[0] data_length += data_length % 2 # IFF chunks are padded to an even size data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte - fieldname = self.riff_mapping.get(field) + fieldname = self._RIFF_MAPPING.get(field) if fieldname: value = data.decode('utf-8') if fieldname == 'track': @@ -1328,17 +1330,32 @@ def _parse_image(fh: BinaryIO) -> bytes: class _Wma(TinyTag): - ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' - ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' - b'\xa0\xc9^\xa8P') - STREAM_BITRATE_PROPERTIES_OBJECT = b'\xceu\xf8{\x8dF\xd1\x11\x8d\x82\x00`\x97\xc9\xa2\xb2' - ASF_FILE_PROPERTY_OBJECT = b'\xa1\xdc\xab\x8cG\xa9\xcf\x11\x8e\xe4\x00\xc0\x0c Se' - ASF_STREAM_PROPERTIES_OBJECT = b'\x91\x07\xdc\xb7\xb7\xa9\xcf\x11\x8e\xe6\x00\xc0\x0c Se' - STREAM_TYPE_ASF_AUDIO_MEDIA = b'@\x9ei\xf8M[\xcf\x11\xa8\xfd\x00\x80_\\D+' # see: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx # and (japanese, but none the less helpful) # http://uguisu.skr.jp/Windows/format_asf.html + _ASF_MAPPING = { + 'WM/TrackNumber': 'track', + 'WM/PartOfSet': 'disc', + 'WM/Year': 'year', + 'WM/AlbumArtist': 'albumartist', + 'WM/Genre': 'genre', + 'WM/AlbumTitle': 'album', + 'WM/Composer': 'extra.composer', + 'WM/Publisher': 'extra.publisher', + 'WM/BeatsPerMinute': 'extra.bpm', + 'WM/InitialKey': 'extra.initial_key', + 'WM/Lyrics': 'extra.lyrics', + 'WM/Language': 'extra.language', + 'WM/AuthorURL': 'extra.url', + } + _ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' + _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' + b'\xa0\xc9^\xa8P') + _STREAM_BITRATE_PROPERTIES_OBJECT = b'\xceu\xf8{\x8dF\xd1\x11\x8d\x82\x00`\x97\xc9\xa2\xb2' + _ASF_FILE_PROPERTY_OBJECT = b'\xa1\xdc\xab\x8cG\xa9\xcf\x11\x8e\xe4\x00\xc0\x0c Se' + _ASF_STREAM_PROPERTIES_OBJECT = b'\x91\x07\xdc\xb7\xb7\xa9\xcf\x11\x8e\xe6\x00\xc0\x0c Se' + _STREAM_TYPE_ASF_AUDIO_MEDIA = b'@\x9ei\xf8M[\xcf\x11\xa8\xfd\x00\x80_\\D+' def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: @@ -1348,7 +1365,7 @@ def _decode_string(self, bytestring: bytes) -> str: return self._unpad(bytestring.decode('utf-16')) def _decode_ext_desc(self, value_type: int, value: bytes) -> int | str | None: - """ decode ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" + """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string return self._decode_string(value) if 1 < value_type < 6: # DWORD / QWORD / WORD @@ -1367,7 +1384,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: object_size = self._bytes_to_int_le(fh.read(8)) if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. - if object_id == self.ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: + if object_id == self._ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: title_length = self._bytes_to_int_le(fh.read(2)) author_length = self._bytes_to_int_le(fh.read(2)) copyright_length = self._bytes_to_int_le(fh.read(2)) @@ -1384,22 +1401,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: bytestring = fh.read(length) if not i_field_name.startswith('_'): self._set_field(i_field_name, self._decode_string(bytestring)) - elif object_id == self.ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: - mapping = { - 'WM/TrackNumber': 'track', - 'WM/PartOfSet': 'disc', - 'WM/Year': 'year', - 'WM/AlbumArtist': 'albumartist', - 'WM/Genre': 'genre', - 'WM/AlbumTitle': 'album', - 'WM/Composer': 'extra.composer', - 'WM/Publisher': 'extra.publisher', - 'WM/BeatsPerMinute': 'extra.bpm', - 'WM/InitialKey': 'extra.initial_key', - 'WM/Lyrics': 'extra.lyrics', - 'WM/Language': 'extra.language', - 'WM/AuthorURL': 'extra.url', - } + elif object_id == self._ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 descriptor_count = self._bytes_to_int_le(fh.read(2)) for _ in range(descriptor_count): @@ -1410,7 +1412,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: if value_type == 1: fh.seek(value_len, os.SEEK_CUR) # skip byte values continue - field_name = mapping.get(name) # try to get normalized field name + field_name = self._ASF_MAPPING.get(name) # try to get normalized field name if field_name is None: # custom field if name.startswith('WM/'): name = name[3:] @@ -1422,7 +1424,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._set_field(field_name, int(field_value)) else: self._set_field(field_name, field_value) - elif object_id == self.ASF_FILE_PROPERTY_OBJECT and self._parse_duration: + elif object_id == self._ASF_FILE_PROPERTY_OBJECT and self._parse_duration: fh.seek(40, os.SEEK_CUR) play_duration = self._bytes_to_int_le(fh.read(8)) / 10000000 fh.seek(8, os.SEEK_CUR) @@ -1431,14 +1433,14 @@ def _parse_tag(self, fh: BinaryIO) -> None: # According to the specification, we need to subtract the preroll from play_duration # to get the actual duration of the file self.duration = max(play_duration - preroll, 0.0) - elif object_id == self.ASF_STREAM_PROPERTIES_OBJECT and self._parse_duration: + elif object_id == self._ASF_STREAM_PROPERTIES_OBJECT and self._parse_duration: stream_type = fh.read(16) fh.seek(24, os.SEEK_CUR) # skip irrelevant fields type_specific_data_length = self._bytes_to_int_le(fh.read(4)) error_correction_data_length = self._bytes_to_int_le(fh.read(4)) fh.seek(6, os.SEEK_CUR) # skip irrelevant fields already_read = 0 - if stream_type == self.STREAM_TYPE_ASF_AUDIO_MEDIA: + if stream_type == self._STREAM_TYPE_ASF_AUDIO_MEDIA: codec_id_format_tag = self._bytes_to_int_le(fh.read(2)) _channels = self._bytes_to_int_le(fh.read(2)) self.samplerate = self._bytes_to_int_le(fh.read(4)) @@ -1478,7 +1480,7 @@ class _Aiff(TinyTag): # ID3 rather than TinyTag since it does everything that needs to be done here. # - aiff_mapping = { + _AIFF_MAPPING = { # # "Name Chunk text contains the name of the sampled sound." # @@ -1506,9 +1508,9 @@ def _parse_tag(self, fh: BinaryIO) -> None: while len(chunk_header) == 8: sub_chunk_id, sub_chunk_size = struct.unpack('>4sI', chunk_header) sub_chunk_size += sub_chunk_size % 2 # IFF chunks are padded to an even number of bytes - if sub_chunk_id in self.aiff_mapping and self._parse_tags: + if sub_chunk_id in self._AIFF_MAPPING and self._parse_tags: value = self._unpad(fh.read(sub_chunk_size).decode('utf-8')) - self._set_field(self.aiff_mapping[sub_chunk_id], value) + self._set_field(self._AIFF_MAPPING[sub_chunk_id], value) elif sub_chunk_id == b'COMM' and self._parse_duration: channels, num_frames, bitdepth = struct.unpack('>hLh', fh.read(8)) self.channels, self.bitdepth = channels, bitdepth From acfee9111c566d760853bdfd34b45acc58a95f8a Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 14:50:38 +0200 Subject: [PATCH 156/305] WMA: set 'channels' attribute --- tinytag/tests/test_all.py | 6 +++--- tinytag/tinytag.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0eabec5..2c13bd0 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -438,13 +438,13 @@ 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', - 'genre': 'Alternative'}), + 'genre': 'Alternative', 'channels': 2}), ('samples/lossless.wma', {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, - 'duration': 43.133}), + 'duration': 43.133, 'channels': 2}), ('samples/wma_invalid_track_number.wma', {'extra': {'encodingsettings': 'Lavf60.16.100'}, 'filesize': 3940, 'bitrate': 128.0, - 'duration': 2.1409999999999996, 'samplerate': 44100}), + 'duration': 2.1409999999999996, 'samplerate': 44100, 'channels': 1}), # ALAC/M4A/MP4 ('samples/test.m4a', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3ded541..ea1ea63 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1442,7 +1442,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: already_read = 0 if stream_type == self._STREAM_TYPE_ASF_AUDIO_MEDIA: codec_id_format_tag = self._bytes_to_int_le(fh.read(2)) - _channels = self._bytes_to_int_le(fh.read(2)) + self.channels = self._bytes_to_int_le(fh.read(2)) self.samplerate = self._bytes_to_int_le(fh.read(4)) avg_bytes_per_second = self._bytes_to_int_le(fh.read(4)) self.bitrate = avg_bytes_per_second * 8 / 1000 From a490c6d40c8fc499f9a3c35af14fd478c7da1d5d Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 14:52:39 +0200 Subject: [PATCH 157/305] WMA: set 'extra.copyright' --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ea1ea63..aede9a8 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1393,7 +1393,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: data_blocks = { 'title': title_length, 'artist': author_length, - '_copyright': copyright_length, + 'extra.copyright': copyright_length, 'comment': description_length, '_rating': rating_length, } From b547378c958214925d3899e4f75e71aeeacd2039 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 18:47:48 +0200 Subject: [PATCH 158/305] Provide access to all available images --- README.md | 95 +++++++++++++++++------ tinytag/__init__.py | 2 +- tinytag/tests/test_all.py | 12 +-- tinytag/tinytag.py | 154 +++++++++++++++++++++++++++++++------- 4 files changed, 210 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index e78b522..fb73134 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ tinytag is a library for reading music meta data of most common audio files in p ## Features - * Read tags, length and cover images of audio files + * Read tags, length and images of audio files * Supported formats: * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) * M4A (AAC / ALAC) @@ -28,15 +28,17 @@ tinytag is a library for reading music meta data of most common audio files in p * Pure Python, no dependencies * Supports Python 3.7 or higher * High test coverage - * Just a few hundred lines of code (just include it in your project!) + * A few hundred lines of code (just include it in your project!) -tinytag only provides the minimum needed for _reading_ meta-data. -It can determine track number, total tracks, title, artist, album, year, duration and any more. +## Usage + +tinytag only provides the minimum needed for _reading_ metadata, and presents it in a simple format. +It can determine track number, total tracks, title, artist, album, year, duration and more. from tinytag import TinyTag tag = TinyTag.get('/some/music.mp3') - print('This track is by %s.' % tag.artist) - print('It is %f seconds long.' % tag.duration) + print(f'This track is by {tag.artist}.') + print(f'It is {tag.duration:.2f} seconds long.') Alternatively you can use tinytag directly on the command line: @@ -45,14 +47,18 @@ Alternatively you can use tinytag directly on the command line: Check `python -m tinytag --help` for all CLI options, for example other output formats. -To receive a list of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: +### Supported Files + +To receive a tuple of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: TinyTag.SUPPORTED_FILE_EXTENSIONS -Alternatively, check if a file is supported: +Alternatively, check if a file is supported by providing its path: is_supported = TinyTag.is_supported('/some/music.mp3') +### Attributes + List of common attributes tinytag provides: tag.album # album as string @@ -82,23 +88,68 @@ For non-common fields and fields specific to certain file formats, use `extra`: The following standard `extra` field names are used when file formats provide relevant data: - `bpm` - `composer` - `copyright` - `director` - `initial_key` - `isrc` - `language` - `lyrics` - `publisher` - `url` + bpm + composer + copyright + director + initial_key + isrc + language + lyrics + publisher + url Any other `extra` field names are not guaranteed to be consistent across audio formats. -Additionally you can also get cover images from ID3 tags: +Additionally, you can also get images from ID3 tags. To receive an image most likely suitable as the front cover: + + tag: TinyTag = TinyTag.get('/some/music.mp3', image=True) + image_data: bytes = tag.get_image() + +If you need to receive an image of a specific type, including its description, use `images`: + + tag.images # image types and their data + +The following possible image types exist: - tag = TinyTag.get('/some/music.mp3', image=True) - image_data = tag.get_image() + other + icon + other_icon + front_cover + back_cover + leaflet + media + lead_artist + artist + conductor + band + composer + lyricist + recording_location + during_recording + during_performance + video + bright_colored_fish + illustration + band_logo + publisher_logo + +The following image attributes are available: + + data # image data as bytes + description # image description as string + +To retrieve e.g. a `bright_colored_fish` image: + + from tinytag import TinyTag, TagImage, TagImages + + tag: TinyTag = TinyTag.get('/some/music.ogg') + images: TagImages = tag.images + image: TagImage = images.bright_colored_fish + data: bytes = image.data + description: str = image.description + +### Encoding To open files using a specific encoding, you can use the `encoding` parameter. This parameter is however only used for formats where the encoding isn't explicitly @@ -106,6 +157,8 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') +### File-like Objects + To use a file-like object (e.g. BytesIO) instead of a file path, pass a `file_obj` keyword argument: diff --git a/tinytag/__init__.py b/tinytag/__init__.py index b4d6291..0a5165e 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,2 +1,2 @@ # pylint: disable=missing-module-docstring -from .tinytag import TinyTag, TinyTagException +from .tinytag import TinyTag, TagExtra, TagImage, TagImages, TinyTagException diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 2c13bd0..8e29ec1 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -614,10 +614,10 @@ def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True, duration=True) results = { - key: val for key, val in tag._as_dict().items() if val is not None + key: val for key, val in tag._as_dict().items() if val is not None and key != 'images' } compare_tag(results, expected, filename) - assert tag._image_data is None + assert tag.images.front_cover.data is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -626,13 +626,13 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"} tag = TinyTag.get(filename, tags=True, duration=False) results = { - key: val for key, val in tag._as_dict().items() if val is not None + key: val for key, val in tag._as_dict().items() if val is not None and key != 'images' } expected = { key: val for key, val in expected.items() if key not in excluded_attrs } compare_tag(results, expected, filename) - assert tag._image_data is None + assert tag.images.front_cover.data is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -641,14 +641,14 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False, duration=True) results = { - key: val for key, val in tag._as_dict().items() if val is not None + key: val for key, val in tag._as_dict().items() if val is not None and key != 'images' } expected = { key: val for key, val in expected.items() if key in allowed_attrs } expected["extra"] = {} compare_tag(results, expected, filename) - assert tag._image_data is None + assert tag.images.front_cover.data is None def test_pathlib_compatibility() -> None: diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index aede9a8..f716941 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -27,7 +27,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -# pylint: disable=missing-module-docstring,missing-class-docstring,missing-function-docstring +# pylint: disable=missing-module-docstring,missing-class-docstring # pylint: disable=invalid-name,protected-access # pylint: disable=too-many-lines,too-many-arguments,too-many-boolean-expressions # pylint: disable=too-many-branches,too-many-instance-attributes,too-many-locals @@ -39,7 +39,7 @@ from functools import reduce from os import PathLike from sys import stderr -from typing import Any, BinaryIO +from typing import Any, BinaryIO, Union import base64 import io @@ -54,6 +54,52 @@ class TinyTagException(Exception): pass +class TagImage: + def __init__(self, data: bytes | None = None) -> None: + self.data = data + self.description: str | None = None + + def __repr__(self) -> str: + variables = vars(self) + data = variables.get("data") + if data is not None: + variables["data"] = (data[:45] + '..') if len(data) > 45 else data + return str(variables) + + +class TagImages: + def __init__(self) -> None: + image = TagImage() + self.other: TagImage = image + self.icon: TagImage = image + self.other_icon: TagImage = image + self.front_cover: TagImage = image + self.back_cover: TagImage = image + self.leaflet: TagImage = image + self.media: TagImage = image + self.lead_artist: TagImage = image + self.artist: TagImage = image + self.conductor: TagImage = image + self.band: TagImage = image + self.composer: TagImage = image + self.lyricist: TagImage = image + self.recording_location: TagImage = image + self.during_recording: TagImage = image + self.during_performance: TagImage = image + self.video: TagImage = image + self.bright_colored_fish: TagImage = image + self.illustration: TagImage = image + self.band_logo: TagImage = image + self.publisher_logo: TagImage = image + + def __repr__(self) -> str: + return str(vars(self)) + + +class TagExtra(dict[str, Union[str, int, float]]): + pass + + class TinyTag: SUPPORTED_FILE_EXTENSIONS = ( '.mp1', '.mp2', '.mp3', @@ -75,9 +121,10 @@ def __init__(self) -> None: self.disc: int | None = None self.disc_total: int | None = None self.duration: float | None = None - self.extra: dict[str, str | int | float] = {} + self.extra = TagExtra() self.filesize = 0 self.genre: str | None = None + self.images = TagImages() self.samplerate: int | None = None self.bitdepth: int | None = None self.title: str | None = None @@ -86,7 +133,6 @@ def __init__(self) -> None: self.year: str | None = None self._filehandler: BinaryIO | None = None self._filename: bytes | str | PathLike[Any] | None = None # for debugging - self._image_data: bytes | None = None self._default_encoding: str | None = None # allow override for some file formats self._ignore_errors = False self._parse_duration = True @@ -103,6 +149,7 @@ def get(cls, ignore_errors: bool = False, encoding: str | None = None, file_obj: BinaryIO | None = None) -> TinyTag: + """Create a tag object for a file path or a file-like object.""" should_close_file = file_obj is None if filename and should_close_file: file_obj = open(filename, 'rb') # pylint: disable=consider-using-with @@ -130,17 +177,31 @@ def get(cls, file_obj.close() def get_image(self) -> bytes | None: - return self._image_data + """Return the closest equivalent to a cover image as bytes.""" + front_cover = self.images.front_cover.data # check the obvious location + if front_cover is not None: + return front_cover + other = self.images.other.data # cover images are sometimes stored here + if other is not None: + return other + back_cover = self.images.back_cover.data # last resort, maybe there's something here... + if back_cover is not None: + return back_cover + return None @classmethod def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: + """Check if a specific file is supported based on the file extension.""" return cls._get_parser_for_filename(filename) is not None def __repr__(self) -> str: return str(self._as_dict()) def _as_dict(self) -> dict[str, Any]: - return {k: v for k, v in sorted(self.__dict__.items()) if not k.startswith('_')} + return { + k: v for k, v in sorted(self.__dict__.items()) + if not k.startswith('_') and k != 'images' + } @classmethod def _get_parser_for_filename( @@ -222,7 +283,7 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: str | int | float) -> None: + def _set_field(self, fieldname: str, value: str | int | float | TagImage) -> None: """convenience function to set fields of the tinytag by name""" write_dest = self.__dict__ # write into the TinyTag by default is_str = isinstance(value, str) @@ -232,6 +293,9 @@ def _set_field(self, fieldname: str, value: str | int | float) -> None: if fieldname.startswith('extra.'): fieldname = fieldname[6:] write_dest = self.extra # write into the extra field instead + elif fieldname.startswith('images.'): + fieldname = fieldname[7:] + write_dest = self.images.__dict__ old_value = write_dest.get(fieldname) if is_str and old_value and old_value != value: # Combine same field with a null character @@ -251,13 +315,14 @@ def _update(self, other: TinyTag) -> None: for key in ('track', 'track_total', 'title', 'artist', 'album', 'albumartist', 'year', 'duration', 'genre', 'disc', 'disc_total', 'comment', - 'bitdepth', 'bitrate', 'channels', 'samplerate', - '_image_data'): + 'bitdepth', 'bitrate', 'channels', 'samplerate'): new_value = getattr(other, key) if new_value: self._set_field(key, new_value) for key, value in other.extra.items(): self._set_field("extra." + key, value) + for second_key, second_value in other.images.__dict__.items(): + self._set_field("images." + second_key, second_value) @staticmethod def _bytes_to_int_le(b: bytes) -> int: @@ -280,7 +345,8 @@ class _MP4(TinyTag): # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html class _Parser: - atom_decoder_by_type: dict[int, Callable[[bytes], int | str | bytes]] | None = None + atom_decoder_by_type: dict[ + int, Callable[[bytes], int | str | bytes | TagImage]] | None = None @classmethod def _unpack_integer(cls, value: bytes, signed: bool = True) -> int: @@ -302,8 +368,8 @@ def _unpack_integer_unsigned(cls, value: bytes) -> int: @classmethod def _make_data_atom_parser( - cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes]]: - def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes]: + cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes | TagImage]]: + def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | TagImage]: data_type = struct.unpack('>I', data_atom[:4])[0] if cls.atom_decoder_by_type is None: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 @@ -313,8 +379,8 @@ def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes]: 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS # 16: duration in millis - 13: lambda x: x, # JPEG - 14: lambda x: x, # PNG + 13: TagImage, # JPEG + 14: TagImage, # PNG 21: cls._unpack_integer, # BE Signed int 22: cls._unpack_integer_unsigned, # BE Unsigned int # 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 @@ -365,7 +431,7 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: break @classmethod - def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes]: + def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | TagImage]: fh = io.BytesIO(data) header_size = 8 field_name = None @@ -472,7 +538,7 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: b'gnre': {b'data': _Parser._parse_id3v1_genre}, b'trkn': {b'data': _Parser._make_number_parser('track', 'track_total')}, b'tmpo': {b'data': _Parser._make_data_atom_parser('extra.bpm')}, - b'covr': {b'data': _Parser._make_data_atom_parser('_image_data')}, + b'covr': {b'data': _Parser._make_data_atom_parser('images.front_cover')}, b'----': _Parser._parse_custom_field, }}}}} @@ -527,7 +593,7 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], for fieldname, value in sub_path(fh.read(atom_size)).items(): if DEBUG: print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) - if fieldname == '_image_data' and not self._load_image: + if fieldname.startswith('images.') and not self._load_image: continue if fieldname: self._set_field(fieldname, value) @@ -614,6 +680,29 @@ class _ID3(TinyTag): 'Neoclassical', 'Audiobook', 'Audio Theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', ) + _IMAGE_TYPES = ( + 'other', + 'icon', + 'other_icon', + 'front_cover', + 'back_cover', + 'leaflet', + 'media', + 'lead_artist', + 'artist', + 'conductor', + 'band', + 'composer', + 'lyricist', + 'recording_location', + 'during_recording', + 'during_performance', + 'video', + 'bright_colored_fish', + 'illustration', + 'band_logo', + 'publisher_logo', + ) # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm @@ -898,11 +987,18 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: desc_start_pos = 1 + 3 + 1 # skip encoding (1), imgformat (3), pictype(1) else: # ID3 v2.3+ desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mtype, pictype(1) + pictype = content[desc_start_pos - 1] # latin1 and utf-8 are 1 byte termination = b'\x00' if encoding in {b'\x00', b'\x03'} else b'\x00\x00' desc_length = self._index_utf16(content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_length + len(termination) - self._image_data = content[desc_end_pos:] + description = self._decode_string(content[desc_start_pos:desc_end_pos]) + image = TagImage(content[desc_end_pos:]) + if description: + image.description = description + if 0 <= pictype <= len(self._IMAGE_TYPES): + pictype = 0 # fall back to 'other' type + self._set_field('images.' + self._IMAGE_TYPES[pictype], image) elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: @@ -1095,8 +1191,9 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N if key_lowercase == "metadata_block_picture" and self._load_image: if DEBUG: - print('Found Vorbis Image', key, value[:64]) - self._image_data = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) + print('Found Vorbis TagImage', key, value[:64]) + fieldname, fieldvalue = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) + self._set_field(fieldname, fieldvalue) else: if DEBUG: print('Found Vorbis Comment', key, value[:64]) @@ -1303,7 +1400,8 @@ def _parse_tag(self, fh: BinaryIO) -> None: oggtag._parse_vorbis_comment(fh) self._update(oggtag) elif block_type == self.METADATA_PICTURE and self._load_image: - self._image_data = self._parse_image(fh) + fieldname, value = self._parse_image(fh) + self._set_field(fieldname, value) elif block_type >= 127: break # invalid block type else: @@ -1319,14 +1417,20 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._tags_parsed = True @staticmethod - def _parse_image(fh: BinaryIO) -> bytes: + def _parse_image(fh: BinaryIO) -> tuple[str, TagImage]: # https://xiph.org/flac/format.html#metadata_block_picture - _pic_type, mime_len = struct.unpack('>2I', fh.read(8)) + pic_type, mime_len = struct.unpack('>2I', fh.read(8)) fh.read(mime_len) description_len = struct.unpack('>I', fh.read(4))[0] - fh.read(description_len) + description = fh.read(description_len).decode('utf-8') _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) - return fh.read(pic_len) + if 0 <= pic_type <= len(_ID3._IMAGE_TYPES): + pic_type = 0 # fall back to 'other' type + field_name = 'images.' + _ID3._IMAGE_TYPES[pic_type] + image = TagImage(fh.read(pic_len)) + if description: + image.description = description + return field_name, image class _Wma(TinyTag): From ae54a08de086987f016d0bd679293f150aa57206 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Feb 2024 22:31:07 +0200 Subject: [PATCH 159/305] Move less common image types to 'extra' dict --- README.md | 22 +++-- tinytag/__init__.py | 2 +- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 192 ++++++++++++++++++-------------------- 4 files changed, 110 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index fb73134..ab55b06 100644 --- a/README.md +++ b/README.md @@ -110,15 +110,18 @@ If you need to receive an image of a specific type, including its description, u tag.images # image types and their data -The following possible image types exist: +The following common image types exist: - other - icon - other_icon front_cover back_cover leaflet media + other + +The following less common image types are provided in an `extra` dict: + + icon + other_icon lead_artist artist conductor @@ -139,16 +142,23 @@ The following image attributes are available: data # image data as bytes description # image description as string -To retrieve e.g. a `bright_colored_fish` image: +To receive a common image, e.g. `front_cover`: from tinytag import TinyTag, TagImage, TagImages tag: TinyTag = TinyTag.get('/some/music.ogg') images: TagImages = tag.images - image: TagImage = images.bright_colored_fish + image: TagImage = images.front_cover data: bytes = image.data description: str = image.description +To receive an extra image, e.g. `bright_colored_fish`: + + image = tag.images.extra.get('bright_colored_fish') + if image is not None: + data = image.data + description = image.description + ### Encoding To open files using a specific encoding, you can use the `encoding` parameter. diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 0a5165e..26c8208 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,2 +1,2 @@ # pylint: disable=missing-module-docstring -from .tinytag import TinyTag, TagExtra, TagImage, TagImages, TinyTagException +from .tinytag import TinyTag, TagImage, TagImages, TinyTagException diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 8e29ec1..34c5181 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -393,7 +393,7 @@ 'artist': 'artist\x00群星', 'title': 'title\x00A 梦 哆啦 机器猫 短信铃声', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'year': '2018', 'comment': 'comment'}), + 'year': '2018', 'comment': 'comment', 'disc': 0}), ('samples/with_padded_id3_header.flac', {'extra': {}, 'filesize': 16070, 'album': 'album', 'artist': 'artist', 'bitrate': 283.4748, 'channels': 1, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index f716941..3d1bde4 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -39,7 +39,7 @@ from functools import reduce from os import PathLike from sys import stderr -from typing import Any, BinaryIO, Union +from typing import Any, BinaryIO import base64 import io @@ -54,52 +54,6 @@ class TinyTagException(Exception): pass -class TagImage: - def __init__(self, data: bytes | None = None) -> None: - self.data = data - self.description: str | None = None - - def __repr__(self) -> str: - variables = vars(self) - data = variables.get("data") - if data is not None: - variables["data"] = (data[:45] + '..') if len(data) > 45 else data - return str(variables) - - -class TagImages: - def __init__(self) -> None: - image = TagImage() - self.other: TagImage = image - self.icon: TagImage = image - self.other_icon: TagImage = image - self.front_cover: TagImage = image - self.back_cover: TagImage = image - self.leaflet: TagImage = image - self.media: TagImage = image - self.lead_artist: TagImage = image - self.artist: TagImage = image - self.conductor: TagImage = image - self.band: TagImage = image - self.composer: TagImage = image - self.lyricist: TagImage = image - self.recording_location: TagImage = image - self.during_recording: TagImage = image - self.during_performance: TagImage = image - self.video: TagImage = image - self.bright_colored_fish: TagImage = image - self.illustration: TagImage = image - self.band_logo: TagImage = image - self.publisher_logo: TagImage = image - - def __repr__(self) -> str: - return str(vars(self)) - - -class TagExtra(dict[str, Union[str, int, float]]): - pass - - class TinyTag: SUPPORTED_FILE_EXTENSIONS = ( '.mp1', '.mp2', '.mp3', @@ -108,6 +62,7 @@ class TinyTag: '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' ) + _EXTRA_PREFIX = 'extra.' _file_extension_mapping: dict[tuple[bytes, ...], type[TinyTag]] | None = None _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None @@ -121,7 +76,7 @@ def __init__(self) -> None: self.disc: int | None = None self.disc_total: int | None = None self.duration: float | None = None - self.extra = TagExtra() + self.extra: dict[str, str | float | int] = {} self.filesize = 0 self.genre: str | None = None self.images = TagImages() @@ -283,27 +238,36 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: str | int | float | TagImage) -> None: + def _set_field(self, fieldname: str, value: str | int | float) -> None: """convenience function to set fields of the tinytag by name""" - write_dest = self.__dict__ # write into the TinyTag by default is_str = isinstance(value, str) if is_str and not value: # don't set empty value return - if fieldname.startswith('extra.'): - fieldname = fieldname[6:] - write_dest = self.extra # write into the extra field instead - elif fieldname.startswith('images.'): - fieldname = fieldname[7:] - write_dest = self.images.__dict__ + write_dest = self.__dict__ + if fieldname.startswith(self._EXTRA_PREFIX): + fieldname = fieldname[len(self._EXTRA_PREFIX):] + write_dest = self.extra old_value = write_dest.get(fieldname) - if is_str and old_value and old_value != value: - # Combine same field with a null character - value = old_value + '\x00' + value + if is_str: + if old_value and old_value != value: + # Combine same field with a null character + value = old_value + '\x00' + value + elif not value and old_value: + return if DEBUG: print(f'Setting field "{fieldname}" to "{value!r}"') write_dest[fieldname] = value + def _set_image_field(self, fieldname: str, value: bytes | str | TagImage) -> None: + write_dest = self.images.__dict__ + if fieldname.startswith(self._EXTRA_PREFIX): + fieldname = fieldname[len(self._EXTRA_PREFIX):] + write_dest = self.images.extra + if DEBUG: + print(f'Setting image field "{fieldname}"') + write_dest[fieldname] = value + def _determine_duration(self, fh: BinaryIO) -> None: raise NotImplementedError @@ -312,17 +276,16 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _update(self, other: TinyTag) -> None: # update the values of this tag with the values from another tag - for key in ('track', 'track_total', 'title', 'artist', - 'album', 'albumartist', 'year', 'duration', - 'genre', 'disc', 'disc_total', 'comment', - 'bitdepth', 'bitrate', 'channels', 'samplerate'): - new_value = getattr(other, key) - if new_value: - self._set_field(key, new_value) - for key, value in other.extra.items(): - self._set_field("extra." + key, value) - for second_key, second_value in other.images.__dict__.items(): - self._set_field("images." + second_key, second_value) + for standard_key, standard_value in other.__dict__.items(): + if (not standard_key.startswith('_') and standard_key != 'filesize' + and standard_value is not None): + self._set_field(standard_key, standard_value) + for extra_key, extra_value in other.extra.items(): + self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) + for image_key, image_value in other.images.__dict__.items(): + self._set_image_field(image_key, image_value) + for image_extra_key, image_extra_value in other.images.extra.items(): + self._set_image_field(self._EXTRA_PREFIX + image_extra_key, image_extra_value) @staticmethod def _bytes_to_int_le(b: bytes) -> int: @@ -340,6 +303,33 @@ def _unpad(s: str) -> str: return s.strip('\x00') +class TagImages: + def __init__(self) -> None: + image = TagImage() + self.front_cover: TagImage = image + self.back_cover: TagImage = image + self.leaflet: TagImage = image + self.media: TagImage = image + self.other: TagImage = image + self.extra: dict[str, bytes | str] = {} + + def __repr__(self) -> str: + return str(vars(self)) + + +class TagImage: + def __init__(self, data: bytes | None = None) -> None: + self.data = data + self.description: str | None = None + + def __repr__(self) -> str: + variables = vars(self).copy() + data = variables.get("data") + if data is not None: + variables["data"] = (data[:45] + b'..') if len(data) > 45 else data + return str(variables) + + class _MP4(TinyTag): # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html @@ -442,7 +432,7 @@ def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | TagIm atom_type = atom_header[4:] if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() - field_name = 'extra.' + atom_value.decode('utf-8', 'replace') + field_name = TinyTag._EXTRA_PREFIX + atom_value.decode('utf-8', 'replace') elif atom_type == b'data': data_atom = fh.read(atom_size) else: @@ -593,9 +583,10 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], for fieldname, value in sub_path(fh.read(atom_size)).items(): if DEBUG: print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) - if fieldname.startswith('images.') and not self._load_image: - continue - if fieldname: + if fieldname.startswith('images.'): + if self._load_image: + self._set_image_field(fieldname[len('images.'):], value) + elif fieldname: self._set_field(fieldname, value) # if no action was specified using dict or callable, jump over atom else: @@ -682,26 +673,26 @@ class _ID3(TinyTag): ) _IMAGE_TYPES = ( 'other', - 'icon', - 'other_icon', + 'extra.icon', + 'extra.other_icon', 'front_cover', 'back_cover', 'leaflet', 'media', - 'lead_artist', - 'artist', - 'conductor', - 'band', - 'composer', - 'lyricist', - 'recording_location', - 'during_recording', - 'during_performance', - 'video', - 'bright_colored_fish', - 'illustration', - 'band_logo', - 'publisher_logo', + 'extra.lead_artist', + 'extra.artist', + 'extra.conductor', + 'extra.band', + 'extra.composer', + 'extra.lyricist', + 'extra.recording_location', + 'extra.during_recording', + 'extra.during_performance', + 'extra.video', + 'extra.bright_colored_fish', + 'extra.illustration', + 'extra.band_logo', + 'extra.publisher_logo', ) # see this page for the magic values used in mp3: @@ -913,7 +904,7 @@ def asciidecode(x: bytes) -> str: def __parse_custom_field(self, content: str) -> bool: custom_field_name, separator, value = content.partition('\x00') if custom_field_name and separator: - self._set_field('extra.' + custom_field_name.lower(), value.lstrip('\ufeff')) + self._set_field(self._EXTRA_PREFIX + custom_field_name.lower(), value.lstrip('\ufeff')) return True return False @@ -998,11 +989,12 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: image.description = description if 0 <= pictype <= len(self._IMAGE_TYPES): pictype = 0 # fall back to 'other' type - self._set_field('images.' + self._IMAGE_TYPES[pictype], image) + self._set_image_field(self._IMAGE_TYPES[pictype], image) elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: - self._set_field('extra.' + frame_id.lower(), self._decode_string(content)) + self._set_field( + self._EXTRA_PREFIX + frame_id.lower(), self._decode_string(content)) return frame_size return 0 @@ -1193,12 +1185,12 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N if DEBUG: print('Found Vorbis TagImage', key, value[:64]) fieldname, fieldvalue = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) - self._set_field(fieldname, fieldvalue) + self._set_image_field(fieldname, fieldvalue) else: if DEBUG: print('Found Vorbis Comment', key, value[:64]) fieldname = self._VORBIS_MAPPING.get( - key_lowercase, 'extra.' + key_lowercase) # custom fields go in 'extra' + key_lowercase, self._EXTRA_PREFIX + key_lowercase) # custom field if fieldname in {'track', 'disc', 'track_total', 'disc_total'}: if fieldname in {'track', 'disc'} and '/' in value: value, total = value.split('/')[:2] @@ -1401,7 +1393,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._update(oggtag) elif block_type == self.METADATA_PICTURE and self._load_image: fieldname, value = self._parse_image(fh) - self._set_field(fieldname, value) + self._set_image_field(fieldname, value) elif block_type >= 127: break # invalid block type else: @@ -1416,8 +1408,8 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._update(id3) self._tags_parsed = True - @staticmethod - def _parse_image(fh: BinaryIO) -> tuple[str, TagImage]: + @classmethod + def _parse_image(cls, fh: BinaryIO) -> tuple[str, TagImage]: # https://xiph.org/flac/format.html#metadata_block_picture pic_type, mime_len = struct.unpack('>2I', fh.read(8)) fh.read(mime_len) @@ -1426,7 +1418,7 @@ def _parse_image(fh: BinaryIO) -> tuple[str, TagImage]: _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) if 0 <= pic_type <= len(_ID3._IMAGE_TYPES): pic_type = 0 # fall back to 'other' type - field_name = 'images.' + _ID3._IMAGE_TYPES[pic_type] + field_name = _ID3._IMAGE_TYPES[pic_type] image = TagImage(fh.read(pic_len)) if description: image.description = description @@ -1520,7 +1512,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: if field_name is None: # custom field if name.startswith('WM/'): name = name[3:] - field_name = 'extra.' + name.lower() + field_name = self._EXTRA_PREFIX + name.lower() field_value = self._decode_ext_desc(value_type, fh.read(value_len)) if field_value is not None: if field_name in {'track', 'disc'}: From 675f9cca3ba065e7d4b51f64ad85fd62fe2e972c Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Mar 2024 22:15:36 +0200 Subject: [PATCH 160/305] Expose MIME type for images --- README.md | 1 + tinytag/tests/test_all.py | 17 ++++++++++++++++- tinytag/tinytag.py | 34 +++++++++++++++++++++++++--------- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ab55b06..6db556b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ The following less common image types are provided in an `extra` dict: The following image attributes are available: data # image data as bytes + mime_type # image MIME type as string description # image description as string To receive a common image, e.g. `front_cover`: diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 34c5181..4f0dd20 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -738,19 +738,34 @@ def test_invalid_file(path: str, cls: type[TinyTag]) -> None: ('samples/12oz.mp3', 2210), ('samples/iso8859_with_image.m4a', 21963), ('samples/flac_with_image.flac', 73246), - ('samples/ogg_with_image.ogg', 1220), ('samples/wav_with_image.wav', 4627), ('samples/aiff_with_image.aiff', 21963), ]) def test_image_loading(path: str, expected_size: int) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) + image = tag.images.front_cover + if image.data is None: + image = tag.images.other image_data = tag.get_image() assert image_data is not None + assert image_data == image.data image_size = len(image_data) assert image_size == expected_size, \ f'Image is {image_size} bytes but should be {expected_size} bytes' assert image_data.startswith(b'\xff\xd8\xff\xe0'), \ 'The image data must start with a jpeg header' + assert image.mime_type == 'image/jpeg' + + +@pytest.mark.parametrize('path', [ + 'samples/ogg_with_image.ogg', +]) +def test_image_loading_extra(path: str) -> None: + tag = TinyTag.get(os.path.join(testfolder, path), image=True) + image = tag.images.extra['bright_colored_fish'] + assert image.data is not None + assert image.mime_type == 'image/jpeg' + assert len(image.data) == 1220 @pytest.mark.xfail(raises=TinyTagException) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3d1bde4..b71fb49 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -311,15 +311,16 @@ def __init__(self) -> None: self.leaflet: TagImage = image self.media: TagImage = image self.other: TagImage = image - self.extra: dict[str, bytes | str] = {} + self.extra: dict[str, TagImage] = {} def __repr__(self) -> str: return str(vars(self)) class TagImage: - def __init__(self, data: bytes | None = None) -> None: + def __init__(self, data: bytes | None = None, mime_type: str | None = None) -> None: self.data = data + self.mime_type = mime_type self.description: str | None = None def __repr__(self) -> str: @@ -369,8 +370,8 @@ def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | TagImage 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS # 16: duration in millis - 13: TagImage, # JPEG - 14: TagImage, # PNG + 13: lambda x: TagImage(x, 'image/jpeg'), # JPEG + 14: lambda x: TagImage(x, 'image/png'), # PNG 21: cls._unpack_integer, # BE Signed int 22: cls._unpack_integer_unsigned, # BE Unsigned int # 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 @@ -671,6 +672,11 @@ class _ID3(TinyTag): 'Neoclassical', 'Audiobook', 'Audio Theatre', 'Neue Deutsche Welle', 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', ) + _ID3V2_2_IMAGE_FORMATS = { + 'bmp': 'image/bmp', + 'jpg': 'image/jpeg', + 'png': 'image/png', + } _IMAGE_TYPES = ( 'other', 'extra.icon', @@ -975,9 +981,15 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: # See section 4.14: http://id3.org/id3v2.4.0-frames encoding = content[0:1] if frame_id == 'PIC': # ID3 v2.2: + imgformat = self._decode_string(content[1:4]).lower() + mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat) desc_start_pos = 1 + 3 + 1 # skip encoding (1), imgformat (3), pictype(1) else: # ID3 v2.3+ - desc_start_pos = content.index(b'\x00', 1) + 1 + 1 # skip mtype, pictype(1) + mime_type_end_pos = content.index(b'\x00', 1) + mime_type = self._decode_string(content[1:mime_type_end_pos]).lower() + if mime_type in self._ID3V2_2_IMAGE_FORMATS: # ID3 v2.2 format in v2.3... + mime_type = self._ID3V2_2_IMAGE_FORMATS[mime_type] + desc_start_pos = mime_type_end_pos + 1 + 1 # skip mtype, pictype(1) pictype = content[desc_start_pos - 1] # latin1 and utf-8 are 1 byte termination = b'\x00' if encoding in {b'\x00', b'\x03'} else b'\x00\x00' @@ -987,7 +999,9 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: image = TagImage(content[desc_end_pos:]) if description: image.description = description - if 0 <= pictype <= len(self._IMAGE_TYPES): + if mime_type: + image.mime_type = mime_type + if pictype < 0 or pictype > len(self._IMAGE_TYPES): pictype = 0 # fall back to 'other' type self._set_image_field(self._IMAGE_TYPES[pictype], image) elif frame_id not in self._DISALLOWED_FRAME_IDS: @@ -1411,17 +1425,19 @@ def _parse_tag(self, fh: BinaryIO) -> None: @classmethod def _parse_image(cls, fh: BinaryIO) -> tuple[str, TagImage]: # https://xiph.org/flac/format.html#metadata_block_picture - pic_type, mime_len = struct.unpack('>2I', fh.read(8)) - fh.read(mime_len) + pic_type, mime_type_len = struct.unpack('>2I', fh.read(8)) + mime_type = fh.read(mime_type_len).decode('utf-8') description_len = struct.unpack('>I', fh.read(4))[0] description = fh.read(description_len).decode('utf-8') _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) - if 0 <= pic_type <= len(_ID3._IMAGE_TYPES): + if pic_type < 0 or pic_type > len(_ID3._IMAGE_TYPES): pic_type = 0 # fall back to 'other' type field_name = _ID3._IMAGE_TYPES[pic_type] image = TagImage(fh.read(pic_len)) if description: image.description = description + if mime_type: + image.mime_type = mime_type return field_name, image From 7a18b8a7fee6546af528d27a7db283dc31f1378b Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Mar 2024 22:46:11 +0200 Subject: [PATCH 161/305] Re-enable coverage report --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a0cfaa..012d09f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -45,12 +45,12 @@ jobs: run: python -m build - name: Coverage report - if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.12' + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' run: coverage lcov - name: Coveralls uses: coverallsapp/github-action@master - if: matrix.os == 'ubuntu-20.04' && matrix.python == '3.12' + if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' with: github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov From 52f0689ee5e0f32060413c863ed904a8a7c3a4e2 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Mar 2024 22:53:30 +0200 Subject: [PATCH 162/305] Tests: verify that get_image() is None for extra --- tinytag/tests/test_all.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 4f0dd20..5665b44 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -764,6 +764,7 @@ def test_image_loading_extra(path: str) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) image = tag.images.extra['bright_colored_fish'] assert image.data is not None + assert tag.get_image() is None # only present for cover-like images assert image.mime_type == 'image/jpeg' assert len(image.data) == 1220 From ce6c566d1cc1386a83f2f76d3d133fe15d6114e9 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Mar 2024 23:07:29 +0200 Subject: [PATCH 163/305] Store unknown images in 'extra.unknown' --- tinytag/tinytag.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b71fb49..ac4a143 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -700,6 +700,7 @@ class _ID3(TinyTag): 'extra.band_logo', 'extra.publisher_logo', ) + _UNKNOWN_IMAGE_TYPE = 'extra.unknown' # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm @@ -914,6 +915,19 @@ def __parse_custom_field(self, content: str) -> bool: return True return False + @classmethod + def _create_tag_image(cls, data: bytes, pic_type: int, mime_type: str | None = None, + description: str | None = None) -> tuple[str, TagImage]: + image = TagImage(data) + if mime_type: + image.mime_type = mime_type + if description: + image.description = description + field_name = cls._UNKNOWN_IMAGE_TYPE + if 0 <= pic_type <= len(cls._IMAGE_TYPES): + field_name = cls._IMAGE_TYPES[pic_type] + return field_name, image + @staticmethod def _index_utf16(s: bytes, search: bytes) -> int: for i in range(0, len(s), len(search)): @@ -990,20 +1004,15 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: if mime_type in self._ID3V2_2_IMAGE_FORMATS: # ID3 v2.2 format in v2.3... mime_type = self._ID3V2_2_IMAGE_FORMATS[mime_type] desc_start_pos = mime_type_end_pos + 1 + 1 # skip mtype, pictype(1) - pictype = content[desc_start_pos - 1] + pic_type = content[desc_start_pos - 1] # latin1 and utf-8 are 1 byte termination = b'\x00' if encoding in {b'\x00', b'\x03'} else b'\x00\x00' desc_length = self._index_utf16(content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_length + len(termination) description = self._decode_string(content[desc_start_pos:desc_end_pos]) - image = TagImage(content[desc_end_pos:]) - if description: - image.description = description - if mime_type: - image.mime_type = mime_type - if pictype < 0 or pictype > len(self._IMAGE_TYPES): - pictype = 0 # fall back to 'other' type - self._set_image_field(self._IMAGE_TYPES[pictype], image) + field_name, image = self._create_tag_image( + content[desc_end_pos:], pic_type, mime_type, description) + self._set_image_field(field_name, image) elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: @@ -1430,15 +1439,7 @@ def _parse_image(cls, fh: BinaryIO) -> tuple[str, TagImage]: description_len = struct.unpack('>I', fh.read(4))[0] description = fh.read(description_len).decode('utf-8') _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) - if pic_type < 0 or pic_type > len(_ID3._IMAGE_TYPES): - pic_type = 0 # fall back to 'other' type - field_name = _ID3._IMAGE_TYPES[pic_type] - image = TagImage(fh.read(pic_len)) - if description: - image.description = description - if mime_type: - image.mime_type = mime_type - return field_name, image + return _ID3._create_tag_image(fh.read(pic_len), pic_type, mime_type, description) class _Wma(TinyTag): From 881153c8a271931d8fdeb7d68a81d5fff81e5794 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Mar 2024 23:08:52 +0200 Subject: [PATCH 164/305] README.md: add 'unknown' image type --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6db556b..2a0b2d5 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,7 @@ The following less common image types are provided in an `extra` dict: illustration band_logo publisher_logo + unknown The following image attributes are available: From 36104b94c5f5cd5aadb9bdd314a3c566909d2b0b Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Mar 2024 23:33:48 +0200 Subject: [PATCH 165/305] Tests: remove function to load custom samples Doesn't seem to be worth keeping this feature around, especially when you have to manually rename files. I've never used it personally. --- tinytag/tests/custom_samples/instructions.txt | 21 ----------- tinytag/tests/test_all.py | 35 ------------------- 2 files changed, 56 deletions(-) delete mode 100644 tinytag/tests/custom_samples/instructions.txt diff --git a/tinytag/tests/custom_samples/instructions.txt b/tinytag/tests/custom_samples/instructions.txt deleted file mode 100644 index 4f6dd56..0000000 --- a/tinytag/tests/custom_samples/instructions.txt +++ /dev/null @@ -1,21 +0,0 @@ -You can easily check if tinytag does the right thing by placing test files in -this folder. - -The name of the test file will automatically create a test, when running pytest. - -For example the file - - some-funky-name-d=10.5-sr=44100.mp3 - -will run a test that checks if the file has a duration of 10.5 and a samplerate -of 44100 seconds. - -These are the prefixes that can be used for the expected values: - - sr=samplerate - d=duration - b=bitrate - c=channels - dn=disc number - dt=disc total - genre="genre string" diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5665b44..0d1e94c 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -13,7 +13,6 @@ import io import os import pathlib -import re import shutil import sys @@ -544,40 +543,6 @@ testfolder = os.path.join(os.path.dirname(__file__)) -def load_custom_samples() -> dict[str, dict[str, Any]]: - retval = {} - custom_samples_folder = os.path.join(testfolder, 'custom_samples') - pattern_field_name_type = [ - (r'sr=(\d+)', 'samplerate', int), - (r'dn=(\d+)', 'disc', str), - (r'dt=(\d+)', 'disc_total', str), - (r'd=(\d+.?\d*)', 'duration', float), - (r'b=(\d+)', 'bitrate', int), - (r'c=(\d)', 'channels', int), - (r'genre="([^"]+)"', 'genre', str), - ] - for filename in os.listdir(custom_samples_folder): - if filename == 'instructions.txt': - continue - if os.path.isdir(os.path.join(custom_samples_folder, filename)): - continue - expected_values = {} - for pattern, fieldname, _type in pattern_field_name_type: - match = re.findall(pattern, filename) - if match: - expected_values[fieldname] = _type(match[0]) - if expected_values: - expected_values['_do_not_require_all_values'] = True - retval[os.path.join('custom_samples', filename)] = expected_values - else: - # if there are no expected values, just try parsing the file - retval[os.path.join('custom_samples', filename)] = {} - return retval - - -testfiles.update(load_custom_samples()) - - def compare_tag(results: dict[str, dict[str, Any]], expected: dict[str, dict[str, Any]], file: str, prev_path: str | None = None) -> None: def compare_values(path: str, result_val: int | float | str | dict[str, Any], From b635ec16bd8d56294672adcf8906f0bfb769c9a5 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 00:06:38 +0200 Subject: [PATCH 166/305] Update changelog for 2.0.0 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 2a0b2d5..673b45f 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,25 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ## Changelog +### 2.0.0 (Unreleased) + +- **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str +- **BREAKING:** Move 'composer' field to 'extra' dict +- **BREAKING:** Remove 'audio_offset' attribute +- **BREAKING:** TinyTagException no longer inherits LookupError +- **BREAKING:** TinyTag subclasses are now private +- **BREAKING:** Remove support for Python 2 +- Support multiple fields with the same name (separated with a null character) +- Provide access to custom metadata fields through the 'extra' dict +- Provide access to all available images +- Add more standard 'extra' fields +- FLAC: Apply ID3 tags after Vorbis +- OGG/WMA: set missing 'channels' field +- WMA: set missing 'extra.copyright' field +- WMA: raise exception if file is invalid +- Add type hints to codebase +- Various optimizations + ### 1.10.1 (2023-10-26) - Update 'extra' fields with data from other tags #188 From e294f10a8fdee7f89e85ca62a432a7efc312ffe9 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 00:11:06 +0200 Subject: [PATCH 167/305] Update changelog --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 673b45f..aaf07d4 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - **BREAKING:** Remove 'audio_offset' attribute - **BREAKING:** TinyTagException no longer inherits LookupError - **BREAKING:** TinyTag subclasses are now private +- **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 - Support multiple fields with the same name (separated with a null character) - Provide access to custom metadata fields through the 'extra' dict From c73fb50fa4be0b8a775230d4dd582595f8de5bbe Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 00:28:10 +0200 Subject: [PATCH 168/305] README.md: clarify that writing metadata will not be supported --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index aaf07d4..1e93e04 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ Alternatively you can use tinytag directly on the command line: Check `python -m tinytag --help` for all CLI options, for example other output formats. +Support for changing/writing metadata will not be added, use another library for this. + ### Supported Files To receive a tuple of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: From 55da9a2dc24ea6a301c8719a1535c8209eeea87b Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 01:38:06 +0200 Subject: [PATCH 169/305] Remove 'ignore_errors' parameter It only affected ID3 tags, other formats had inconsistent behavior. Things should 'just work', so replace any invalid characters. --- README.md | 1 + tinytag/tests/test_all.py | 5 ++--- tinytag/tinytag.py | 24 ++++++++++-------------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1e93e04..4908861 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - **BREAKING:** Remove 'audio_offset' attribute - **BREAKING:** TinyTagException no longer inherits LookupError - **BREAKING:** TinyTag subclasses are now private +- **BREAKING:** Remove 'ignore_errors' parameter for TinyTag.get() - **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 - Support multiple fields with the same name (separated with a null character) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0d1e94c..a754e3e 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -740,11 +740,10 @@ def test_mp3_utf_8_invalid_string_raises_exception() -> None: def test_mp3_utf_8_invalid_string_can_be_ignored() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3'), - ignore_errors=True) + tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) # the title used to be Gran dia, but I replaced the first byte with 0xFF, # which should be ignored here - assert tag.title == 'ran día' + assert tag.title == '�ran día' @pytest.mark.parametrize("testfile,expected", [ diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ac4a143..87b7fef 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -89,7 +89,6 @@ def __init__(self) -> None: self._filehandler: BinaryIO | None = None self._filename: bytes | str | PathLike[Any] | None = None # for debugging self._default_encoding: str | None = None # allow override for some file formats - self._ignore_errors = False self._parse_duration = True self._parse_tags = True self._load_image = False @@ -101,7 +100,6 @@ def get(cls, tags: bool = True, duration: bool = True, image: bool = False, - ignore_errors: bool = False, encoding: str | None = None, file_obj: BinaryIO | None = None) -> TinyTag: """Create a tag object for a file path or a file-like object.""" @@ -119,7 +117,6 @@ def get(cls, tag._filehandler = file_obj tag._filename = filename tag._default_encoding = encoding - tag._ignore_errors = ignore_errors tag.filesize = filesize if filesize > 0: try: @@ -849,7 +846,7 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: extended = False # for info on the specs, see: http://id3.org/Developer%20Information header = struct.unpack('3sBBB4B', fh.read(10)) - tag = header[0].decode('ISO-8859-1') + tag = header[0].decode('ISO-8859-1', 'replace') # check if there is an ID3v2 tag at the beginning of the file if tag == 'ID3': major, _rev = header[1:3] @@ -884,7 +881,7 @@ def _parse_id3v1(self, fh: BinaryIO) -> None: return def asciidecode(x: bytes) -> str: - return self._unpad(x.decode(self._default_encoding or 'latin1')) + return self._unpad(x.decode(self._default_encoding or 'latin1', 'replace')) # Only set fields that were not set by ID3v2 tags, as ID3v1 # tags are more likely to be outdated or have encoding issues fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) @@ -1058,8 +1055,7 @@ def _decode_string(self, bytestr: bytes, language: bool = False) -> str: encoding = default_encoding # wild guess if language and bytestr[:3].isalpha(): bytestr = bytestr[3:] # remove language - errors = 'ignore' if self._ignore_errors else 'strict' - return self._unpad(bytestr.decode(encoding, errors)) + return self._unpad(bytestr.decode(encoding, 'replace')) @staticmethod def _calc_size(bytestr: tuple[int, ...], bits_per_byte: int) -> int: @@ -1179,7 +1175,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif check_speex_second_packet: if self._parse_tags: length = struct.unpack('I', walker.read(4))[0] # starts with a comment string - comment = walker.read(length).decode('UTF-8') + comment = walker.read(length).decode('utf-8', 'replace') self._set_field('comment', comment) self._parse_vorbis_comment(walker, contains_vendor=False) # other tags check_speex_second_packet = False @@ -1199,7 +1195,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N elements = struct.unpack('I', fh.read(4))[0] for _i in range(elements): length = struct.unpack('I', fh.read(4))[0] - keyvalpair = fh.read(length).decode('utf-8', 'ignore') + keyvalpair = fh.read(length).decode('utf-8', 'replace') if '=' in keyvalpair: key, value = keyvalpair.split('=', 1) key_lowercase = key.lower() @@ -1324,7 +1320,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte fieldname = self._RIFF_MAPPING.get(field) if fieldname: - value = data.decode('utf-8') + value = data.decode('utf-8', 'replace') if fieldname == 'track': if value.isdecimal(): self._set_field(fieldname, int(value)) @@ -1435,9 +1431,9 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _parse_image(cls, fh: BinaryIO) -> tuple[str, TagImage]: # https://xiph.org/flac/format.html#metadata_block_picture pic_type, mime_type_len = struct.unpack('>2I', fh.read(8)) - mime_type = fh.read(mime_type_len).decode('utf-8') + mime_type = fh.read(mime_type_len).decode('utf-8', 'replace') description_len = struct.unpack('>I', fh.read(4))[0] - description = fh.read(description_len).decode('utf-8') + description = fh.read(description_len).decode('utf-8', 'replace') _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) return _ID3._create_tag_image(fh.read(pic_len), pic_type, mime_type, description) @@ -1475,7 +1471,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: self._parse_tag(fh) def _decode_string(self, bytestring: bytes) -> str: - return self._unpad(bytestring.decode('utf-16')) + return self._unpad(bytestring.decode('utf-16', 'replace')) def _decode_ext_desc(self, value_type: int, value: bytes) -> int | str | None: """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" @@ -1622,7 +1618,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: sub_chunk_id, sub_chunk_size = struct.unpack('>4sI', chunk_header) sub_chunk_size += sub_chunk_size % 2 # IFF chunks are padded to an even number of bytes if sub_chunk_id in self._AIFF_MAPPING and self._parse_tags: - value = self._unpad(fh.read(sub_chunk_size).decode('utf-8')) + value = self._unpad(fh.read(sub_chunk_size).decode('utf-8', 'replace')) self._set_field(self._AIFF_MAPPING[sub_chunk_id], value) elif sub_chunk_id == b'COMM' and self._parse_duration: channels, num_frames, bitdepth = struct.unpack('>hLh', fh.read(8)) From 9503c27e6956ef239d559bbe92193c85d0da2837 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 04:47:06 +0200 Subject: [PATCH 170/305] Add a few extra fields --- README.md | 3 +++ tinytag/tinytag.py | 31 ++++++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4908861..4fd6d5c 100644 --- a/README.md +++ b/README.md @@ -92,13 +92,16 @@ The following standard `extra` field names are used when file formats provide re bpm composer + conductor copyright director initial_key isrc language + lyricist lyrics publisher + set_subtitle url Any other `extra` field names are not guaranteed to be consistent across audio formats. diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 87b7fef..e9a5355 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -335,6 +335,14 @@ class _MP4(TinyTag): class _Parser: atom_decoder_by_type: dict[ int, Callable[[bytes], int | str | bytes | TagImage]] | None = None + _CUSTOM_FIELD_NAME_MAPPING = { + 'conductor': 'conductor', + 'discsubtitle': 'set_subtitle', + 'initialkey': 'initial_key', + 'isrc': 'isrc', + 'language': 'language', + 'lyricist': 'lyricist', + } @classmethod def _unpack_integer(cls, value: bytes, signed: bool = True) -> int: @@ -430,7 +438,11 @@ def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | TagIm atom_type = atom_header[4:] if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() - field_name = TinyTag._EXTRA_PREFIX + atom_value.decode('utf-8', 'replace') + field_name = atom_value.decode('utf-8', 'replace') + field_name = ( + TinyTag._EXTRA_PREFIX + + cls._CUSTOM_FIELD_NAME_MAPPING.get(field_name, field_name) + ) elif atom_type == b'data': data_atom = fh.read(atom_size) else: @@ -508,6 +520,7 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: b'\xa9ART': {b'data': _Parser._make_data_atom_parser('artist')}, b'\xa9alb': {b'data': _Parser._make_data_atom_parser('album')}, b'\xa9cmt': {b'data': _Parser._make_data_atom_parser('comment')}, + b'\xa9con': {b'data': _Parser._make_data_atom_parser('extra.conductor')}, # need test-data for this # b'cpil': {b'data': _Parser._make_data_atom_parser('extra.compilation')}, b'\xa9day': {b'data': _Parser._make_data_atom_parser('year')}, @@ -617,6 +630,9 @@ class _ID3(TinyTag): 'TLAN': 'extra.language', 'TLA': 'extra.language', 'TPUB': 'extra.publisher', 'TPB': 'extra.publisher', 'USLT': 'extra.lyrics', 'ULT': 'extra.lyrics', + 'TPE3': 'extra.conductor', 'TP3': 'extra.conductor', + 'TEXT': 'extra.lyricist', 'TXT': 'extra.lyricist', + 'TSST': 'extra.set_subtitle', } _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} @@ -1090,6 +1106,12 @@ class _Ogg(TinyTag): 'language': 'extra.language', 'director': 'extra.director', 'website': 'extra.url', + 'conductor': 'extra.conductor', + 'lyricist': 'extra.lyricist', + 'discsubtitle': 'extra.set_subtitle', + 'setsubtitle': 'extra.set_subtitle', + 'initialkey': 'extra.initial_key', + 'key': 'extra.initial_key', } def __init__(self) -> None: @@ -1267,10 +1289,9 @@ class _Wave(TinyTag): b'IPRT': 'track', b'ITRK': 'track', b'TRCK': 'track', - b'PRT1': 'track', - b'PRT2': 'track_number', b'IBSU': 'extra.url', b'YEAR': 'year', + b'IWRI': 'extra.lyricist', } def _determine_duration(self, fh: BinaryIO) -> None: @@ -1457,6 +1478,10 @@ class _Wma(TinyTag): 'WM/Lyrics': 'extra.lyrics', 'WM/Language': 'extra.language', 'WM/AuthorURL': 'extra.url', + 'WM/ISRC': 'extra.isrc', + 'WM/Conductor': 'extra.conductor', + 'WM/Writer': 'extra.lyricist', + 'WM/SetSubTitle': 'extra.set_subtitle', } _ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' From 011a56c50aaa2f011907ae536a21a4e57fa1e95f Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 05:31:20 +0200 Subject: [PATCH 171/305] Simplify get_image() --- README.md | 2 +- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 19 +++++++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4fd6d5c..935de65 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ The following standard `extra` field names are used when file formats provide re Any other `extra` field names are not guaranteed to be consistent across audio formats. -Additionally, you can also get images from ID3 tags. To receive an image most likely suitable as the front cover: +Additionally, you can also get images from ID3 tags. To receive any available image, prioritizing the front cover: tag: TinyTag = TinyTag.get('/some/music.mp3', image=True) image_data: bytes = tag.get_image() diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index a754e3e..1a93926 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -729,7 +729,7 @@ def test_image_loading_extra(path: str) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) image = tag.images.extra['bright_colored_fish'] assert image.data is not None - assert tag.get_image() is None # only present for cover-like images + assert tag.get_image() == image.data assert image.mime_type == 'image/jpeg' assert len(image.data) == 1220 diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e9a5355..4bc9306 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -129,16 +129,15 @@ def get(cls, file_obj.close() def get_image(self) -> bytes | None: - """Return the closest equivalent to a cover image as bytes.""" - front_cover = self.images.front_cover.data # check the obvious location - if front_cover is not None: - return front_cover - other = self.images.other.data # cover images are sometimes stored here - if other is not None: - return other - back_cover = self.images.back_cover.data # last resort, maybe there's something here... - if back_cover is not None: - return back_cover + """Return a cover image as bytes. + If not present, fall back to any other available image. + """ + for value in self.images.__dict__.values(): + if isinstance(value, TagImage) and value.data is not None: + return value.data + for extra_value in self.images.extra.values(): + if extra_value.data is not None: + return extra_value.data return None @classmethod From d427fec682957fe78b3619285b473f42e1e33bc4 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 07:06:03 +0200 Subject: [PATCH 172/305] Add more fine-grained exceptions --- README.md | 6 ++++++ tinytag/__init__.py | 10 +++++++-- tinytag/tests/test_all.py | 29 +++++++++++-------------- tinytag/tests/test_cli.py | 8 +++---- tinytag/tinytag.py | 45 +++++++++++++++++++++++++-------------- 5 files changed, 59 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 935de65..f43e68a 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a TinyTag.get(file_obj=your_file_obj) +### Exceptions + + TinyTagException # Base class for exceptions + ParseError # Parsing an audio file failed + UnsupportedFormatError # File format is not supported + ## Changelog diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 26c8208..59fc7a2 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,2 +1,8 @@ -# pylint: disable=missing-module-docstring -from .tinytag import TinyTag, TagImage, TagImages, TinyTagException +"""Audio file metadata reader""" + +from .tinytag import ( + ParseError, TinyTag, TagImage, TagImages, TinyTagException, UnsupportedFormatError +) +__all__ = [ + "ParseError", "TinyTag", "TagImage", "TagImages", "TinyTagException", "UnsupportedFormatError" +] diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 1a93926..2091751 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -644,10 +644,10 @@ def test_binary_path_compatibility() -> None: assert not os.path.exists(binary_file_path) -@pytest.mark.xfail(raises=TinyTagException) def test_unsupported_extension() -> None: bogus_file = os.path.join(testfolder, 'samples/there_is_no_such_ext.bogus') - TinyTag.get(bogus_file) + with pytest.raises(TinyTagException): + TinyTag.get(bogus_file) def test_override_encoding() -> None: @@ -657,22 +657,22 @@ def test_override_encoding() -> None: assert tag.album == '角落之歌' -@pytest.mark.xfail(raises=TinyTagException) def test_unsubclassed_tinytag_load() -> None: tag = TinyTag() tag._load(tags=True, duration=True) + assert not tag._tags_parsed -@pytest.mark.xfail(raises=NotImplementedError) def test_unsubclassed_tinytag_duration() -> None: tag = TinyTag() - tag._determine_duration(None) # type: ignore + with pytest.raises(NotImplementedError): + tag._determine_duration(None) # type: ignore -@pytest.mark.xfail(raises=NotImplementedError) def test_unsubclassed_tinytag_parse_tag() -> None: tag = TinyTag() - tag._parse_tag(None) # type: ignore + with pytest.raises(NotImplementedError): + tag._parse_tag(None) # type: ignore def test_mp3_length_estimation() -> None: @@ -690,9 +690,9 @@ def test_mp3_length_estimation() -> None: ('samples/flac1.5sStereo.flac', _Wma), ('samples/ilbm.aiff', _Aiff), ]) -@pytest.mark.xfail(raises=TinyTagException) def test_invalid_file(path: str, cls: type[TinyTag]) -> None: - cls.get(os.path.join(testfolder, path)) + with pytest.raises(TinyTagException): + cls.get(os.path.join(testfolder, path)) @pytest.mark.parametrize('path,expected_size', [ @@ -734,12 +734,7 @@ def test_image_loading_extra(path: str) -> None: assert len(image.data) == 1220 -@pytest.mark.xfail(raises=TinyTagException) -def test_mp3_utf_8_invalid_string_raises_exception() -> None: - TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) - - -def test_mp3_utf_8_invalid_string_can_be_ignored() -> None: +def test_mp3_utf_8_invalid_string() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) # the title used to be Gran dia, but I replaced the first byte with 0xFF, # which should be ignored here @@ -766,9 +761,9 @@ def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None: def test_show_hint_for_wrong_usage() -> None: - with pytest.raises(TinyTagException) as exc_info: + with pytest.raises(ValueError) as exc_info: TinyTag.get() - assert exc_info.type == TinyTagException + assert exc_info.type == ValueError assert exc_info.value.args[0] == 'Either filename or file_obj argument is required' diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index de77b2c..bebd7cc 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -33,9 +33,9 @@ def file_size(filename: str) -> int: return os.stat(filename).st_size -@pytest.mark.xfail(raises=CalledProcessError) def test_wrong_params() -> None: - assert 'tinytag [options] None: @@ -108,9 +108,9 @@ def test_meta_data_output_format_tabularcsv() -> None: assert set(header.split(',')) == tinytag_attributes -@pytest.mark.xfail(raises=CalledProcessError) def test_fail_on_unsupported_file() -> None: - run_cli(bogus_file) + with pytest.raises(CalledProcessError): + run_cli(bogus_file) def test_fail_skip_unsupported_file_long_opt() -> None: diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4bc9306..6b9c0d6 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,4 +1,4 @@ -# tinytag - an audio meta info reader +# tinytag - an audio file metadata reader # Copyright (c) 2014-2023 Tom Wallroth # Copyright (c) 2021-2024 Mat (mathiascode) # @@ -27,7 +27,8 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -# pylint: disable=missing-module-docstring,missing-class-docstring +"""Audio file metadata reader""" + # pylint: disable=invalid-name,protected-access # pylint: disable=too-many-lines,too-many-arguments,too-many-boolean-expressions # pylint: disable=too-many-branches,too-many-instance-attributes,too-many-locals @@ -47,14 +48,25 @@ import re import struct + DEBUG = bool(os.environ.get('DEBUG')) # some of the parsers can print debug info class TinyTagException(Exception): - pass + """Base class for exceptions.""" + + +class ParseError(TinyTagException): + """Parsing an audio file failed.""" + + +class UnsupportedFormatError(TinyTagException): + """File format is not supported.""" class TinyTag: + """A class containing audio file metadata.""" + SUPPORTED_FILE_EXTENSIONS = ( '.mp1', '.mp2', '.mp3', '.oga', '.ogg', '.opus', '.spx', @@ -102,12 +114,12 @@ def get(cls, image: bool = False, encoding: str | None = None, file_obj: BinaryIO | None = None) -> TinyTag: - """Create a tag object for a file path or a file-like object.""" + """Return a tag object for an audio file.""" should_close_file = file_obj is None if filename and should_close_file: file_obj = open(filename, 'rb') # pylint: disable=consider-using-with if file_obj is None: - raise TinyTagException('Either filename or file_obj argument is required') + raise ValueError('Either filename or file_obj argument is required') try: file_obj.seek(0, os.SEEK_END) filesize = file_obj.tell() @@ -122,7 +134,7 @@ def get(cls, try: tag._load(tags=tags, duration=duration, image=image) except Exception as exc: - raise TinyTagException(f'Failed to parse file: {exc}') from exc + raise ParseError(exc) from exc return tag finally: if should_close_file: @@ -142,7 +154,7 @@ def get_image(self) -> bytes | None: @classmethod def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: - """Check if a specific file is supported based on the file extension.""" + """Check if a specific file is supported based on its file extension.""" return cls._get_parser_for_filename(filename) is not None def __repr__(self) -> str: @@ -219,14 +231,14 @@ def _get_parser_class(cls, filename: bytes | str | PathLike[Any] | None = None, parser_class = cls._get_parser_for_file_handle(filehandle) if parser_class is not None: return parser_class - raise TinyTagException('No tag reader found to support filetype') + raise UnsupportedFormatError('No tag reader found to support file type') def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._parse_tags = tags self._parse_duration = duration self._load_image = image - if not self._filehandler: - raise TinyTagException('No file object set') + if self._filehandler is None: + return if tags: self._parse_tag(self._filehandler) if duration: @@ -235,7 +247,6 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._determine_duration(self._filehandler) def _set_field(self, fieldname: str, value: str | int | float) -> None: - """convenience function to set fields of the tinytag by name""" is_str = isinstance(value, str) if is_str and not value: # don't set empty value @@ -300,6 +311,7 @@ def _unpad(s: str) -> str: class TagImages: + """A class containing images embedded in an audio file.""" def __init__(self) -> None: image = TagImage() self.front_cover: TagImage = image @@ -314,6 +326,7 @@ def __repr__(self) -> str: class TagImage: + """A class representing an image embedded in an audio file.""" def __init__(self, data: bytes | None = None, mime_type: str | None = None) -> None: self.data = data self.mime_type = mime_type @@ -1251,7 +1264,7 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: oggs, version, _flags, pos, _serial, _pageseq, _crc, segments = header self._max_samplenum = max(self._max_samplenum, pos) if oggs != b'OggS' or version != 0: - raise TinyTagException('Invalid OGG file') + raise ParseError('Invalid OGG header') segsizes = struct.unpack('B' * segments, fh.read(segments)) total = 0 for segsize in segsizes: # read all segments @@ -1302,7 +1315,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: # and: https://en.wikipedia.org/wiki/WAV riff, _size, fformat = struct.unpack('4sI4s', fh.read(12)) if riff != b'RIFF' or fformat != b'WAVE': - raise TinyTagException('Invalid WAV file') + raise ParseError('Invalid WAV header') if self._parse_duration: self.bitdepth = 16 # assume 16bit depth (CD quality) chunk_header = fh.read(8) @@ -1383,7 +1396,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: id3._parse_id3v2(fh) header = fh.read(4) # after ID3 should be fLaC if header[:4] != b'fLaC': - raise TinyTagException('Invalid FLAC file') + raise ParseError('Invalid FLAC header') # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data) == 4: @@ -1511,7 +1524,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc521913958 if (header[:16] != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' # 128 bit GUID or header[-1:] != b'\x02'): - raise TinyTagException('Invalid WMA file') + raise ParseError('Invalid WMA header') while True: object_id = fh.read(16) object_size = self._bytes_to_int_le(fh.read(8)) @@ -1636,7 +1649,7 @@ class _Aiff(TinyTag): def _parse_tag(self, fh: BinaryIO) -> None: chunk_id, _size, form = struct.unpack('>4sI4s', fh.read(12)) if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): - raise TinyTagException('Invalid AIFF file') + raise ParseError('Invalid AIFF header') chunk_header = fh.read(8) while len(chunk_header) == 8: sub_chunk_id, sub_chunk_size = struct.unpack('>4sI', chunk_header) From 8e53c704db0afb56f98f6f46ab7a9c104c40defb Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 07:52:59 +0200 Subject: [PATCH 173/305] Improve order of fields in output --- tinytag/__init__.py | 4 ++-- tinytag/tests/test_all.py | 25 ++++++++++++++----------- tinytag/tinytag.py | 28 ++++++++++++++-------------- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 59fc7a2..0e62032 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -3,6 +3,6 @@ from .tinytag import ( ParseError, TinyTag, TagImage, TagImages, TinyTagException, UnsupportedFormatError ) -__all__ = [ +__all__ = ( "ParseError", "TinyTag", "TagImage", "TagImages", "TinyTagException", "UnsupportedFormatError" -] +) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 2091751..f934f01 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -769,14 +769,17 @@ def test_show_hint_for_wrong_usage() -> None: def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) - assert str(tag) == ( - "{'album': 'Hymns for the Exiled', 'albumartist': None, 'artist': 'Anais Mitchell', " - "'bitdepth': None, 'bitrate': 160.0, 'channels': 2, " - "'comment': 'Waterbug Records, www.anaismitchell.com', 'disc': None, " - "'disc_total': None, 'duration': 0.13836297152858082, 'extra': {'ten': 'iTunes v4.6', " - "'itunnorm': ' 0000044E 00000061 00009B67 000044C3 00022478 00022182 00007FCC " - "00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+174405+11+150+14097+27391+43983+" - "65786+84877+99399+113226+132452+146426+163829', 'itunes_cddb_tracknumber': '3'}, " - "'filesize': 5120, " - "'genre': None, 'samplerate': 44100, 'title': 'cosmic american', 'track': 3, " - "'track_total': 11, 'year': '2004'}") + assert str(tag).startswith( + "{'artist': 'Anais Mitchell', 'albumartist': None, 'album': 'Hymns for the Exiled', " + "'disc': None, 'disc_total': None, 'title': 'cosmic american', 'track': 3, " + "'track_total': 11, 'genre': None, 'year': '2004', 'comment': 'Waterbug Records, " + "www.anaismitchell.com', 'duration': 0.13836297152858082, 'filesize': 5120, " + "'channels': 2, 'bitrate': 160.0, 'bitdepth': None, 'samplerate': 44100, " + "'extra': {'ten': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " + "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': " + "'9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" + "163829', 'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'data': None, " + "'mime_type': None, 'description': None}, 'back_cover': {'data': None, " + "'mime_type': None, 'description': None}, 'leaflet': {'data': None, 'mime_type': None, " + "'description': None}, 'media': {'data': None, 'mime_type': None, 'description': None}, " + "'other': {'data': None, 'mime_type': None, 'description': None}, 'extra': {}}, ") diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 6b9c0d6..68568c2 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -79,25 +79,25 @@ class TinyTag: _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None def __init__(self) -> None: - self.album: str | None = None - self.albumartist: str | None = None self.artist: str | None = None - self.bitrate: float | None = None - self.channels: int | None = None - self.comment: str | None = None + self.albumartist: str | None = None + self.album: str | None = None self.disc: int | None = None self.disc_total: int | None = None - self.duration: float | None = None - self.extra: dict[str, str | float | int] = {} - self.filesize = 0 - self.genre: str | None = None - self.images = TagImages() - self.samplerate: int | None = None - self.bitdepth: int | None = None self.title: str | None = None self.track: int | None = None self.track_total: int | None = None + self.genre: str | None = None self.year: str | None = None + self.comment: str | None = None + self.duration: float | None = None + self.filesize = 0 + self.channels: int | None = None + self.bitrate: float | None = None + self.bitdepth: int | None = None + self.samplerate: int | None = None + self.extra: dict[str, str | float | int] = {} + self.images = TagImages() self._filehandler: BinaryIO | None = None self._filename: bytes | str | PathLike[Any] | None = None # for debugging self._default_encoding: str | None = None # allow override for some file formats @@ -158,11 +158,11 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: return cls._get_parser_for_filename(filename) is not None def __repr__(self) -> str: - return str(self._as_dict()) + return str(vars(self)) def _as_dict(self) -> dict[str, Any]: return { - k: v for k, v in sorted(self.__dict__.items()) + k: v for k, v in self.__dict__.items() if not k.startswith('_') and k != 'images' } From cff4e2402f0336dbba3f077ec46c2ba6428f44b4 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 08:03:11 +0200 Subject: [PATCH 174/305] Show audio attributes before tags --- tinytag/__main__.py | 3 +-- tinytag/tests/test_all.py | 36 ++++++++++++++++++++---------------- tinytag/tinytag.py | 16 ++++++++-------- 3 files changed, 29 insertions(+), 26 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index f900ba7..b2c7985 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -44,8 +44,7 @@ def _pop_switch(name: str) -> bool: def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool: - data = {'filename': tag._filename} - data.update(tag._as_dict()) + data = tag._as_dict() if formatting == 'json': print(json.dumps(data)) return header_printed diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index f934f01..513259c 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -579,7 +579,8 @@ def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True, duration=True) results = { - key: val for key, val in tag._as_dict().items() if val is not None and key != 'images' + key: val for key, val in tag._as_dict().items() + if val is not None and key not in ('filename', 'images') } compare_tag(results, expected, filename) assert tag.images.front_cover.data is None @@ -591,7 +592,8 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"} tag = TinyTag.get(filename, tags=True, duration=False) results = { - key: val for key, val in tag._as_dict().items() if val is not None and key != 'images' + key: val for key, val in tag._as_dict().items() + if val is not None and key not in ('filename', 'images') } expected = { key: val for key, val in expected.items() if key not in excluded_attrs @@ -606,7 +608,8 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False, duration=True) results = { - key: val for key, val in tag._as_dict().items() if val is not None and key != 'images' + key: val for key, val in tag._as_dict().items() + if val is not None and key not in ('filename', 'images') } expected = { key: val for key, val in expected.items() if key in allowed_attrs @@ -769,17 +772,18 @@ def test_show_hint_for_wrong_usage() -> None: def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) - assert str(tag).startswith( - "{'artist': 'Anais Mitchell', 'albumartist': None, 'album': 'Hymns for the Exiled', " - "'disc': None, 'disc_total': None, 'title': 'cosmic american', 'track': 3, " - "'track_total': 11, 'genre': None, 'year': '2004', 'comment': 'Waterbug Records, " - "www.anaismitchell.com', 'duration': 0.13836297152858082, 'filesize': 5120, " - "'channels': 2, 'bitrate': 160.0, 'bitdepth': None, 'samplerate': 44100, " + assert ( + "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " + "'bitdepth': None, 'samplerate': 44100, 'artist': 'Anais Mitchell', 'albumartist': None, " + "'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " + "'title': 'cosmic american', 'track': 3, 'track_total': 11, 'genre': None, " + "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " "'extra': {'ten': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " - "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': " - "'9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" - "163829', 'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'data': None, " - "'mime_type': None, 'description': None}, 'back_cover': {'data': None, " - "'mime_type': None, 'description': None}, 'leaflet': {'data': None, 'mime_type': None, " - "'description': None}, 'media': {'data': None, 'mime_type': None, 'description': None}, " - "'other': {'data': None, 'mime_type': None, 'description': None}, 'extra': {}}, ") + "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+" + "174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+163829', " + "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'data': None, " + "'mime_type': None, 'description': None}, 'back_cover': {'data': None, 'mime_type': None, " + "'description': None}, 'leaflet': {'data': None, 'mime_type': None, 'description': None}, " + "'media': {'data': None, 'mime_type': None, 'description': None}, 'other': {'data': None, " + "'mime_type': None, 'description': None}, 'extra': {}}, " + ) in str(tag) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 68568c2..fd3a7c0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -79,6 +79,13 @@ class TinyTag: _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None def __init__(self) -> None: + self.filename: bytes | str | PathLike[Any] | None = None + self.filesize = 0 + self.duration: float | None = None + self.channels: int | None = None + self.bitrate: float | None = None + self.bitdepth: int | None = None + self.samplerate: int | None = None self.artist: str | None = None self.albumartist: str | None = None self.album: str | None = None @@ -90,16 +97,9 @@ def __init__(self) -> None: self.genre: str | None = None self.year: str | None = None self.comment: str | None = None - self.duration: float | None = None - self.filesize = 0 - self.channels: int | None = None - self.bitrate: float | None = None - self.bitdepth: int | None = None - self.samplerate: int | None = None self.extra: dict[str, str | float | int] = {} self.images = TagImages() self._filehandler: BinaryIO | None = None - self._filename: bytes | str | PathLike[Any] | None = None # for debugging self._default_encoding: str | None = None # allow override for some file formats self._parse_duration = True self._parse_tags = True @@ -127,8 +127,8 @@ def get(cls, parser_class = cls._get_parser_class(filename, file_obj) tag = parser_class() tag._filehandler = file_obj - tag._filename = filename tag._default_encoding = encoding + tag.filename = filename tag.filesize = filesize if filesize > 0: try: From 457d829538a4bebd8978c4790df80022adcedfdd Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 08:07:07 +0200 Subject: [PATCH 175/305] Hide private attributes from string representation --- tinytag/__main__.py | 1 + tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 7 ++----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index b2c7985..238989d 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -45,6 +45,7 @@ def _pop_switch(name: str) -> bool: def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool: data = tag._as_dict() + del data['images'] if formatting == 'json': print(json.dumps(data)) return header_printed diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 513259c..7ad4369 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -785,5 +785,5 @@ def test_to_str() -> None: "'mime_type': None, 'description': None}, 'back_cover': {'data': None, 'mime_type': None, " "'description': None}, 'leaflet': {'data': None, 'mime_type': None, 'description': None}, " "'media': {'data': None, 'mime_type': None, 'description': None}, 'other': {'data': None, " - "'mime_type': None, 'description': None}, 'extra': {}}, " + "'mime_type': None, 'description': None}, 'extra': {}}" ) in str(tag) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index fd3a7c0..baee192 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -158,13 +158,10 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: return cls._get_parser_for_filename(filename) is not None def __repr__(self) -> str: - return str(vars(self)) + return str(self._as_dict()) def _as_dict(self) -> dict[str, Any]: - return { - k: v for k, v in self.__dict__.items() - if not k.startswith('_') and k != 'images' - } + return {k: v for k, v in self.__dict__.items() if not k.startswith('_')} @classmethod def _get_parser_for_filename( From b0e7ecc4cc943f27ede5b56ecb77c9580f7ae360 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 09:01:08 +0200 Subject: [PATCH 176/305] Add more extra fields --- README.md | 3 +++ tinytag/tests/test_all.py | 42 +++++++++++++++++++++------------------ tinytag/tinytag.py | 37 ++++++++++++++++++++++------------ 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f43e68a..3a3c474 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,14 @@ The following standard `extra` field names are used when file formats provide re conductor copyright director + encoded_by + encoder_settings initial_key isrc language lyricist lyrics + media publisher set_subtitle url diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 7ad4369..7baf707 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -40,13 +40,14 @@ {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731}), ('samples/vbr_xing_header_2channel.mp3', - {'extra': {'tsse': 'LAME 32bits version 3.99.5 (http://lame.sf.net)', 'tlen': '249976'}, + {'extra': {'encoder_settings': 'LAME 32bits version 3.99.5 (http://lame.sf.net)', + 'tlen': '249976'}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', - {'extra': {'ten': 'iTunes v4.6', + {'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': (' 0000044E 00000061 00009B67 000044C3 00022478 00022182 ' '00007FCC 00007E5C 0002245E 0002214E'), 'itunes_cddb_1': ('9D09130B+174405+11+150+14097+27391+43983+65786+84877+' @@ -76,8 +77,8 @@ 'ufid': 'http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5', 'publisher': '4AD', 'tdat': '1105', 'wxxx': 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet', - 'tmed': 'Digital', 'tlen': '203733', - 'tsse': 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)'}, + 'media': 'Digital', 'tlen': '203733', + 'encoder_settings': 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)'}, 'track_total': 11, 'track': 7, 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), @@ -107,11 +108,11 @@ {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), ('samples/id3v22_image.mp3', - {'extra': {'rva': '\x10', 'tbp': '131'}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, + {'extra': {'rva': '\x10', 'bpm': '131'}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', - {'extra': {'ten': 'iTunes 11.0.4', + {'extra': {'encoded_by': 'iTunes 11.0.4', 'itunnorm': (' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A 0000902E ' '00008D36 00020882 000321D6'), 'itunsmpb': (' 00000000 00000210 000007B9 00000000008FB737 00000000 008242F1 ' @@ -121,7 +122,7 @@ 'genre': 'Pop', 'title': 'Applause'}), ('samples/id3_comment_utf_16_with_bom.mp3', {'extra': {'copyright': '(c) 2008 nin', 'isrc': 'USTC40852229', 'bpm': '60', - 'url': 'www.nin.com', 'tenc': 'LAME 3.97'}, + 'url': 'www.nin.com', 'encoded_by': 'LAME 3.97'}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'disc': 1, 'disc_total': 2, 'title': '1 Ghosts I', 'track': 1, 'track_total': 36, @@ -146,7 +147,7 @@ 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), ('samples/nicotinetestdata.mp3', - {'extra': {'tsse': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2, + {'extra': {'encoder_settings': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2, 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), ('samples/chinese_id3.mp3', {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', @@ -154,7 +155,7 @@ 'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': 1}), ('samples/cut_off_titles.mp3', - {'extra': {'tsse': 'Lavf54.29.104'}, 'filesize': 1000, 'album': 'ERB', + {'extra': {'encoder_settings': 'Lavf54.29.104'}, 'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), @@ -175,7 +176,7 @@ 'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious', 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America', - 'composer': 'Billy Howerdel/Maynard James Keenan', 'tmed': 'CD', + 'composer': 'Billy Howerdel/Maynard James Keenan', 'media': 'CD', 'tso2': 'Perfect Circle, A', 'ufid': 'http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72', 'tsop': 'Perfect Circle, A', 'tory': '2004', 'tdat': '0211', @@ -402,10 +403,10 @@ {'extra': {'mcdi': ('2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5' '\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00\x00\x00ä7\x00\x10' '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'), - 'tlen': '297666', 'tenc': 'Exact Audio Copy (Sicherer Modus)', - 'tsse': ('flac.exe -T "artist=Unbekannter Künstler" -T "title=Track01" -T ' - '"album=Unbekannter Titel" -T "date=" -T "tracknumber=01" -T ' - '"genre=" -5')}, + 'tlen': '297666', 'encoded_by': 'Exact Audio Copy (Sicherer Modus)', + 'encoder_settings': ('flac.exe -T "artist=Unbekannter Künstler" -T ' + '"title=Track01" -T "album=Unbekannter Titel" -T ' + '"date=" -T "tracknumber=01" -T "genre=" -5')}, 'filesize': 19522, 'album': 'album\x00Unbekannter Titel', 'artist': 'artist\x00Unbekannter Künstler', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': 1, 'disc_total': 1, @@ -442,7 +443,7 @@ {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, 'duration': 43.133, 'channels': 2}), ('samples/wma_invalid_track_number.wma', - {'extra': {'encodingsettings': 'Lavf60.16.100'}, 'filesize': 3940, 'bitrate': 128.0, + {'extra': {'encoder_settings': 'Lavf60.16.100'}, 'filesize': 3940, 'bitrate': 128.0, 'duration': 2.1409999999999996, 'samplerate': 44100, 'channels': 1}), # ALAC/M4A/MP4 @@ -452,7 +453,8 @@ 'itunnorm': (' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 ' '00007E90 00007BFD 00009293'), 'itunes_cddb_ids': '11++', 'ufidhttp://www.cddb.com/id3/taginfo1.html': - '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1', 'bpm': 0}, + '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1', 'bpm': 0, + 'encoded_by': 'iTunes 10.5'}, 'samplerate': 44100, 'duration': 314.97868480725623, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': 11, 'track': 11, 'artist': 'Marian', 'filesize': 61432}), @@ -498,7 +500,7 @@ 'channels': 2, 'comment': 'test comment', 'duration': 2.36, - 'extra': {'description': 'test description'}, + 'extra': {'description': 'test description', 'encoded_by': 'Lavf59.27.100'}, 'samplerate': 44100}), ('samples/mpeg4_xa9des.m4a', { 'filesize': 2639, @@ -506,7 +508,9 @@ 'duration': 727.1066666666667, 'extra': {'description': 'test description'}}), ('samples/test3.m4a', - {'extra': {'publisher': 'test7', 'bpm': 99999, 'composer': 'test8'}, 'artist': 'test1', + {'extra': {'publisher': 'test7', 'bpm': 99999, 'composer': 'test8', + 'encoded_by': 'Lavf60.3.100'}, + 'artist': 'test1', 'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1, 'bitrate': 27.887}), @@ -778,7 +782,7 @@ def test_to_str() -> None: "'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " "'title': 'cosmic american', 'track': 3, 'track_total': 11, 'genre': None, " "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " - "'extra': {'ten': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " + "'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+" "174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+163829', " "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'data': None, " diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index baee192..41cce73 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -345,12 +345,13 @@ class _Parser: atom_decoder_by_type: dict[ int, Callable[[bytes], int | str | bytes | TagImage]] | None = None _CUSTOM_FIELD_NAME_MAPPING = { - 'conductor': 'conductor', - 'discsubtitle': 'set_subtitle', - 'initialkey': 'initial_key', - 'isrc': 'isrc', - 'language': 'language', - 'lyricist': 'lyricist', + 'conductor': 'extra.conductor', + 'discsubtitle': 'extra.set_subtitle', + 'initialkey': 'extra.initial_key', + 'isrc': 'extra.isrc', + 'language': 'extra.language', + 'lyricist': 'extra.lyricist', + 'media': 'extra.media', } @classmethod @@ -448,10 +449,8 @@ def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | TagIm if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() field_name = atom_value.decode('utf-8', 'replace') - field_name = ( - TinyTag._EXTRA_PREFIX - + cls._CUSTOM_FIELD_NAME_MAPPING.get(field_name, field_name) - ) + field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get( + field_name, TinyTag._EXTRA_PREFIX + field_name) elif atom_type == b'data': data_atom = fh.read(atom_size) else: @@ -540,6 +539,7 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: b'\xa9mvn': {b'data': _Parser._make_data_atom_parser('movement')}, b'\xa9nam': {b'data': _Parser._make_data_atom_parser('title')}, b'\xa9pub': {b'data': _Parser._make_data_atom_parser('extra.publisher')}, + b'\xa9too': {b'data': _Parser._make_data_atom_parser('extra.encoded_by')}, b'\xa9wrt': {b'data': _Parser._make_data_atom_parser('extra.composer')}, b'aART': {b'data': _Parser._make_data_atom_parser('albumartist')}, b'cprt': {b'data': _Parser._make_data_atom_parser('extra.copyright')}, @@ -632,16 +632,19 @@ class _ID3(TinyTag): 'TPE2': 'albumartist', 'TP2': 'albumartist', 'TCOM': 'extra.composer', 'TCM': 'extra.composer', 'WOAR': 'extra.url', 'WAR': 'extra.url', - 'TSRC': 'extra.isrc', + 'TSRC': 'extra.isrc', 'TRC': 'extra.isrc', 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', - 'TBPM': 'extra.bpm', - 'TKEY': 'extra.initial_key', + 'TBPM': 'extra.bpm', 'TBP': 'extra.bpm', + 'TKEY': 'extra.initial_key', 'TKE': 'extra.initial_key', 'TLAN': 'extra.language', 'TLA': 'extra.language', 'TPUB': 'extra.publisher', 'TPB': 'extra.publisher', 'USLT': 'extra.lyrics', 'ULT': 'extra.lyrics', 'TPE3': 'extra.conductor', 'TP3': 'extra.conductor', 'TEXT': 'extra.lyricist', 'TXT': 'extra.lyricist', 'TSST': 'extra.set_subtitle', + 'TENC': 'extra.encoded_by', 'TEN': 'extra.encoded_by', + 'TSSE': 'extra.encoder_settings', 'TSS': 'extra.encoder_settings', + 'TMED': 'extra.media', 'TMT': 'extra.media', } _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} @@ -1121,6 +1124,9 @@ class _Ogg(TinyTag): 'setsubtitle': 'extra.set_subtitle', 'initialkey': 'extra.initial_key', 'key': 'extra.initial_key', + 'encodedby': 'extra.encoded_by', + 'encodersettings': 'extra.encoder_settings', + 'media': 'extra.media', } def __init__(self) -> None: @@ -1301,6 +1307,8 @@ class _Wave(TinyTag): b'IBSU': 'extra.url', b'YEAR': 'year', b'IWRI': 'extra.lyricist', + b'IENC': 'extra.encoded_by', + b'IMED': 'extra.media', } def _determine_duration(self, fh: BinaryIO) -> None: @@ -1491,6 +1499,9 @@ class _Wma(TinyTag): 'WM/Conductor': 'extra.conductor', 'WM/Writer': 'extra.lyricist', 'WM/SetSubTitle': 'extra.set_subtitle', + 'WM/EncodedBy': 'extra.encoded_by', + 'WM/EncodingSettings': 'extra.encoder_settings', + 'WM/Media': 'extra.media', } _ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' From b6e896ce9533c5827e43cd0db66a4b769447f538 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 09:17:24 +0200 Subject: [PATCH 177/305] README.md: mention same API for all formats as feature --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3a3c474..141a3eb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ tinytag is a library for reading music meta data of most common audio files in p ## Features - * Read tags, length and images of audio files + * Read tags, stream info and images of audio files * Supported formats: * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) * M4A (AAC / ALAC) @@ -25,6 +25,7 @@ tinytag is a library for reading music meta data of most common audio files in p * FLAC * WMA * AIFF / AIFF-C + * Same API for all formats * Pure Python, no dependencies * Supports Python 3.7 or higher * High test coverage From 8f033abf328a7cf166f2c897355016d14a63b404 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 09:49:31 +0200 Subject: [PATCH 178/305] Update project description --- README.md | 12 +++++++----- setup.cfg | 45 +++++++++++++++++++++++++++------------------ 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 141a3eb..44e961b 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # tinytag -tinytag is a library for reading music meta data of most common audio files in pure Python +tinytag is a Python library for reading audio file metadata [![Build Status](https://github.com/devsnd/tinytag/actions/workflows/tests.yml/badge.svg)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22) -[![Build status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) +[![Build Status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) [![Coverage Status](https://coveralls.io/repos/devsnd/tinytag/badge.svg)](https://coveralls.io/r/devsnd/tinytag) -[![PyPI version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) +[![PyPI Version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) [![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag.svg)](https://pypistats.org/packages/tinytag) ## Install -```pip install tinytag``` +``` +python3 -m pip install tinytag +``` ## Features @@ -20,7 +22,7 @@ tinytag is a library for reading music meta data of most common audio files in p * Supported formats: * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) * M4A (AAC / ALAC) - * Wave / RIFF + * WAVE / WAV * OGG (FLAC / Opus / Speex / Vorbis) * FLAC * WMA diff --git a/setup.cfg b/setup.cfg index d87dd5a..9c1bb77 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,28 +4,37 @@ version = 2.0.0 author = Tom Wallroth author_email = tomwallroth@gmail.com url = https://github.com/devsnd/tinytag -description = Read music meta data and length of MP3, OGG, OPUS, MP4, M4A, FLAC, WMA and Wave files +description = Read audio file metadata keywords = metadata + audio music + mp3 + m4a + wav + ogg + opus + flac + wma + aiff classifiers = - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - License :: OSI Approved :: MIT License - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Intended Audience :: Developers - Operating System :: OS Independent - Topic :: Internet :: WWW/HTTP - Topic :: Multimedia - Topic :: Multimedia :: Sound/Audio - Topic :: Multimedia :: Sound/Audio :: Analysis + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 + License :: OSI Approved :: MIT License + Development Status :: 5 - Production/Stable + Environment :: Web Environment + Intended Audience :: Developers + Operating System :: OS Independent + Topic :: Internet :: WWW/HTTP + Topic :: Multimedia + Topic :: Multimedia :: Sound/Audio + Topic :: Multimedia :: Sound/Audio :: Analysis license = MIT license_files = LICENSE long_description = file: README.md From 0b59beae1e9111d0abdd57ca629ee160f1e8d603 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 09:55:21 +0200 Subject: [PATCH 179/305] README.md: slightly reword --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 44e961b..37e098b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ python3 -m pip install tinytag ## Features - * Read tags, stream info and images of audio files + * Read tags, images and properties of audio files * Supported formats: * MP3 / MP2 / MP1 (ID3 v1, v1.1, v2.2, v2.3+) * M4A (AAC / ALAC) From 167edd8c4f1d8596e1a271c199662f14284fd7f4 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 17:47:28 +0200 Subject: [PATCH 180/305] Place additional artists and genres in extra dict --- README.md | 8 +++---- tinytag/tests/test_all.py | 23 ++++++++++---------- tinytag/tinytag.py | 44 ++++++++++++++++++++++++++++----------- 3 files changed, 47 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 37e098b..c564000 100644 --- a/README.md +++ b/README.md @@ -83,16 +83,15 @@ List of common attributes tinytag provides: tag.track_total # total number of tracks tag.year # year or date as string -If multiple fields with the same name are provided, the values are separated with a null character: - - tag.artist == 'artist 1\x00artist 2\x00artist 3' - For non-common fields and fields specific to certain file formats, use `extra`: tag.extra # a dict of additional data The following standard `extra` field names are used when file formats provide relevant data: + other_artists # additional artists as list + other_genres # additional genres as list + bpm composer conductor @@ -206,7 +205,6 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - **BREAKING:** Remove 'ignore_errors' parameter for TinyTag.get() - **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 -- Support multiple fields with the same name (separated with a null character) - Provide access to custom metadata fields through the 'extra' dict - Provide access to all available images - Add more standard 'extra' fields diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 7baf707..f84c9ac 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -312,7 +312,7 @@ ('samples/id3_header_with_a_zero_byte.wav', {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', - 'title': 'Test000\x00Stacked', 'track': 17, + 'title': 'Test000', 'track': 17, 'album': 'prototypes'}), ('samples/adpcm.wav', {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, @@ -389,9 +389,9 @@ {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.684716553287982, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', - {'extra': {'id': '8591671910'}, 'filesize': 64837, 'album': 'album\x00 ', - 'artist': 'artist\x00群星', - 'title': 'title\x00A 梦 哆啦 机器猫 短信铃声', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, + {'extra': {'id': '8591671910', 'other_artists': ['群星']}, 'filesize': 64837, + 'album': 'album', 'artist': 'artist', + 'title': 'title', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, 'year': '2018', 'comment': 'comment', 'disc': 0}), ('samples/with_padded_id3_header.flac', @@ -406,12 +406,13 @@ 'tlen': '297666', 'encoded_by': 'Exact Audio Copy (Sicherer Modus)', 'encoder_settings': ('flac.exe -T "artist=Unbekannter Künstler" -T ' '"title=Track01" -T "album=Unbekannter Titel" -T ' - '"date=" -T "tracknumber=01" -T "genre=" -5')}, - 'filesize': 19522, 'album': 'album\x00Unbekannter Titel', - 'artist': 'artist\x00Unbekannter Künstler', 'bitrate': 344.36807999999996, + '"date=" -T "tracknumber=01" -T "genre=" -5'), + 'other_artists': ['Unbekannter Künstler']}, + 'filesize': 19522, 'album': 'album', + 'artist': 'artist', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': 1, 'disc_total': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'title': 'title\x00Track01', 'track': 1, 'track_total': 5, 'year': '2018', + 'title': 'title', 'track': 1, 'track_total': 5, 'year': '2018', 'comment': 'comment'}), ('samples/flac_with_image.flac', {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', @@ -423,9 +424,9 @@ {'extra': {}, 'filesize': 235, 'bitrate': 18.8, 'channels': 1, 'duration': 0.1, 'samplerate': 44100, 'bitdepth': 16}), ('samples/flac_multiple_fields.flac', - {'extra': {}, 'filesize': 235, 'album': 'album 1\x00album 2', - 'artist': 'artist 1\x00artist 2\x00artist 3', - 'bitrate': 18.8, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1\x00genre 2', + {'extra': {'other_artists': ['artist 2', 'artist 3'], 'other_genres': ['genre 2']}, + 'filesize': 235, 'album': 'album 1', 'artist': 'artist 1', + 'bitrate': 18.8, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1', 'samplerate': 44100, 'bitdepth': 16}), # WMA diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 41cce73..b9fc6c8 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -243,24 +243,42 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: str | int | float) -> None: - is_str = isinstance(value, str) - if is_str and not value: - # don't set empty value - return + def _parse_string_field(self, fieldname: str, old_value: Any | None, value: str) -> str | None: + if fieldname in {'artist', 'genre'}: + # First artist/genre goes in tag.artist/genre, others in tag.extra.other_artists/genres + values = value.split('\x00') + value = values[0] + start_pos = 0 if old_value else 1 + if len(values) > 1: + self._set_field(self._EXTRA_PREFIX + f'other_{fieldname}s', values[start_pos:]) + elif old_value and value != old_value: + self._set_field(self._EXTRA_PREFIX + f'other_{fieldname}s', [value]) + return None + if old_value or not value: + return None + return value + + def _set_field(self, fieldname: str, value: str | int | float | list[str] | None) -> None: write_dest = self.__dict__ + original_fieldname = fieldname if fieldname.startswith(self._EXTRA_PREFIX): - fieldname = fieldname[len(self._EXTRA_PREFIX):] write_dest = self.extra + fieldname = fieldname[len(self._EXTRA_PREFIX):] old_value = write_dest.get(fieldname) - if is_str: - if old_value and old_value != value: - # Combine same field with a null character - value = old_value + '\x00' + value + if isinstance(value, str): + value = self._parse_string_field(original_fieldname, old_value, value) + if not value: + return + elif isinstance(value, list): + if not isinstance(old_value, list): + old_value = [] + value = old_value + [i for i in value if i and i not in old_value] + if not value: + return elif not value and old_value: return if DEBUG: - print(f'Setting field "{fieldname}" to "{value!r}"') + print(f'Setting field "{original_fieldname}" to "{value!r}"') write_dest[fieldname] = value def _set_image_field(self, fieldname: str, value: bytes | str | TagImage) -> None: @@ -280,8 +298,10 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _update(self, other: TinyTag) -> None: # update the values of this tag with the values from another tag + excluded_attrs = {'filesize', 'extra', 'images'} for standard_key, standard_value in other.__dict__.items(): - if (not standard_key.startswith('_') and standard_key != 'filesize' + if (not standard_key.startswith('_') + and standard_key not in excluded_attrs and standard_value is not None): self._set_field(standard_key, standard_value) for extra_key, extra_value in other.extra.items(): From 85f06aa7180f6272f8ca9e9b1c492ea45a45df8d Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 19:04:37 +0200 Subject: [PATCH 181/305] ID3: add sample file containing multiple artists --- tinytag/tests/samples/id3_multiple_artists.mp3 | Bin 0 -> 2007 bytes tinytag/tests/test_all.py | 5 +++++ tinytag/tinytag.py | 2 -- 3 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 tinytag/tests/samples/id3_multiple_artists.mp3 diff --git a/tinytag/tests/samples/id3_multiple_artists.mp3 b/tinytag/tests/samples/id3_multiple_artists.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..990a8ff5e95770c1081f8f8edd439752b7f129c7 GIT binary patch literal 2007 zcmeZtF=l1}0uGgs09Qj01BergN-~Q}451 Date: Sat, 2 Mar 2024 19:08:24 +0200 Subject: [PATCH 182/305] ID3: update sample file to include more artists --- .../tests/samples/id3_multiple_artists.mp3 | Bin 2007 -> 2007 bytes tinytag/tests/test_all.py | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tinytag/tests/samples/id3_multiple_artists.mp3 b/tinytag/tests/samples/id3_multiple_artists.mp3 index 990a8ff5e95770c1081f8f8edd439752b7f129c7..f1c7687d616d1fdadf7f3ea3606e37bd4755d28f 100644 GIT binary patch delta 87 zcmcc4f1Q7VvO5O@14Cj_NoH}0QAmKRA&@5l7cqv)m_TV$s0snN3NxsT`9`xN%$xOC HRx$$sFLfC& delta 38 ocmcc4f1Q7VvakRH14Cj_NoH}05tKIGn017C;sle;dMqoM0rKSx{r~^~ diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index ed9e3a5..46ffdf0 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -240,7 +240,8 @@ ('samples/id3_multiple_artists.mp3', {'filesize': 2007, 'bitrate': 57.39124999999999, 'channels': 1, 'duration': 0.1306122448979592, - 'extra': {'other_artists': ['artist2', 'artist3']}, + 'extra': {'other_artists': ['artist2', 'artist3', 'artist4', 'artist5', + 'artist6', 'artist7']}, 'samplerate': 44100, 'artist': 'artist1', 'genre': 'something 1'}), # OGG From 7b8f0781f597d7507bb8b94ec567044dc19966fc Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 19:11:47 +0200 Subject: [PATCH 183/305] Increase test coverage --- tinytag/tests/test_all.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 46ffdf0..a1444f5 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -594,7 +594,7 @@ def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, if val is not None and key not in ('filename', 'images') } compare_tag(results, expected, filename) - assert tag.images.front_cover.data is None + assert tag.get_image() is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -610,7 +610,7 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - key: val for key, val in expected.items() if key not in excluded_attrs } compare_tag(results, expected, filename) - assert tag.images.front_cover.data is None + assert tag.get_image() is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -627,7 +627,7 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] } expected["extra"] = {} compare_tag(results, expected, filename) - assert tag.images.front_cover.data is None + assert tag.get_image() is None def test_pathlib_compatibility() -> None: From 585f91299de669da8d8f62c583c7c7f45524f023 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 22:10:26 +0200 Subject: [PATCH 184/305] Remove get_image() method in favor of images.any property --- README.md | 15 ++++++++---- tinytag/__main__.py | 6 ++--- tinytag/tests/test_all.py | 24 ++++++++++-------- tinytag/tinytag.py | 51 ++++++++++++++++++++------------------- 4 files changed, 53 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index c564000..a5c45b9 100644 --- a/README.md +++ b/README.md @@ -114,13 +114,16 @@ Any other `extra` field names are not guaranteed to be consistent across audio f Additionally, you can also get images from ID3 tags. To receive any available image, prioritizing the front cover: tag: TinyTag = TinyTag.get('/some/music.mp3', image=True) - image_data: bytes = tag.get_image() + image: TagImage = tag.images.any + data: bytes = image.data + name: str = image.name + description: str = image.description -If you need to receive an image of a specific type, including its description, use `images`: +If you need to receive an image of a specific kind, including its description, use `images`: - tag.images # image types and their data + tag.images # available embedded images -The following common image types exist: +The following common images are available: front_cover back_cover @@ -128,7 +131,7 @@ The following common image types exist: media other -The following less common image types are provided in an `extra` dict: +The following less common images are provided in an `extra` dict when present: icon other_icon @@ -151,6 +154,7 @@ The following less common image types are provided in an `extra` dict: The following image attributes are available: data # image data as bytes + name # image name/kind as string mime_type # image MIME type as string description # image description as string @@ -198,6 +202,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ### 2.0.0 (Unreleased) - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str +- **BREAKING:** Remove 'get_image()' method in favor of 'images.any' property - **BREAKING:** Move 'composer' field to 'extra' dict - **BREAKING:** Remove 'audio_offset' attribute - **BREAKING:** TinyTagException no longer inherits LookupError diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 238989d..ee809bb 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -86,10 +86,10 @@ def _run() -> int: if len(filenames) > 1: actual_save_image_path, ext = splitext(actual_save_image_path) actual_save_image_path += f'{i:05d}{ext}' - image = tag.get_image() - if image: + image_data = tag.images.any.data + if image_data: with open(actual_save_image_path, 'wb') as file_handle: - file_handle.write(image) + file_handle.write(image_data) header_printed = _print_tag(tag, formatting, header_printed) except (OSError, TinyTagException) as exc: sys.stderr.write(f'{filename}: {exc}\n') diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index a1444f5..4939523 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -594,7 +594,7 @@ def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, if val is not None and key not in ('filename', 'images') } compare_tag(results, expected, filename) - assert tag.get_image() is None + assert tag.images.any.data is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -610,7 +610,7 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - key: val for key, val in expected.items() if key not in excluded_attrs } compare_tag(results, expected, filename) - assert tag.get_image() is None + assert tag.images.any.data is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -627,7 +627,7 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] } expected["extra"] = {} compare_tag(results, expected, filename) - assert tag.get_image() is None + assert tag.images.any.data is None def test_pathlib_compatibility() -> None: @@ -725,7 +725,7 @@ def test_image_loading(path: str, expected_size: int) -> None: image = tag.images.front_cover if image.data is None: image = tag.images.other - image_data = tag.get_image() + image_data = tag.images.any.data assert image_data is not None assert image_data == image.data image_size = len(image_data) @@ -734,6 +734,7 @@ def test_image_loading(path: str, expected_size: int) -> None: assert image_data.startswith(b'\xff\xd8\xff\xe0'), \ 'The image data must start with a jpeg header' assert image.mime_type == 'image/jpeg' + assert image.name in {'front_cover', 'other'} @pytest.mark.parametrize('path', [ @@ -743,8 +744,9 @@ def test_image_loading_extra(path: str) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) image = tag.images.extra['bright_colored_fish'] assert image.data is not None - assert tag.get_image() == image.data + assert tag.images.any.data == image.data assert image.mime_type == 'image/jpeg' + assert image.name == 'extra.bright_colored_fish' assert len(image.data) == 1220 @@ -792,9 +794,11 @@ def test_to_str() -> None: "'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+" "174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+163829', " - "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'data': None, " - "'mime_type': None, 'description': None}, 'back_cover': {'data': None, 'mime_type': None, " - "'description': None}, 'leaflet': {'data': None, 'mime_type': None, 'description': None}, " - "'media': {'data': None, 'mime_type': None, 'description': None}, 'other': {'data': None, " - "'mime_type': None, 'description': None}, 'extra': {}}" + "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'name': 'front_cover', " + "'data': None, 'mime_type': None, 'description': None}, 'back_cover': {'name': " + "'back_cover', 'data': None, 'mime_type': None, 'description': None}, 'leaflet': " + "{'name': 'leaflet', 'data': None, 'mime_type': None, 'description': None}, " + "'media': {'name': 'media', 'data': None, 'mime_type': None, 'description': None}, " + "'other': {'name': 'other', 'data': None, 'mime_type': None, 'description': None}, " + "'extra': {}}" ) in str(tag) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d5dae42..3f57a68 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -140,18 +140,6 @@ def get(cls, if should_close_file: file_obj.close() - def get_image(self) -> bytes | None: - """Return a cover image as bytes. - If not present, fall back to any other available image. - """ - for value in self.images.__dict__.values(): - if isinstance(value, TagImage) and value.data is not None: - return value.data - for extra_value in self.images.extra.values(): - if extra_value.data is not None: - return extra_value.data - return None - @classmethod def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: """Check if a specific file is supported based on its file extension.""" @@ -328,21 +316,34 @@ def _unpad(s: str) -> str: class TagImages: """A class containing images embedded in an audio file.""" def __init__(self) -> None: - image = TagImage() - self.front_cover: TagImage = image - self.back_cover: TagImage = image - self.leaflet: TagImage = image - self.media: TagImage = image - self.other: TagImage = image + self.front_cover = TagImage('front_cover') + self.back_cover = TagImage('back_cover') + self.leaflet = TagImage('leaflet') + self.media = TagImage('media') + self.other = TagImage('other') self.extra: dict[str, TagImage] = {} + @property + def any(self) -> TagImage: + """Return a cover image. + If not present, fall back to any other available image. + """ + for value in self.__dict__.values(): + if isinstance(value, TagImage) and value.data is not None: + return value + for extra_value in self.extra.values(): + if extra_value.data is not None: + return extra_value + return self.front_cover + def __repr__(self) -> str: return str(vars(self)) class TagImage: """A class representing an image embedded in an audio file.""" - def __init__(self, data: bytes | None = None, mime_type: str | None = None) -> None: + def __init__(self, name: str, data: bytes | None = None, mime_type: str | None = None) -> None: + self.name = name self.data = data self.mime_type = mime_type self.description: str | None = None @@ -403,8 +404,8 @@ def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | TagImage 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS # 16: duration in millis - 13: lambda x: TagImage(x, 'image/jpeg'), # JPEG - 14: lambda x: TagImage(x, 'image/png'), # PNG + 13: lambda x: TagImage('front_cover', x, 'image/jpeg'), # JPEG + 14: lambda x: TagImage('front_cover', x, 'image/png'), # PNG 21: cls._unpack_integer, # BE Signed int 22: cls._unpack_integer_unsigned, # BE Unsigned int # 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 @@ -961,14 +962,14 @@ def __parse_custom_field(self, content: str) -> bool: @classmethod def _create_tag_image(cls, data: bytes, pic_type: int, mime_type: str | None = None, description: str | None = None) -> tuple[str, TagImage]: - image = TagImage(data) + field_name = cls._UNKNOWN_IMAGE_TYPE + if 0 <= pic_type <= len(cls._IMAGE_TYPES): + field_name = cls._IMAGE_TYPES[pic_type] + image = TagImage(field_name, data) if mime_type: image.mime_type = mime_type if description: image.description = description - field_name = cls._UNKNOWN_IMAGE_TYPE - if 0 <= pic_type <= len(cls._IMAGE_TYPES): - field_name = cls._IMAGE_TYPES[pic_type] return field_name, image @staticmethod From 0024921190ef7b8177a12ba8c62dc055eb720ace Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 23:34:18 +0200 Subject: [PATCH 185/305] Support multiple images of the same type --- README.md | 25 +++++++++++------- tinytag/__main__.py | 6 ++--- tinytag/tests/test_all.py | 41 ++++++++++++++--------------- tinytag/tinytag.py | 54 ++++++++++++++++++++++++--------------- 4 files changed, 73 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index a5c45b9..b84acee 100644 --- a/README.md +++ b/README.md @@ -114,10 +114,12 @@ Any other `extra` field names are not guaranteed to be consistent across audio f Additionally, you can also get images from ID3 tags. To receive any available image, prioritizing the front cover: tag: TinyTag = TinyTag.get('/some/music.mp3', image=True) - image: TagImage = tag.images.any - data: bytes = image.data - name: str = image.name - description: str = image.description + image: TagImage | None = tag.images.any + + if image is not None: + data: bytes = image.data + name: str = image.name + description: str = image.description If you need to receive an image of a specific kind, including its description, use `images`: @@ -164,14 +166,19 @@ To receive a common image, e.g. `front_cover`: tag: TinyTag = TinyTag.get('/some/music.ogg') images: TagImages = tag.images - image: TagImage = images.front_cover - data: bytes = image.data - description: str = image.description + front_cover_images: list[TagImage] = images.front_cover + + if front_cover_images: + image: TagImage = front_cover_images[0] # Use first image + data: bytes = image.data + description: str = image.description To receive an extra image, e.g. `bright_colored_fish`: - image = tag.images.extra.get('bright_colored_fish') - if image is not None: + fish_images = tag.images.extra.get('bright_colored_fish') + + if fish_images: + image = fish_images[0] # Use first image data = image.data description = image.description diff --git a/tinytag/__main__.py b/tinytag/__main__.py index ee809bb..6b280f0 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -86,10 +86,10 @@ def _run() -> int: if len(filenames) > 1: actual_save_image_path, ext = splitext(actual_save_image_path) actual_save_image_path += f'{i:05d}{ext}' - image_data = tag.images.any.data - if image_data: + image = tag.images.any + if image is not None: with open(actual_save_image_path, 'wb') as file_handle: - file_handle.write(image_data) + file_handle.write(image.data) header_printed = _print_tag(tag, formatting, header_printed) except (OSError, TinyTagException) as exc: sys.stderr.write(f'{filename}: {exc}\n') diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 4939523..b6e9dac 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -594,7 +594,7 @@ def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, if val is not None and key not in ('filename', 'images') } compare_tag(results, expected, filename) - assert tag.images.any.data is None + assert tag.images.any is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -610,7 +610,7 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - key: val for key, val in expected.items() if key not in excluded_attrs } compare_tag(results, expected, filename) - assert tag.images.any.data is None + assert tag.images.any is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -627,7 +627,7 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] } expected["extra"] = {} compare_tag(results, expected, filename) - assert tag.images.any.data is None + assert tag.images.any is None def test_pathlib_compatibility() -> None: @@ -722,19 +722,24 @@ def test_invalid_file(path: str, cls: type[TinyTag]) -> None: ]) def test_image_loading(path: str, expected_size: int) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) - image = tag.images.front_cover - if image.data is None: - image = tag.images.other - image_data = tag.images.any.data - assert image_data is not None - assert image_data == image.data - image_size = len(image_data) + image = tag.images.any + manual_image = None + if tag.images.front_cover: + manual_image = tag.images.front_cover[0] + elif tag.images.other: + manual_image = tag.images.other[0] + assert image is not None + assert manual_image is not None + image.data = manual_image.data + assert image.name in {'front_cover', 'other'} + assert image.data is not None + assert image.data == manual_image.data + image_size = len(image.data) assert image_size == expected_size, \ f'Image is {image_size} bytes but should be {expected_size} bytes' - assert image_data.startswith(b'\xff\xd8\xff\xe0'), \ + assert image.data.startswith(b'\xff\xd8\xff\xe0'), \ 'The image data must start with a jpeg header' assert image.mime_type == 'image/jpeg' - assert image.name in {'front_cover', 'other'} @pytest.mark.parametrize('path', [ @@ -742,8 +747,9 @@ def test_image_loading(path: str, expected_size: int) -> None: ]) def test_image_loading_extra(path: str) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) - image = tag.images.extra['bright_colored_fish'] + image = tag.images.extra['bright_colored_fish'][0] assert image.data is not None + assert tag.images.any is not None assert tag.images.any.data == image.data assert image.mime_type == 'image/jpeg' assert image.name == 'extra.bright_colored_fish' @@ -794,11 +800,6 @@ def test_to_str() -> None: "'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+" "174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+163829', " - "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': {'name': 'front_cover', " - "'data': None, 'mime_type': None, 'description': None}, 'back_cover': {'name': " - "'back_cover', 'data': None, 'mime_type': None, 'description': None}, 'leaflet': " - "{'name': 'leaflet', 'data': None, 'mime_type': None, 'description': None}, " - "'media': {'name': 'media', 'data': None, 'mime_type': None, 'description': None}, " - "'other': {'name': 'other', 'data': None, 'mime_type': None, 'description': None}, " - "'extra': {}}" + "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': [], 'back_cover': [], " + "'leaflet': [], 'media': [], 'other': [], 'extra': {}}" ) in str(tag) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3f57a68..a4483e2 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -267,14 +267,18 @@ def _set_field(self, fieldname: str, value: str | int | float | list[str] | None print(f'Setting field "{original_fieldname}" to "{value!r}"') write_dest[fieldname] = value - def _set_image_field(self, fieldname: str, value: bytes | str | TagImage) -> None: + def _set_image_field(self, fieldname: str, value: TagImage) -> None: write_dest = self.images.__dict__ if fieldname.startswith(self._EXTRA_PREFIX): fieldname = fieldname[len(self._EXTRA_PREFIX):] write_dest = self.images.extra + old_values = write_dest.get(fieldname) + values = [value] + if old_values is not None: + values = old_values + values if DEBUG: print(f'Setting image field "{fieldname}"') - write_dest[fieldname] = value + write_dest[fieldname] = values def _determine_duration(self, fh: BinaryIO) -> None: raise NotImplementedError @@ -292,10 +296,12 @@ def _update(self, other: TinyTag) -> None: self._set_field(standard_key, standard_value) for extra_key, extra_value in other.extra.items(): self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) - for image_key, image_value in other.images.__dict__.items(): - self._set_image_field(image_key, image_value) - for image_extra_key, image_extra_value in other.images.extra.items(): - self._set_image_field(self._EXTRA_PREFIX + image_extra_key, image_extra_value) + for image_key, images in other.images._as_dict().items(): + for image in images: + self._set_image_field(image_key, image) + for image_extra_key, images_extra in other.images.extra.items(): + for image_extra in images_extra: + self._set_image_field(self._EXTRA_PREFIX + image_extra_key, image_extra) @staticmethod def _bytes_to_int_le(b: bytes) -> int: @@ -316,33 +322,39 @@ def _unpad(s: str) -> str: class TagImages: """A class containing images embedded in an audio file.""" def __init__(self) -> None: - self.front_cover = TagImage('front_cover') - self.back_cover = TagImage('back_cover') - self.leaflet = TagImage('leaflet') - self.media = TagImage('media') - self.other = TagImage('other') - self.extra: dict[str, TagImage] = {} + self.front_cover: list[TagImage] = [] + self.back_cover: list[TagImage] = [] + self.leaflet: list[TagImage] = [] + self.media: list[TagImage] = [] + self.other: list[TagImage] = [] + self.extra: dict[str, list[TagImage]] = {} @property - def any(self) -> TagImage: + def any(self) -> TagImage | None: """Return a cover image. If not present, fall back to any other available image. """ - for value in self.__dict__.values(): - if isinstance(value, TagImage) and value.data is not None: - return value - for extra_value in self.extra.values(): - if extra_value.data is not None: - return extra_value - return self.front_cover + for image_list in self._as_dict().values(): + for image in image_list: + return image + for extra_image_list in self.extra.values(): + for extra_image in extra_image_list: + return extra_image + return None def __repr__(self) -> str: return str(vars(self)) + def _as_dict(self) -> dict[str, list[TagImage]]: + return { + k: v for k, v in self.__dict__.items() + if not k.startswith('_') and k != 'extra' + } + class TagImage: """A class representing an image embedded in an audio file.""" - def __init__(self, name: str, data: bytes | None = None, mime_type: str | None = None) -> None: + def __init__(self, name: str, data: bytes, mime_type: str | None = None) -> None: self.name = name self.data = data self.mime_type = mime_type From ea34543bd1ccccc73e6db1bf466fe43126d648f5 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Mar 2024 23:50:46 +0200 Subject: [PATCH 186/305] Test __repr__ for TagImage --- tinytag/tests/test_all.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index b6e9dac..5cb526c 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -754,6 +754,12 @@ def test_image_loading_extra(path: str) -> None: assert image.mime_type == 'image/jpeg' assert image.name == 'extra.bright_colored_fish' assert len(image.data) == 1220 + assert str(image) == ( + "{'name': 'extra.bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00" + "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" + "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " + "'description': None}" + ) def test_mp3_utf_8_invalid_string() -> None: From 6ada23336e7d9e557258a384bd61dd9a9122a659 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Mar 2024 02:52:21 +0200 Subject: [PATCH 187/305] Add deprecation warnings --- README.md | 8 ++++---- tinytag/tests/test_all.py | 13 +++++++++++++ tinytag/tinytag.py | 28 +++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b84acee..292ce04 100644 --- a/README.md +++ b/README.md @@ -209,14 +209,14 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ### 2.0.0 (Unreleased) - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str -- **BREAKING:** Remove 'get_image()' method in favor of 'images.any' property -- **BREAKING:** Move 'composer' field to 'extra' dict -- **BREAKING:** Remove 'audio_offset' attribute - **BREAKING:** TinyTagException no longer inherits LookupError - **BREAKING:** TinyTag subclasses are now private -- **BREAKING:** Remove 'ignore_errors' parameter for TinyTag.get() - **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 +- Mark 'ignore_errors' parameter for TinyTag.get() as obsolete +- Mark 'audio_offset' attribute as obsolete +- Deprecate 'composer' attribute in favor of 'extra.composer' +- Deprecate 'get_image()' method in favor of 'images.any' property - Provide access to custom metadata fields through the 'extra' dict - Provide access to all available images - Add more standard 'extra' fields diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5cb526c..ca3b66b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -795,6 +795,19 @@ def test_show_hint_for_wrong_usage() -> None: assert exc_info.value.args[0] == 'Either filename or file_obj argument is required' +def test_deprecations() -> None: + file_path = os.path.join(testfolder, 'samples/id3v24-long-title.mp3') + with pytest.warns(DeprecationWarning): + tag = TinyTag.get(filename=file_path, image=True, ignore_errors=True) + with pytest.warns(DeprecationWarning): + assert tag.composer == tag.extra.get('composer') + with pytest.warns(DeprecationWarning): + assert tag.audio_offset is None + with pytest.warns(DeprecationWarning): + assert tag.images.any is not None + assert tag.get_image() == tag.images.any.data + + def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert ( diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a4483e2..3f9c4ae 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -41,6 +41,7 @@ from os import PathLike from sys import stderr from typing import Any, BinaryIO +from warnings import warn import base64 import io @@ -113,13 +114,17 @@ def get(cls, duration: bool = True, image: bool = False, encoding: str | None = None, - file_obj: BinaryIO | None = None) -> TinyTag: + file_obj: BinaryIO | None = None, + **kwargs: Any) -> TinyTag: """Return a tag object for an audio file.""" should_close_file = file_obj is None if filename and should_close_file: file_obj = open(filename, 'rb') # pylint: disable=consider-using-with if file_obj is None: raise ValueError('Either filename or file_obj argument is required') + if 'ignore_errors' in kwargs: + warn('ignore_errors argument is obsolete, and will be removed in a future ' + '2.x release', DeprecationWarning, stacklevel=2) try: file_obj.seek(0, os.SEEK_END) filesize = file_obj.tell() @@ -318,6 +323,27 @@ def _unpad(s: str) -> str: # strings in mp3 and asf *may* be terminated with a zero byte at the end return s.strip('\x00') + def get_image(self) -> bytes | None: + """Deprecated, use images.any instead.""" + warn('get_image() is deprecated, and will be removed in a future 2.x release. ' + 'Use images.any instead.', DeprecationWarning, stacklevel=2) + image = self.images.any + return image.data if image is not None else None + + @property + def audio_offset(self) -> None: + """Obsolete.""" + warn('audio_offset attribute is obsolete, and will be ' + 'removed in a future 2.x release', DeprecationWarning, stacklevel=2) + + @property + def composer(self) -> str | None: + """Deprecated, use extra.composer instead.""" + warn('composer attribute is deprecated, and will be removed in a future 2.x release. ' + 'Use extra.composer instead.', DeprecationWarning, stacklevel=2) + composer = self.extra.get('composer') + return composer if isinstance(composer, str) else None + class TagImages: """A class containing images embedded in an audio file.""" From 15a76b3814e1c2f633c460c0b6e7c9155dccbd68 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 5 Mar 2024 18:35:09 +0200 Subject: [PATCH 188/305] CI: test GraalPy (#204) --- .github/workflows/tests.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 012d09f..12145bb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,17 +9,25 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + python: [ + '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', + 'pypy-3.10', 'graalpy-23' + ] + exclude: + - os: windows-latest + python: 'graalpy-23' steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + cache: 'pip' + cache-dependency-path: setup.py - name: Install dependencies run: python -m pip install build setuptools .[tests] @@ -28,6 +36,7 @@ jobs: run: python -m pycodestyle - name: Linting + if: matrix.python != 'graalpy-23' run: python -m pylint --recursive=y . - name: Typing From 2ca8431852db154b3f20336204770a07ce8ec9fa Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 5 Mar 2024 18:57:10 +0200 Subject: [PATCH 189/305] README.md: update badges --- README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 292ce04..8b7ec21 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,14 @@ tinytag is a Python library for reading audio file metadata -[![Build Status](https://github.com/devsnd/tinytag/actions/workflows/tests.yml/badge.svg)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22) -[![Build Status](https://ci.appveyor.com/api/projects/status/w9y2kg97869g1edj?svg=true)](https://ci.appveyor.com/project/devsnd/tinytag) -[![Coverage Status](https://coveralls.io/repos/devsnd/tinytag/badge.svg)](https://coveralls.io/r/devsnd/tinytag) -[![PyPI Version](https://badge.fury.io/py/tinytag.svg)](https://pypi.org/project/tinytag/) -[![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag.svg)](https://pypistats.org/packages/tinytag) +[![Build Status](https://img.shields.io/github/actions/workflow/status/devsnd/tinytag/tests.yml +)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22) +[![Coverage Status](https://img.shields.io/coverallsCoverage/github/devsnd/tinytag +)](https://coveralls.io/r/devsnd/tinytag) +[![PyPI Version](https://img.shields.io/pypi/v/tinytag +)](https://pypi.org/project/tinytag/) +[![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag +)](https://pypistats.org/packages/tinytag) ## Install From 224acb135ec13bf2a0179af9af737d3dc75cd4f7 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 16 Mar 2024 04:54:45 +0200 Subject: [PATCH 190/305] Use os.fsdecode() to convert path to string Fixes #205 --- ...cii_filename_\303\244\303\244\303\244.mp3" | Bin tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 24 ++++++++---------- 3 files changed, 11 insertions(+), 15 deletions(-) rename tinytag/tests/samples/nicotinetestdata.mp3 => "tinytag/tests/samples/non_ascii_filename_\303\244\303\244\303\244.mp3" (100%) diff --git a/tinytag/tests/samples/nicotinetestdata.mp3 "b/tinytag/tests/samples/non_ascii_filename_\303\244\303\244\303\244.mp3" similarity index 100% rename from tinytag/tests/samples/nicotinetestdata.mp3 rename to "tinytag/tests/samples/non_ascii_filename_\303\244\303\244\303\244.mp3" diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index ca3b66b..355ca94 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -146,7 +146,7 @@ 'extra': {'love rating': 'L', 'publisher': 'Century Media', 'popm': 'MusicBee\x00Ä'}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), - ('samples/nicotinetestdata.mp3', + ('samples/non_ascii_filename_äää.mp3', {'extra': {'encoder_settings': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2, 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), ('samples/chinese_id3.mp3', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3f9c4ae..d10675f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -76,7 +76,7 @@ class TinyTag: '.aiff', '.aifc', '.aif', '.afc' ) _EXTRA_PREFIX = 'extra.' - _file_extension_mapping: dict[tuple[bytes, ...], type[TinyTag]] | None = None + _file_extension_mapping: dict[tuple[str, ...], type[TinyTag]] | None = None _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None def __init__(self) -> None: @@ -161,21 +161,17 @@ def _get_parser_for_filename( cls, filename: bytes | str | PathLike[Any]) -> type[TinyTag] | None: if cls._file_extension_mapping is None: cls._file_extension_mapping = { - (b'.mp1', b'.mp2', b'.mp3'): _ID3, - (b'.oga', b'.ogg', b'.opus', b'.spx'): _Ogg, - (b'.wav',): _Wave, - (b'.flac',): _Flac, - (b'.wma',): _Wma, - (b'.m4b', b'.m4a', b'.m4r', b'.m4v', b'.mp4', b'.aax', b'.aaxc'): _MP4, - (b'.aiff', b'.aifc', b'.aif', b'.afc'): _Aiff, + ('.mp1', '.mp2', '.mp3'): _ID3, + ('.oga', '.ogg', '.opus', '.spx'): _Ogg, + ('.wav',): _Wave, + ('.flac',): _Flac, + ('.wma',): _Wma, + ('.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc'): _MP4, + ('.aiff', '.aifc', '.aif', '.afc'): _Aiff, } - filename = os.fspath(filename).lower() - if isinstance(filename, str): - filename_bytes = filename.encode('ascii') - else: - filename_bytes = filename + filename = os.fsdecode(filename).lower() for ext, tagclass in cls._file_extension_mapping.items(): - if filename_bytes.endswith(ext): + if filename.endswith(ext): return tagclass return None From 1b1c807a9ce5710961860d83f70ae748e5e0a544 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 5 May 2024 11:48:27 +0300 Subject: [PATCH 191/305] Temporarily revert documentation to stable version Closes #208 --- README.md | 137 +++++++----------------------------------------------- 1 file changed, 17 insertions(+), 120 deletions(-) diff --git a/README.md b/README.md index 8b7ec21..79449d2 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,13 @@ python3 -m pip install tinytag * High test coverage * A few hundred lines of code (just include it in your project!) -## Usage - -tinytag only provides the minimum needed for _reading_ metadata, and presents it in a simple format. -It can determine track number, total tracks, title, artist, album, year, duration and more. +tinytag only provides the minimum needed for _reading_ meta-data. +It can determine track number, total tracks, title, artist, album, year, duration and any more. from tinytag import TinyTag tag = TinyTag.get('/some/music.mp3') - print(f'This track is by {tag.artist}.') - print(f'It is {tag.duration:.2f} seconds long.') + print('This track is by %s.' % tag.artist) + print('It is %f seconds long.' % tag.duration) Alternatively you can use tinytag directly on the command line: @@ -53,28 +51,24 @@ Alternatively you can use tinytag directly on the command line: Check `python -m tinytag --help` for all CLI options, for example other output formats. -Support for changing/writing metadata will not be added, use another library for this. - -### Supported Files - -To receive a tuple of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: +To receive a list of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: TinyTag.SUPPORTED_FILE_EXTENSIONS -Alternatively, check if a file is supported by providing its path: +Alternatively, check if a file is supported: is_supported = TinyTag.is_supported('/some/music.mp3') -### Attributes - -List of common attributes tinytag provides: +List of possible attributes you can get with TinyTag: tag.album # album as string tag.albumartist # album artist as string tag.artist # artist name as string + tag.audio_offset # number of bytes before audio data begins tag.bitdepth # bit depth for lossless audio tag.bitrate # bitrate in kBits/s tag.comment # file comment as string + tag.composer # composer as string tag.disc # disc number tag.disc_total # the total number of discs tag.duration # duration of the song in seconds @@ -82,110 +76,21 @@ List of common attributes tinytag provides: tag.genre # genre as string tag.samplerate # samples per second tag.title # title of the song - tag.track # track number - tag.track_total # total number of tracks + tag.track # track number as string + tag.track_total # total number of tracks as string tag.year # year or date as string -For non-common fields and fields specific to certain file formats, use `extra`: +For non-common fields and fields specific to single file formats, use `extra`: tag.extra # a dict of additional data -The following standard `extra` field names are used when file formats provide relevant data: - - other_artists # additional artists as list - other_genres # additional genres as list - - bpm - composer - conductor - copyright - director - encoded_by - encoder_settings - initial_key - isrc - language - lyricist - lyrics - media - publisher - set_subtitle - url - -Any other `extra` field names are not guaranteed to be consistent across audio formats. - -Additionally, you can also get images from ID3 tags. To receive any available image, prioritizing the front cover: - - tag: TinyTag = TinyTag.get('/some/music.mp3', image=True) - image: TagImage | None = tag.images.any - - if image is not None: - data: bytes = image.data - name: str = image.name - description: str = image.description - -If you need to receive an image of a specific kind, including its description, use `images`: - - tag.images # available embedded images - -The following common images are available: - - front_cover - back_cover - leaflet - media - other +The `extra` dict currently *may* contain the following data: + `url`, `isrc`, `text`, `initial_key`, `lyrics`, `copyright` -The following less common images are provided in an `extra` dict when present: +Additionally you can also get cover images from ID3 tags: - icon - other_icon - lead_artist - artist - conductor - band - composer - lyricist - recording_location - during_recording - during_performance - video - bright_colored_fish - illustration - band_logo - publisher_logo - unknown - -The following image attributes are available: - - data # image data as bytes - name # image name/kind as string - mime_type # image MIME type as string - description # image description as string - -To receive a common image, e.g. `front_cover`: - - from tinytag import TinyTag, TagImage, TagImages - - tag: TinyTag = TinyTag.get('/some/music.ogg') - images: TagImages = tag.images - front_cover_images: list[TagImage] = images.front_cover - - if front_cover_images: - image: TagImage = front_cover_images[0] # Use first image - data: bytes = image.data - description: str = image.description - -To receive an extra image, e.g. `bright_colored_fish`: - - fish_images = tag.images.extra.get('bright_colored_fish') - - if fish_images: - image = fish_images[0] # Use first image - data = image.data - description = image.description - -### Encoding + tag = TinyTag.get('/some/music.mp3', image=True) + image_data = tag.get_image() To open files using a specific encoding, you can use the `encoding` parameter. This parameter is however only used for formats where the encoding isn't explicitly @@ -193,19 +98,11 @@ specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') -### File-like Objects - To use a file-like object (e.g. BytesIO) instead of a file path, pass a `file_obj` keyword argument: TinyTag.get(file_obj=your_file_obj) -### Exceptions - - TinyTagException # Base class for exceptions - ParseError # Parsing an audio file failed - UnsupportedFormatError # File format is not supported - ## Changelog From 4075bf9c80be60c8fcf819fef59e05f4f98874be Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 12 May 2024 14:34:21 +0300 Subject: [PATCH 192/305] tests.yml: use macOS 13 --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 12145bb..f53052d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-13, windows-latest] python: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10', 'graalpy-23' From 7903c625a2d0826b18dac50952714fe6178ff3ba Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 12 May 2024 14:38:45 +0300 Subject: [PATCH 193/305] Rename DEBUG env variable to TINYTAG_DEBUG Closes #210 --- tinytag/tests/test_cli.py | 4 ++-- tinytag/tinytag.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index bebd7cc..f6a30d7 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -22,10 +22,10 @@ def run_cli(args: str) -> str: - debug_env = str(os.environ.pop("DEBUG", None)) + debug_env = str(os.environ.pop("TINYTAG_DEBUG", None)) output = check_output(f'{sys.executable} -m tinytag ' + args, cwd=project_folder, shell=True) if debug_env: - os.environ["DEBUG"] = debug_env + os.environ["TINYTAG_DEBUG"] = debug_env return output.decode('utf-8') diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d10675f..3b39967 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -50,7 +50,7 @@ import struct -DEBUG = bool(os.environ.get('DEBUG')) # some of the parsers can print debug info +DEBUG = bool(os.environ.get('TINYTAG_DEBUG')) # some of the parsers can print debug info class TinyTagException(Exception): From b351e17fdb0888c5f44001c8e82e6e213315b208 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 12 May 2024 14:45:54 +0300 Subject: [PATCH 194/305] CI: use new TINYTAG_DEBUG env variable --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f53052d..c3610c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -48,7 +48,7 @@ jobs: - name: Unit tests run: python -m pytest --cov env: - DEBUG: true + TINYTAG_DEBUG: true - name: Build package run: python -m build From beaf70003559a4b632aae489f9609ac1b1c4b469 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 12 May 2024 14:54:13 +0300 Subject: [PATCH 195/305] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 6 ++---- .github/ISSUE_TEMPLATE/feature_request.md | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9cc2bb4..5a64758 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,9 +1,7 @@ --- name: Bug report -about: Create a report to help us improve -title: "[BUG] INSERT TITLE HERE" +about: Something is broken or doesn't work as expected labels: bug -assignees: '' --- @@ -20,5 +18,5 @@ Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. -**Sample File** +**Sample file** Please provide a sample file that shows the behavior that you have described in this report. You can use any upload site (such as wetransfer) and provide the link here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..9390310 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,9 +1,7 @@ --- name: Feature request about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' +labels: enhancement --- From f9bdcc6220b9ab51a9f0c396a08850ddc276246a Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 21 May 2024 03:41:07 +0300 Subject: [PATCH 196/305] Undeprecate 'composer' attribute It's commonly used instead of 'artist' when tagging classical music. Deprecating the attribute is also an annoyance for users of tinytag. --- README.md | 1 - tinytag/tests/test_all.py | 42 ++++++++++++++++++--------------------- tinytag/tests/test_cli.py | 2 +- tinytag/tinytag.py | 19 ++++++------------ 4 files changed, 26 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 79449d2..0c18f7e 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,6 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - **BREAKING:** Remove support for Python 2 - Mark 'ignore_errors' parameter for TinyTag.get() as obsolete - Mark 'audio_offset' attribute as obsolete -- Deprecate 'composer' attribute in favor of 'extra.composer' - Deprecate 'get_image()' method in favor of 'images.any' property - Provide access to custom metadata fields through the 'extra' dict - Provide access to all available images diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 355ca94..f244e07 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -96,9 +96,8 @@ 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v24-long-title.mp3', {'extra': - {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett', - 'composer': 'Courtney Barnett'}, - 'track': 1, 'disc_total': 1, + {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett'}, + 'track': 1, 'disc_total': 1, 'composer': 'Courtney Barnett', 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, 'track_total': 12, 'genre': 'AlternRock', 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', @@ -176,15 +175,14 @@ 'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious', 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America', - 'composer': 'Billy Howerdel/Maynard James Keenan', 'media': 'CD', - 'tso2': 'Perfect Circle, A', + 'media': 'CD', 'tso2': 'Perfect Circle, A', 'ufid': 'http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72', 'tsop': 'Perfect Circle, A', 'tory': '2004', 'tdat': '0211', 'ipls': ('producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan' '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter')}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', - 'artist': 'A Perfect Circle', 'bitrate': 192.0, 'channels': 2, - 'duration': 0.13198711063372717, 'genre': 'Rock', + 'artist': 'A Perfect Circle', 'composer': 'Billy Howerdel/Maynard James Keenan', + 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', 'samplerate': 44100, 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', 'track': 10, 'comment': ' ', 'disc': 1, 'disc_total': 1, 'track_total': 12, 'year': '2004'}), @@ -265,8 +263,8 @@ {'extra': {}, 'filesize': 18648, 'bitrate': 80.0, 'duration': 2.132358276643991, 'samplerate': 44100, 'channels': 1}), ('samples/composer.ogg', - {'extra': {'composer': 'some composer'}, 'filesize': 4480, - 'album': 'An Album', 'artist': 'An Artist', + {'extra': {}, 'filesize': 4480, + 'album': 'An Album', 'artist': 'An Artist', 'composer': 'some composer', 'bitrate': 112.0, 'duration': 3.684716553287982, 'channels': 2, 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2, 'year': '2007', 'comment': 'A Comment'}), @@ -382,13 +380,13 @@ 'organization': 'Sony Music Records (SRCP-371)', 'ripper': 'Exact Audio Copy 0.99pb5', 'replaygain_album_gain': '-8.68 dB', 'replaygain_album_peak': '1.000000', - 'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000', - 'composer': 'Boom Boom Satellites (Lyrics)'}, + 'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000'}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': 1, 'track_total': 11, 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, 'disc': 1, 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'bitdepth': 16, - 'disc_total': 2, 'comment': 'Original Soundtrack'}), + 'disc_total': 2, 'comment': 'Original Soundtrack', + 'composer': 'Boom Boom Satellites (Lyrics)'}), ('samples/106-invalid-streaminfo.flac', {'extra': {}, 'filesize': 4692}), ('samples/106-short-picture-block-size.flac', @@ -441,11 +439,11 @@ 'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}', 'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145', 'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369, - 'averagelevel': 7291, 'composer': 'Foo Fighters'}, + 'averagelevel': 7291}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', - 'genre': 'Alternative', 'channels': 2}), + 'genre': 'Alternative', 'composer': 'Foo Fighters', 'channels': 2}), ('samples/lossless.wma', {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, 'duration': 43.133, 'channels': 2}), @@ -476,14 +474,14 @@ '/PropertyList-1.0.dtd">\n\n\n\t' 'asset-info\n\t\n\t\tflavor\n\t\t' '2:256\n\t\n\n\n'), - 'tool': 144255989988720642, - 'composer': "Millie Jackson - Get It Out 'cha System - 1978"}, + 'tool': 144255989988720642}, 'bitrate': 256.0, 'track': 1, 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', 'artist': 'Millie Jackson', 'track_total': 9, 'disc_total': 1, 'genre': 'R&B/Soul', 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': 1, 'title': 'Go Out and Get Some', + 'composer': "Millie Jackson - Get It Out 'cha System - 1978", 'comment': "Millie Jackson - Get It Out 'cha System - 1978"}), ('samples/iso8859_with_image.m4a', {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, @@ -494,8 +492,9 @@ 'comment': '? 2016 Mad Decent'}), ('samples/alac_file.m4a', {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:', - 'upc': '0034571177380', 'composer': 'Clementi, Muzio (1752-1832)'}, + 'upc': '0034571177380'}, 'artist': 'Howard Shelley', 'filesize': 20000, + 'composer': 'Clementi, Muzio (1752-1832)', 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': 14, 'track_total': 27, 'disc': 1, 'disc_total': 1, 'samplerate': 44100, @@ -515,9 +514,8 @@ 'duration': 727.1066666666667, 'extra': {'description': 'test description'}}), ('samples/test3.m4a', - {'extra': {'publisher': 'test7', 'bpm': 99999, 'composer': 'test8', - 'encoded_by': 'Lavf60.3.100'}, - 'artist': 'test1', + {'extra': {'publisher': 'test7', 'bpm': 99999, 'encoded_by': 'Lavf60.3.100'}, + 'artist': 'test1', 'composer': 'test8', 'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1, 'bitrate': 27.887}), @@ -799,8 +797,6 @@ def test_deprecations() -> None: file_path = os.path.join(testfolder, 'samples/id3v24-long-title.mp3') with pytest.warns(DeprecationWarning): tag = TinyTag.get(filename=file_path, image=True, ignore_errors=True) - with pytest.warns(DeprecationWarning): - assert tag.composer == tag.extra.get('composer') with pytest.warns(DeprecationWarning): assert tag.audio_offset is None with pytest.warns(DeprecationWarning): @@ -813,7 +809,7 @@ def test_to_str() -> None: assert ( "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " "'bitdepth': None, 'samplerate': 44100, 'artist': 'Anais Mitchell', 'albumartist': None, " - "'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " + "'composer': None, 'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " "'title': 'cosmic american', 'track': 3, 'track_total': 11, 'genre': None, " "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " "'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index f6a30d7..1fca347 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -16,7 +16,7 @@ assert os.path.exists(mp3_with_image) tinytag_attributes = {'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', - 'channels', 'comment', 'disc', 'disc_total', 'duration', 'extra', + 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year'} diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3b39967..3ea07ad 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -89,6 +89,7 @@ def __init__(self) -> None: self.samplerate: int | None = None self.artist: str | None = None self.albumartist: str | None = None + self.composer: str | None = None self.album: str | None = None self.disc: int | None = None self.disc_total: int | None = None @@ -332,14 +333,6 @@ def audio_offset(self) -> None: warn('audio_offset attribute is obsolete, and will be ' 'removed in a future 2.x release', DeprecationWarning, stacklevel=2) - @property - def composer(self) -> str | None: - """Deprecated, use extra.composer instead.""" - warn('composer attribute is deprecated, and will be removed in a future 2.x release. ' - 'Use extra.composer instead.', DeprecationWarning, stacklevel=2) - composer = self.extra.get('composer') - return composer if isinstance(composer, str) else None - class TagImages: """A class containing images embedded in an audio file.""" @@ -593,7 +586,7 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: b'\xa9nam': {b'data': _Parser._make_data_atom_parser('title')}, b'\xa9pub': {b'data': _Parser._make_data_atom_parser('extra.publisher')}, b'\xa9too': {b'data': _Parser._make_data_atom_parser('extra.encoded_by')}, - b'\xa9wrt': {b'data': _Parser._make_data_atom_parser('extra.composer')}, + b'\xa9wrt': {b'data': _Parser._make_data_atom_parser('composer')}, b'aART': {b'data': _Parser._make_data_atom_parser('albumartist')}, b'cprt': {b'data': _Parser._make_data_atom_parser('extra.copyright')}, b'desc': {b'data': _Parser._make_data_atom_parser('extra.description')}, @@ -683,7 +676,7 @@ class _ID3(TinyTag): 'TCON': 'genre', 'TCO': 'genre', 'TPOS': 'disc', 'TPA': 'disc', 'TPE2': 'albumartist', 'TP2': 'albumartist', - 'TCOM': 'extra.composer', 'TCM': 'extra.composer', + 'TCOM': 'composer', 'TCM': 'composer', 'WOAR': 'extra.url', 'WAR': 'extra.url', 'TSRC': 'extra.isrc', 'TRC': 'extra.isrc', 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', @@ -1162,7 +1155,7 @@ class _Ogg(TinyTag): 'description': 'comment', 'comment': 'comment', 'comments': 'comment', - 'composer': 'extra.composer', + 'composer': 'composer', 'bpm': 'extra.bpm', 'copyright': 'extra.copyright', 'isrc': 'extra.isrc', @@ -1347,7 +1340,7 @@ class _Wave(TinyTag): b'IART': 'artist', b'IBPM': 'extra.bpm', b'ICMT': 'comment', - b'IMUS': 'extra.composer', + b'IMUS': 'composer', b'ICOP': 'extra.copyright', b'ICRD': 'year', b'IGNR': 'genre', @@ -1541,7 +1534,7 @@ class _Wma(TinyTag): 'WM/AlbumArtist': 'albumartist', 'WM/Genre': 'genre', 'WM/AlbumTitle': 'album', - 'WM/Composer': 'extra.composer', + 'WM/Composer': 'composer', 'WM/Publisher': 'extra.publisher', 'WM/BeatsPerMinute': 'extra.bpm', 'WM/InitialKey': 'extra.initial_key', From e552e16121ad8a20d0fd0a9ed901b83ff155fca0 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 21 May 2024 03:51:15 +0300 Subject: [PATCH 197/305] Fix linting error --- tinytag/tinytag.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3ea07ad..fc50e6f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -830,6 +830,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3._SAMPLES_PER_FRAME frame_size_accu = 0 + audio_offset = 0 header_bytes = 4 frames = 0 # count frames for determining mp3 duration bitrate_accu = 0 # add up bitrates to find average bitrate to detect From abe7561192f5713c2bd3ea71a947d08967bf2e45 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 21 May 2024 04:25:47 +0300 Subject: [PATCH 198/305] CI: test GraalPy 24 --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c3610c1..1a63c5d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,11 +11,11 @@ jobs: os: [ubuntu-latest, macos-13, windows-latest] python: [ '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', - 'pypy-3.10', 'graalpy-23' + 'pypy-3.10', 'graalpy-24' ] exclude: - os: windows-latest - python: 'graalpy-23' + python: 'graalpy-24' steps: - name: Checkout code uses: actions/checkout@v4 @@ -36,7 +36,7 @@ jobs: run: python -m pycodestyle - name: Linting - if: matrix.python != 'graalpy-23' + if: matrix.python != 'graalpy-24' run: python -m pylint --recursive=y . - name: Typing From 40484dbf7dd30812b3cc50523f4b7657878ffe07 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 21 May 2024 04:35:55 +0300 Subject: [PATCH 199/305] CI: test Python 3.13 beta --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1a63c5d..5dcc691 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,8 +10,8 @@ jobs: matrix: os: [ubuntu-latest, macos-13, windows-latest] python: [ - '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', - 'pypy-3.10', 'graalpy-24' + '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-beta.1', 'pypy-3.7', 'pypy-3.8', + 'pypy-3.9', 'pypy-3.10', 'graalpy-24' ] exclude: - os: windows-latest From 5b966007cbb51845b5cbfbe708ab61dd17199c40 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 28 May 2024 22:31:47 +0300 Subject: [PATCH 200/305] Add more standard extra fields --- tinytag/tests/test_all.py | 7 +++---- tinytag/tinytag.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index f244e07..e16cf3f 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -159,16 +159,15 @@ 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), ('samples/id3_xxx_lang.mp3', - {'extra': {'script': 'Latn', 'originalyear': '2004', + {'extra': {'script': 'Latn', 'acoustid id': '2dc0b571-a633-45b0-aa5e-f3d25e4e0020', 'musicbrainz album type': 'album', 'musicbrainz album artist id': '078a9376-3c04-4280-b7d7-b20e158f345d', 'musicbrainz artist id': '078a9376-3c04-4280-b7d7-b20e158f345d', 'barcode': '724386668721', 'musicbrainz album id': '38b555fe-24c7-37b3-ad1b-f6dea9f1aafa', - 'artists': 'A Perfect Circle', 'musicbrainz release track id': '7f7c31a5-0905-39ba-ba72-68db91d3b9da', - 'catalognumber': '7243 8 66687 2 1', + 'catalog_number': '7243 8 66687 2 1', 'musicbrainz release group id': '0f21095a-e629-389c-981a-d9569e9673c9', 'musicbrainz album status': 'official', 'asin': 'B000641ZIQ', 'musicbrainz album release country': 'US', @@ -177,7 +176,7 @@ 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America', 'media': 'CD', 'tso2': 'Perfect Circle, A', 'ufid': 'http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72', - 'tsop': 'Perfect Circle, A', 'tory': '2004', 'tdat': '0211', + 'tsop': 'Perfect Circle, A', 'original_year': '2004', 'tdat': '0211', 'ipls': ('producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan' '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter')}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index fc50e6f..48a2939 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -391,6 +391,7 @@ class _Parser: atom_decoder_by_type: dict[ int, Callable[[bytes], int | str | bytes | TagImage]] | None = None _CUSTOM_FIELD_NAME_MAPPING = { + 'artists': 'artist', 'conductor': 'extra.conductor', 'discsubtitle': 'extra.set_subtitle', 'initialkey': 'extra.initial_key', @@ -398,6 +399,12 @@ class _Parser: 'language': 'extra.language', 'lyricist': 'extra.lyricist', 'media': 'extra.media', + 'website': 'extra.url', + 'originaldate': 'extra.original_date', + 'originalyear': 'extra.original_year', + 'license': 'extra.license', + 'barcode': 'extra.barcode', + 'catalognumber': 'extra.catalog_number', } @classmethod @@ -691,6 +698,17 @@ class _ID3(TinyTag): 'TENC': 'extra.encoded_by', 'TEN': 'extra.encoded_by', 'TSSE': 'extra.encoder_settings', 'TSS': 'extra.encoder_settings', 'TMED': 'extra.media', 'TMT': 'extra.media', + 'TDOR': 'extra.original_date', + 'TORY': 'extra.original_year', 'TOR': 'extra.original_year', + 'WCOP': 'extra.license', + } + _ID3_MAPPING_CUSTOM = { + 'artists': 'artist', + 'director': 'extra.director', + 'license': 'extra.license', + 'originalyear': 'extra.original_year', + 'barcode': 'extra.barcode', + 'catalognumber': 'extra.catalog_number', } _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} @@ -982,8 +1000,11 @@ def asciidecode(x: bytes) -> str: def __parse_custom_field(self, content: str) -> bool: custom_field_name, separator, value = content.partition('\x00') - if custom_field_name and separator: - self._set_field(self._EXTRA_PREFIX + custom_field_name.lower(), value.lstrip('\ufeff')) + custom_field_name_lower = custom_field_name.lower() + if custom_field_name_lower and separator: + field_name = self._ID3_MAPPING_CUSTOM.get( + custom_field_name_lower, self._EXTRA_PREFIX + custom_field_name_lower) + self._set_field(field_name, value.lstrip('\ufeff')) return True return False @@ -1144,6 +1165,7 @@ class _Ogg(TinyTag): 'albumartist': 'albumartist', 'title': 'title', 'artist': 'artist', + 'artists': 'artist', 'author': 'artist', 'date': 'year', 'tracknumber': 'track', @@ -1174,6 +1196,11 @@ class _Ogg(TinyTag): 'encodedby': 'extra.encoded_by', 'encodersettings': 'extra.encoder_settings', 'media': 'extra.media', + 'originaldate': 'extra.original_date', + 'originalyear': 'extra.original_year', + 'license': 'extra.license', + 'barcode': 'extra.barcode', + 'catalognumber': 'extra.catalog_number', } def __init__(self) -> None: @@ -1529,6 +1556,7 @@ class _Wma(TinyTag): # and (japanese, but none the less helpful) # http://uguisu.skr.jp/Windows/format_asf.html _ASF_MAPPING = { + 'WM/ARTISTS': 'artist', 'WM/TrackNumber': 'track', 'WM/PartOfSet': 'disc', 'WM/Year': 'year', @@ -1541,6 +1569,7 @@ class _Wma(TinyTag): 'WM/InitialKey': 'extra.initial_key', 'WM/Lyrics': 'extra.lyrics', 'WM/Language': 'extra.language', + 'WM/Director': 'extra.director', 'WM/AuthorURL': 'extra.url', 'WM/ISRC': 'extra.isrc', 'WM/Conductor': 'extra.conductor', @@ -1549,6 +1578,10 @@ class _Wma(TinyTag): 'WM/EncodedBy': 'extra.encoded_by', 'WM/EncodingSettings': 'extra.encoder_settings', 'WM/Media': 'extra.media', + 'WM/OriginalReleaseTime': 'extra.original_date', + 'WM/OriginalReleaseYear': 'extra.original_year', + 'WM/Barcode': 'extra.barcode', + 'WM/CatalogNo': 'extra.catalog_number', } _ASF_CONTENT_DESCRIPTION_OBJECT = b'3&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT = (b'@\xa4\xd0\xd2\x07\xe3\xd2\x11\x97\xf0\x00' From c52b01b4faf229c824716f710307ce0f07d332e6 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 11 May 2024 17:08:39 +0300 Subject: [PATCH 201/305] Allow reading multiple extra fields of same type Fixes #206 --- tinytag/tests/test_all.py | 273 ++++++++++++++++++++------------------ tinytag/tinytag.py | 126 ++++++++++-------- 2 files changed, 211 insertions(+), 188 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index e16cf3f..94ae7f3 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -24,7 +24,8 @@ testfiles = dict([ # MP3 ('samples/vbri.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, + {'extra': {}, + 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, 'filesize': 8192, 'genre': 'Dance', @@ -40,19 +41,19 @@ {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, 'duration': 3.944489795918367, 'filesize': 91731}), ('samples/vbr_xing_header_2channel.mp3', - {'extra': {'encoder_settings': 'LAME 32bits version 3.99.5 (http://lame.sf.net)', - 'tlen': '249976'}, + {'extra': {'encoder_settings': ['LAME 32bits version 3.99.5 (http://lame.sf.net)'], + 'tlen': ['249976']}, 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', 'bitrate': 46.276128290848305, 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, 'title': 'Lochaber No More', 'year': '1992'}), ('samples/id3v22-test.mp3', - {'extra': {'encoded_by': 'iTunes v4.6', - 'itunnorm': (' 0000044E 00000061 00009B67 000044C3 00022478 00022182 ' - '00007FCC 00007E5C 0002245E 0002214E'), - 'itunes_cddb_1': ('9D09130B+174405+11+150+14097+27391+43983+65786+84877+' - '99399+113226+132452+146426+163829'), - 'itunes_cddb_tracknumber': '3'}, + {'extra': {'encoded_by': ['iTunes v4.6'], + 'itunnorm': [' 0000044E 00000061 00009B67 000044C3 00022478 00022182 ' + '00007FCC 00007E5C 0002245E 0002214E'], + 'itunes_cddb_1': ['9D09130B+174405+11+150+14097+27391+43983+65786+84877+' + '99399+113226+132452+146426+163829'], + 'itunes_cddb_tracknumber': ['3']}, 'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.13836297152858082, 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', 'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120, @@ -67,18 +68,18 @@ 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': 12, 'artist': 'Björk', 'year': '1993', 'comment': ' '}), ('samples/UTF16.mp3', - {'extra': {'musicbrainz artist id': '664c3e0e-42d8-48c1-b209-1efca19c0325', - 'musicbrainz album id': '25322466-a29b-417b-b560-399687b91ddd', - 'musicbrainz album artist id': '664c3e0e-42d8-48c1-b209-1efca19c0325', - 'musicbrainz disc id': 'p.5xoyYRtCVFe2gt0mfTfsXrO9U-', - 'musicip puid': '6ff97581-1c73-fc05-b4e4-a4ccee12ec84', 'asin': 'B003KVNV4S', - 'musicbrainz album status': 'Official', 'musicbrainz album type': 'Album', - 'musicbrainz album release country': 'United States', - 'ufid': 'http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5', - 'publisher': '4AD', 'tdat': '1105', - 'wxxx': 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet', - 'media': 'Digital', 'tlen': '203733', - 'encoder_settings': 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)'}, + {'extra': {'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], + 'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'], + 'musicbrainz album artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], + 'musicbrainz disc id': ['p.5xoyYRtCVFe2gt0mfTfsXrO9U-'], + 'musicip puid': ['6ff97581-1c73-fc05-b4e4-a4ccee12ec84'], 'asin': ['B003KVNV4S'], + 'musicbrainz album status': ['Official'], 'musicbrainz album type': ['Album'], + 'musicbrainz album release country': ['United States'], + 'ufid': ['http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5'], + 'publisher': ['4AD'], 'tdat': ['1105'], + 'wxxx': ['WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet'], + 'media': ['Digital'], 'tlen': ['203733'], + 'encoder_settings': ['LAME 32bits version 3.98.4 (http://www.mp3dev.org/)']}, 'track_total': 11, 'track': 7, 'artist': 'The National', 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, 'genre': 'Indie', 'comment': 'Track 7'}), @@ -96,7 +97,7 @@ 'bitrate': 32.0, 'duration': 1.0438932496075353}), ('samples/id3v24-long-title.mp3', {'extra': - {'copyright': '2013 Marathon Artists under exclsuive license from Courtney Barnett'}, + {'copyright': ['2013 Marathon Artists under exclsuive license from Courtney Barnett']}, 'track': 1, 'disc_total': 1, 'composer': 'Courtney Barnett', 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, 'track_total': 12, 'genre': 'AlternRock', @@ -107,27 +108,28 @@ {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, 'album': 'party mix', 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), ('samples/id3v22_image.mp3', - {'extra': {'rva': '\x10', 'bpm': '131'}, 'title': 'Kids (MGMT Cover) ', 'filesize': 35924, + {'extra': {'rva': ['\x10'], 'bpm': ['131']}, 'title': 'Kids (MGMT Cover) ', + 'filesize': 35924, 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', 'genre': '.'}), ('samples/id3v22.TCO.genre.mp3', - {'extra': {'encoded_by': 'iTunes 11.0.4', - 'itunnorm': (' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A 0000902E ' - '00008D36 00020882 000321D6'), - 'itunsmpb': (' 00000000 00000210 000007B9 00000000008FB737 00000000 008242F1 ' - '00000000 00000000 00000000 00000000 00000000 00000000'), - 'itunpgap': '0'}, + {'extra': {'encoded_by': ['iTunes 11.0.4'], + 'itunnorm': [' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A 0000902E ' + '00008D36 00020882 000321D6'], + 'itunsmpb': [' 00000000 00000210 000007B9 00000000008FB737 00000000 008242F1 ' + '00000000 00000000 00000000 00000000 00000000 00000000'], + 'itunpgap': ['0']}, 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', 'genre': 'Pop', 'title': 'Applause'}), ('samples/id3_comment_utf_16_with_bom.mp3', - {'extra': {'copyright': '(c) 2008 nin', 'isrc': 'USTC40852229', 'bpm': '60', - 'url': 'www.nin.com', 'encoded_by': 'LAME 3.97'}, + {'extra': {'copyright': ['(c) 2008 nin'], 'isrc': ['USTC40852229'], 'bpm': ['60'], + 'url': ['www.nin.com'], 'encoded_by': ['LAME 3.97']}, 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', 'disc': 1, 'disc_total': 2, 'title': '1 Ghosts I', 'track': 1, 'track_total': 36, 'year': '2008', 'comment': '3/4 time'}), ('samples/id3_comment_utf_16_double_bom.mp3', - {'extra': {'label': 'Unclear'}, 'filesize': 512, 'album': 'The Embrace', + {'extra': {'label': ['Unclear']}, 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', 'title': 'The Embrace (Romano Alfieri Remix)', 'year': '2012'}), @@ -142,11 +144,11 @@ ('samples/id3v1_does_not_overwrite_id3v2.mp3', {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', 'artist': 'Blind Guardian', - 'extra': {'love rating': 'L', 'publisher': 'Century Media', 'popm': 'MusicBee\x00Ä'}, + 'extra': {'love rating': ['L'], 'publisher': ['Century Media'], 'popm': ['MusicBee\x00Ä']}, 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, 'year': '1992'}), ('samples/non_ascii_filename_äää.mp3', - {'extra': {'encoder_settings': 'Lavf58.20.100'}, 'filesize': 80919, 'channels': 2, + {'extra': {'encoder_settings': ['Lavf58.20.100']}, 'filesize': 80919, 'channels': 2, 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), ('samples/chinese_id3.mp3', {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', @@ -154,31 +156,31 @@ 'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, 'title': '½ÇÂäÖ®¸è', 'track': 1}), ('samples/cut_off_titles.mp3', - {'extra': {'encoder_settings': 'Lavf54.29.104'}, 'filesize': 1000, 'album': 'ERB', + {'extra': {'encoder_settings': ['Lavf54.29.104']}, 'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), ('samples/id3_xxx_lang.mp3', - {'extra': {'script': 'Latn', - 'acoustid id': '2dc0b571-a633-45b0-aa5e-f3d25e4e0020', - 'musicbrainz album type': 'album', - 'musicbrainz album artist id': '078a9376-3c04-4280-b7d7-b20e158f345d', - 'musicbrainz artist id': '078a9376-3c04-4280-b7d7-b20e158f345d', - 'barcode': '724386668721', - 'musicbrainz album id': '38b555fe-24c7-37b3-ad1b-f6dea9f1aafa', - 'musicbrainz release track id': '7f7c31a5-0905-39ba-ba72-68db91d3b9da', - 'catalog_number': '7243 8 66687 2 1', - 'musicbrainz release group id': '0f21095a-e629-389c-981a-d9569e9673c9', - 'musicbrainz album status': 'official', - 'asin': 'B000641ZIQ', 'musicbrainz album release country': 'US', - 'isrc': 'USVI20400513', 'lyrics': 'Don\'t fret, precious', - 'replaygain_track_gain': '-3.95 dB', 'replaygain_track_peak': '0.999969', - 'replaygain_album_gain': '-8.26 dB', 'publisher': 'Virgin Records America', - 'media': 'CD', 'tso2': 'Perfect Circle, A', - 'ufid': 'http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72', - 'tsop': 'Perfect Circle, A', 'original_year': '2004', 'tdat': '0211', - 'ipls': ('producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan' - '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter')}, + {'extra': {'script': ['Latn'], + 'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'], + 'musicbrainz album type': ['album'], + 'musicbrainz album artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'], + 'musicbrainz artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'], + 'barcode': ['724386668721'], + 'musicbrainz album id': ['38b555fe-24c7-37b3-ad1b-f6dea9f1aafa'], + 'musicbrainz release track id': ['7f7c31a5-0905-39ba-ba72-68db91d3b9da'], + 'catalog_number': ['7243 8 66687 2 1'], + 'musicbrainz release group id': ['0f21095a-e629-389c-981a-d9569e9673c9'], + 'musicbrainz album status': ['official'], + 'asin': ['B000641ZIQ'], 'musicbrainz album release country': ['US'], + 'isrc': ['USVI20400513'], 'lyrics': ['Don\'t fret, precious'], + 'replaygain_track_gain': ['-3.95 dB'], 'replaygain_track_peak': ['0.999969'], + 'replaygain_album_gain': ['-8.26 dB'], 'publisher': ['Virgin Records America'], + 'media': ['CD'], 'tso2': ['Perfect Circle, A'], + 'ufid': ['http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72'], + 'tsop': ['Perfect Circle, A'], 'original_year': ['2004'], 'tdat': ['0211'], + 'ipls': ['producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan' + '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter']}, 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', 'artist': 'A Perfect Circle', 'composer': 'Billy Howerdel/Maynard James Keenan', 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', @@ -237,8 +239,8 @@ ('samples/id3_multiple_artists.mp3', {'filesize': 2007, 'bitrate': 57.39124999999999, 'channels': 1, 'duration': 0.1306122448979592, - 'extra': {'other_artists': ['artist2', 'artist3', 'artist4', 'artist5', - 'artist6', 'artist7']}, + 'extra': {'artist': ['artist2', 'artist3', 'artist4', 'artist5', + 'artist6', 'artist7']}, 'samplerate': 44100, 'artist': 'artist1', 'genre': 'something 1'}), # OGG @@ -246,9 +248,9 @@ {'extra': {}, 'duration': 3.684716553287982, 'filesize': 4328, 'bitrate': 112.0, 'samplerate': 44100, 'channels': 2}), ('samples/multipage-setup.ogg', - {'extra': {'transcoded': 'mp3;241', 'replaygain_album_gain': '-10.29 dB', - 'replaygain_album_peak': '1.50579047', 'replaygain_track_peak': '1.17979193', - 'replaygain_track_gain': '-10.02 dB'}, + {'extra': {'transcoded': ['mp3;241'], 'replaygain_album_gain': ['-10.29 dB'], + 'replaygain_album_peak': ['1.50579047'], 'replaygain_track_peak': ['1.17979193'], + 'replaygain_track_gain': ['-10.02 dB']}, 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': 7, 'filesize': 76983, 'bitrate': 160.0, @@ -268,21 +270,22 @@ 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2, 'year': '2007', 'comment': 'A Comment'}), ('samples/test.opus', - {'extra': {'encoder': 'Lavc57.24.102 libopus', 'arrange': '\u6771\u65b9', - 'catalogid': 'ARCD0024', 'discid': 'A212230D', 'event': '\u4f8b\u5927\u796d5', - 'lyricist': 'Haruka', 'mastering': 'Hedonist', - 'origin': '\u6771\u65b9\u5e7b\u60f3\u90f7', 'originaltitle': 'Bad Apple!!', - 'performer': 'Masayoshi Minoshima', 'vocal': 'nomico'}, + {'extra': {'encoder': ['Lavc57.24.102 libopus'], 'arrange': ['\u6771\u65b9'], + 'catalogid': ['ARCD0024'], 'discid': ['A212230D'], + 'event': ['\u4f8b\u5927\u796d5'], + 'lyricist': ['Haruka'], 'mastering': ['Hedonist'], + 'origin': ['\u6771\u65b9\u5e7b\u60f3\u90f7'], 'originaltitle': ['Bad Apple!!'], + 'performer': ['Masayoshi Minoshima'], 'vocal': ['nomico']}, 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, 'track': 1, 'disc': 1, 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', 'album': 'Exserens - A selection of Alstroemeria Records', 'comment': 'ARCD0018 - Lovelight', 'disc_total': 1, 'track_total': 13}), ('samples/8khz_5s.opus', - {'extra': {'encoder': 'opusenc from opus-tools 0.2'}, 'filesize': 7251, 'channels': 1, + {'extra': {'encoder': ['opusenc from opus-tools 0.2']}, 'filesize': 7251, 'channels': 1, 'samplerate': 48000, 'duration': 5.0065}), ('samples/test_flac.oga', - {'extra': {'copyright': 'test3', 'isrc': 'test4', 'lyrics': 'test7'}, + {'extra': {'copyright': ['test3'], 'isrc': ['test4'], 'lyrics': ['test7']}, 'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5', 'bitrate': 20.022488249118684, 'duration': 3.705034013605442, 'channels': 2, 'genre': 'Acoustic', 'samplerate': 44100, 'bitdepth': 16, 'title': 'test1', 'track': 5, @@ -313,8 +316,8 @@ {'extra': {}, 'channels': 1, 'duration': 0.9991836734693877, 'filesize': 48160, 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16}), ('samples/id3_header_with_a_zero_byte.wav', - {'extra': {}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, 'bitrate': 352.8, - 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', + {'extra': {'title': ['Stacked']}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, + 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', 'title': 'Test000', 'track': 17, 'album': 'prototypes'}), ('samples/adpcm.wav', @@ -357,13 +360,13 @@ 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', 'samplerate': 44100, 'bitdepth': 16, 'comment': 'hello'}), ('samples/flac_application.flac', - {'extra': {'replaygain_track_peak': '0.9976', - 'musicbrainz_albumartistid': 'e5c7b94f-e264-473c-bb0f-37c85d4d5c70', - 'musicbrainz_trackid': 'e65fb332-0c1e-4172-85e0-59cd37e5669e', - 'replaygain_album_gain': '-8.14 dB', 'labelid': 'RTRADLP480', - 'musicbrainz_albumid': '359a91e9-3bb3-4b60-a823-8aaa4bad1e36', - 'artistsort': 'Belle and Sebastian', 'replaygain_track_gain': '-8.08 dB', - 'replaygain_album_peak': '1.0000'}, + {'extra': {'replaygain_track_peak': ['0.9976'], + 'musicbrainz_albumartistid': ['e5c7b94f-e264-473c-bb0f-37c85d4d5c70'], + 'musicbrainz_trackid': ['e65fb332-0c1e-4172-85e0-59cd37e5669e'], + 'replaygain_album_gain': ['-8.14 dB'], 'labelid': ['RTRADLP480'], + 'musicbrainz_albumid': ['359a91e9-3bb3-4b60-a823-8aaa4bad1e36'], + 'artistsort': ['Belle and Sebastian'], 'replaygain_track_gain': ['-8.08 dB'], + 'replaygain_album_peak': ['1.0000']}, 'channels': 2, 'track_total': 11, 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, 'title': 'I Want the World to Stop', 'track': 4, 'artist': 'Belle and Sebastian', @@ -372,14 +375,14 @@ {'extra': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, 'bitrate': 10.186943678613627, 'samplerate': 44100, 'bitdepth': 16}), ('samples/variable-block.flac', - {'extra': {'discid': 'AA0B360B', - 'japanese title': ('\u30a2\u30c3\u30d7\u30eb\u30b7\u30fc\u30c9 ' + {'extra': {'discid': ['AA0B360B'], + 'japanese title': ['\u30a2\u30c3\u30d7\u30eb\u30b7\u30fc\u30c9 ' '\u30aa\u30ea\u30b8\u30ca\u30eb\u30fb\u30b5\u30a6' - '\u30f3\u30c9\u30c8\u30e9\u30c3\u30af'), - 'organization': 'Sony Music Records (SRCP-371)', - 'ripper': 'Exact Audio Copy 0.99pb5', - 'replaygain_album_gain': '-8.68 dB', 'replaygain_album_peak': '1.000000', - 'replaygain_track_gain': '-9.61 dB', 'replaygain_track_peak': '1.000000'}, + '\u30f3\u30c9\u30c8\u30e9\u30c3\u30af'], + 'organization': ['Sony Music Records (SRCP-371)'], + 'ripper': ['Exact Audio Copy 0.99pb5'], + 'replaygain_album_gain': ['-8.68 dB'], 'replaygain_album_peak': ['1.000000'], + 'replaygain_track_gain': ['-9.61 dB'], 'replaygain_track_peak': ['1.000000']}, 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': 1, 'track_total': 11, 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, @@ -392,7 +395,8 @@ {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, 'duration': 3.684716553287982, 'samplerate': 44100, 'bitdepth': 16}), ('samples/with_id3_header.flac', - {'extra': {'id': '8591671910', 'other_artists': ['群星']}, 'filesize': 64837, + {'extra': {'id': ['8591671910'], 'artist': ['群星'], 'album': [' '], + 'title': ['A 梦 哆啦 机器猫 短信铃声']}, 'filesize': 64837, 'album': 'album', 'artist': 'artist', 'title': 'title', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, @@ -403,14 +407,15 @@ 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, 'title': 'title', 'track': 1, 'year': '2018', 'comment': 'comment'}), ('samples/with_padded_id3_header2.flac', - {'extra': {'mcdi': ('2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5' + {'extra': {'mcdi': ['2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5' '\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00\x00\x00ä7\x00\x10' - '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'), - 'tlen': '297666', 'encoded_by': 'Exact Audio Copy (Sicherer Modus)', - 'encoder_settings': ('flac.exe -T "artist=Unbekannter Künstler" -T ' + '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'], + 'tlen': ['297666'], 'encoded_by': ['Exact Audio Copy (Sicherer Modus)'], + 'encoder_settings': ['flac.exe -T "artist=Unbekannter Künstler" -T ' '"title=Track01" -T "album=Unbekannter Titel" -T ' - '"date=" -T "tracknumber=01" -T "genre=" -5'), - 'other_artists': ['Unbekannter Künstler']}, + '"date=" -T "tracknumber=01" -T "genre=" -5'], + 'artist': ['Unbekannter Künstler'], 'album': ['Unbekannter Titel'], + 'title': ['Track01']}, 'filesize': 19522, 'album': 'album', 'artist': 'artist', 'bitrate': 344.36807999999996, 'channels': 1, 'disc': 1, 'disc_total': 1, @@ -418,8 +423,8 @@ 'title': 'title', 'track': 1, 'track_total': 5, 'year': '2018', 'comment': 'comment'}), ('samples/flac_with_image.flac', - {'extra': {}, 'filesize': 80000, 'album': 'smilin´ in circles', - 'artist': 'Andreas Kümmert', + {'extra': {}, 'filesize': 80000, + 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': 1, 'disc_total': 1, 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'bitdepth': 16, 'title': 'intro', 'track': 1, 'track_total': 8}), @@ -427,53 +432,56 @@ {'extra': {}, 'filesize': 235, 'bitrate': 18.8, 'channels': 1, 'duration': 0.1, 'samplerate': 44100, 'bitdepth': 16}), ('samples/flac_multiple_fields.flac', - {'extra': {'other_artists': ['artist 2', 'artist 3'], 'other_genres': ['genre 2']}, + {'extra': {'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'], + 'album': ['album 2']}, 'filesize': 235, 'album': 'album 1', 'artist': 'artist 1', 'bitrate': 18.8, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1', 'samplerate': 44100, 'bitdepth': 16}), # WMA ('samples/test2.wma', - {'extra': {'track': 0, - 'mediaprimaryclassid': '{D1607DBC-E323-4BE2-86A1-48A42A28441E}', - 'encodingtime': 128861118183900000, 'wmfsdkversion': '11.0.5721.5145', - 'wmfsdkneeded': '0.0.0.0000', 'isvbr': 1, 'peakvalue': 30369, - 'averagelevel': 7291}, + {'extra': {'track': ['0'], + 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], + 'encodingtime': ['128861118183900000'], 'wmfsdkversion': ['11.0.5721.5145'], + 'wmfsdkneeded': ['0.0.0.0000'], 'isvbr': ['1'], 'peakvalue': ['30369'], + 'averagelevel': ['7291']}, 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', 'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters', 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', 'genre': 'Alternative', 'composer': 'Foo Fighters', 'channels': 2}), ('samples/lossless.wma', - {'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, + {'extra': {}, + 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, 'duration': 43.133, 'channels': 2}), ('samples/wma_invalid_track_number.wma', - {'extra': {'encoder_settings': 'Lavf60.16.100'}, 'filesize': 3940, 'bitrate': 128.0, + {'extra': {'encoder_settings': ['Lavf60.16.100']}, + 'filesize': 3940, 'bitrate': 128.0, 'duration': 2.1409999999999996, 'samplerate': 44100, 'channels': 1}), # ALAC/M4A/MP4 ('samples/test.m4a', - {'extra': {'itunsmpb': (' 00000000 00000840 000001DC 0000000000D3E9E4 00000000 00000000 ' - '00000000 00000000 00000000 00000000 00000000 00000000'), - 'itunnorm': (' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 ' - '00007E90 00007BFD 00009293'), - 'itunes_cddb_ids': '11++', 'ufidhttp://www.cddb.com/id3/taginfo1.html': - '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1', 'bpm': 0, - 'encoded_by': 'iTunes 10.5'}, + {'extra': {'itunsmpb': [' 00000000 00000840 000001DC 0000000000D3E9E4 00000000 00000000 ' + '00000000 00000000 00000000 00000000 00000000 00000000'], + 'itunnorm': [' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 ' + '00007E90 00007BFD 00009293'], + 'itunes_cddb_ids': ['11++'], 'ufidhttp://www.cddb.com/id3/taginfo1.html': + ['3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1'], 'bpm': ['0'], + 'encoded_by': ['iTunes 10.5']}, 'samplerate': 44100, 'duration': 314.97868480725623, 'bitrate': 256.0, 'channels': 2, 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', 'track_total': 11, 'track': 11, 'artist': 'Marian', 'filesize': 61432}), ('samples/test2.m4a', - {'extra': {'copyright': '℗ 1992 Ace Records', - 'itunnorm': (' 00000371 00000481 00002E90 00002EA6 00000099 00000058 000073F3 ' - '0000768E 00000092 00000092'), - 'itunsmpb': (' 00000000 00000840 00000110 000000000070DEB0 00000000 00000000 ' - '00000000 00000000 00000000 00000000 00000000 00000000'), - 'itunmovi': ('\n\n\n\n\n\t' 'asset-info\n\t\n\t\tflavor\n\t\t' - '2:256\n\t\n\n\n'), - 'tool': 144255989988720642}, + '2:256\n\t\n\n\n'], + 'tool': ['144255989988720642']}, 'bitrate': 256.0, 'track': 1, 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', @@ -490,8 +498,8 @@ 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, 'comment': '? 2016 Mad Decent'}), ('samples/alac_file.m4a', - {'extra': {'copyright': '© Hyperion Records Ltd, London', 'lyrics': 'Album notes:', - 'upc': '0034571177380'}, + {'extra': {'copyright': ['© Hyperion Records Ltd, London'], 'lyrics': ['Album notes:'], + 'upc': ['0034571177380']}, 'artist': 'Howard Shelley', 'filesize': 20000, 'composer': 'Clementi, Muzio (1752-1832)', 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', @@ -505,15 +513,16 @@ 'channels': 2, 'comment': 'test comment', 'duration': 2.36, - 'extra': {'description': 'test description', 'encoded_by': 'Lavf59.27.100'}, + 'extra': {'description': ['test description'], 'encoded_by': ['Lavf59.27.100']}, 'samplerate': 44100}), ('samples/mpeg4_xa9des.m4a', { 'filesize': 2639, 'comment': 'test comment', 'duration': 727.1066666666667, - 'extra': {'description': 'test description'}}), + 'extra': {'description': ['test description']}}), ('samples/test3.m4a', - {'extra': {'publisher': 'test7', 'bpm': 99999, 'encoded_by': 'Lavf60.3.100'}, + {'extra': {'publisher': ['test7'], 'bpm': ['99999'], + 'encoded_by': ['Lavf60.3.100']}, 'artist': 'test1', 'composer': 'test8', 'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1, 'bitrate': 27.887}), @@ -525,7 +534,7 @@ 'title': 'thetitle', 'album': 'thealbum', 'comment': 'hello', 'year': '2014'}), ('samples/test.aiff', - {'extra': {'copyright': '℗ 1992 Ace Records'}, 'channels': 2, 'duration': 0.0, + {'extra': {'copyright': ['℗ 1992 Ace Records']}, 'channels': 2, 'duration': 0.0, 'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, 'title': 'Go Out and Get Some', 'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}), @@ -535,14 +544,14 @@ 'bitrate': 176.4, 'samplerate': 11025, 'bitdepth': 8, 'comment': 'Audacity Pluck + Wahwah', 'year': '2013'}), ('samples/M1F1-mulawC-AFsp.afc', - {'extra': {}, 'channels': 2, 'duration': 2.936625, 'filesize': 47148, + {'extra': {'comment': ['user: kabal@CAPELLA', 'program: CopyAudio']}, + 'channels': 2, 'duration': 2.936625, 'filesize': 47148, 'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16, - 'comment': - 'AFspdate: 2003-01-30 03:28:34 UTC\x00user: kabal@CAPELLA\x00program: CopyAudio'}), + 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC'}), ('samples/invalid_sample_rate.aiff', {'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16}), ('samples/aiff_extra_tags.aiff', - {'extra': {'copyright': 'test', 'isrc': 'CC-XXX-YY-NNNNN'}, 'channels': 1, + {'extra': {'copyright': ['test'], 'isrc': ['CC-XXX-YY-NNNNN']}, 'channels': 1, 'duration': 2.176, 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8, 'title': 'song title', 'artist': 'artist 1;artist 2'}), @@ -557,8 +566,8 @@ def compare_values(path: str, result_val: int | float | str | dict[str, Any], expected_val: int | float | str | dict[str, Any]) -> bool: # lets not copy *all* the lyrics inside the fixture if (path == 'extra.lyrics' - and isinstance(expected_val, str) and isinstance(result_val, str)): - return result_val.startswith(expected_val) + and isinstance(expected_val, list) and isinstance(result_val, list)): + return result_val[0].startswith(expected_val[0]) if isinstance(expected_val, float): return result_val == pytest.approx(expected_val) return result_val == expected_val @@ -811,9 +820,9 @@ def test_to_str() -> None: "'composer': None, 'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " "'title': 'cosmic american', 'track': 3, 'track_total': 11, 'genre': None, " "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " - "'extra': {'encoded_by': 'iTunes v4.6', 'itunnorm': ' 0000044E 00000061 00009B67 000044C3 " - "00022478 00022182 00007FCC 00007E5C 0002245E 0002214E', 'itunes_cddb_1': '9D09130B+" - "174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+163829', " - "'itunes_cddb_tracknumber': '3'}, 'images': {'front_cover': [], 'back_cover': [], " - "'leaflet': [], 'media': [], 'other': [], 'extra': {}}" + "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 " + "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': " + "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" + "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'front_cover': [], " + "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], 'extra': {}}" ) in str(tag) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 48a2939..1977e6f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -99,7 +99,7 @@ def __init__(self) -> None: self.genre: str | None = None self.year: str | None = None self.comment: str | None = None - self.extra: dict[str, str | float | int] = {} + self.extra: dict[str, list[str]] = {} self.images = TagImages() self._filehandler: BinaryIO | None = None self._default_encoding: str | None = None # allow override for some file formats @@ -233,41 +233,38 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _parse_string_field(self, fieldname: str, old_value: Any | None, value: str) -> str | None: - if fieldname in {'artist', 'genre'}: - # First artist/genre goes in tag.artist/genre, others in tag.extra.other_artists/genres - values = value.split('\x00') - value = values[0] - start_pos = 0 if old_value else 1 - if len(values) > 1: - self._set_field(self._EXTRA_PREFIX + f'other_{fieldname}s', values[start_pos:]) - elif old_value and value != old_value: - self._set_field(self._EXTRA_PREFIX + f'other_{fieldname}s', [value]) - return None - if old_value or not value: - return None - return value - - def _set_field(self, fieldname: str, value: str | int | float | list[str] | None) -> None: - write_dest = self.__dict__ - original_fieldname = fieldname + def _set_field(self, fieldname: str, value: str | int | float) -> None: if fieldname.startswith(self._EXTRA_PREFIX): - write_dest = self.extra fieldname = fieldname[len(self._EXTRA_PREFIX):] - old_value = write_dest.get(fieldname) - if isinstance(value, str): - value = self._parse_string_field(original_fieldname, old_value, value) - if not value: + extra_values = self.extra.get(fieldname, []) + if not isinstance(value, str) or value in extra_values: + return + extra_values.append(value) + if DEBUG: + print(f'Setting extra field "{fieldname}" to "{extra_values!r}"') + self.extra[fieldname] = extra_values + return + old_value = self.__dict__.get(fieldname) + new_value = value + if isinstance(new_value, str): + # First value goes in tag, others in tag.extra + values = new_value.split('\x00') + new_value = values[0] + start_pos = 0 if old_value else 1 + if len(values) > 1: + for i_value in values[start_pos:]: + self._set_field(self._EXTRA_PREFIX + fieldname, i_value) + elif old_value and new_value != old_value: + self._set_field(self._EXTRA_PREFIX + fieldname, new_value) + return + if old_value: return - elif isinstance(value, list): - if not isinstance(old_value, list): - old_value = [] - value = old_value + [i for i in value if i and i not in old_value] - elif not value and old_value: + elif not new_value and old_value: + # Prioritize non-zero integer values return if DEBUG: - print(f'Setting field "{original_fieldname}" to "{value!r}"') - write_dest[fieldname] = value + print(f'Setting field "{fieldname}" to "{new_value!r}"') + self.__dict__[fieldname] = new_value def _set_image_field(self, fieldname: str, value: TagImage) -> None: write_dest = self.images.__dict__ @@ -290,14 +287,14 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _update(self, other: TinyTag) -> None: # update the values of this tag with the values from another tag - excluded_attrs = {'filesize', 'extra', 'images'} - for standard_key, standard_value in other.__dict__.items(): - if (not standard_key.startswith('_') - and standard_key not in excluded_attrs + excluded_attrs = {'extra', 'images'} + for standard_key, standard_value in other._as_dict().items(): + if (standard_key not in excluded_attrs and standard_value is not None): self._set_field(standard_key, standard_value) - for extra_key, extra_value in other.extra.items(): - self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) + for extra_key, extra_values in other.extra.items(): + for extra_value in extra_values: + self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) for image_key, images in other.images._as_dict().items(): for image in images: self._set_image_field(image_key, image) @@ -408,7 +405,7 @@ class _Parser: } @classmethod - def _unpack_integer(cls, value: bytes, signed: bool = True) -> int: + def _unpack_integer(cls, value: bytes, signed: bool = True) -> str: value_length = len(value) result = -1 if value_length == 1: @@ -419,10 +416,10 @@ def _unpack_integer(cls, value: bytes, signed: bool = True) -> int: result = struct.unpack('>i' if signed else '>I', value)[0] elif value_length == 8: result = struct.unpack('>q' if signed else '>Q', value)[0] - return result + return str(result) @classmethod - def _unpack_integer_unsigned(cls, value: bytes) -> int: + def _unpack_integer_unsigned(cls, value: bytes) -> str: return cls._unpack_integer(value, signed=False) @classmethod @@ -979,20 +976,30 @@ def asciidecode(x: bytes) -> str: # tags are more likely to be outdated or have encoding issues fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) if not self.title: - self._set_field('title', asciidecode(fields[:30])) + value = asciidecode(fields[:30]) + if value: + self._set_field('title', value) if not self.artist: - self._set_field('artist', asciidecode(fields[30:60])) + value = asciidecode(fields[30:60]) + if value: + self._set_field('artist', value) if not self.album: - self._set_field('album', asciidecode(fields[60:90])) + value = asciidecode(fields[60:90]) + if value: + self._set_field('album', value) if not self.year: - self._set_field('year', asciidecode(fields[90:94])) + value = asciidecode(fields[90:94]) + if value: + self._set_field('year', value) comment = fields[94:124] if b'\x00\x00' < comment[-2:] < b'\x01\x00': if self.track is None: self._set_field('track', ord(comment[-1:])) comment = comment[:-2] if not self.comment: - self._set_field('comment', asciidecode(comment)) + value = asciidecode(comment) + if value: + self._set_field('comment', value) if not self.genre: genre_id = ord(fields[124:125]) if genre_id < len(self._ID3V1_GENRES): @@ -1001,10 +1008,11 @@ def asciidecode(x: bytes) -> str: def __parse_custom_field(self, content: str) -> bool: custom_field_name, separator, value = content.partition('\x00') custom_field_name_lower = custom_field_name.lower() - if custom_field_name_lower and separator: + value = value.lstrip('\ufeff') + if custom_field_name_lower and separator and value: field_name = self._ID3_MAPPING_CUSTOM.get( custom_field_name_lower, self._EXTRA_PREFIX + custom_field_name_lower) - self._set_field(field_name, value.lstrip('\ufeff')) + self._set_field(field_name, value) return True return False @@ -1053,6 +1061,8 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: return frame_size language = fieldname in {'comment', 'extra.lyrics'} value = self._decode_string(content, language) + if not value: + return frame_size if fieldname == "comment": # check if comment is a key-value pair (used by iTunes) should_set_field = not self.__parse_custom_field(value) @@ -1082,7 +1092,9 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: elif frame_id in self._CUSTOM_FRAME_IDS: # custom fields if self._parse_tags: - self.__parse_custom_field(self._decode_string(content)) + value = self._decode_string(content) + if value: + self.__parse_custom_field(value) elif frame_id in self._IMAGE_FRAME_IDS: if self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames @@ -1109,8 +1121,9 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: - self._set_field( - self._EXTRA_PREFIX + frame_id.lower(), self._decode_string(content)) + value = self._decode_string(content) + if value: + self._set_field(self._EXTRA_PREFIX + frame_id.lower(), value) return frame_size return 0 @@ -1328,7 +1341,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N self._set_field(f'{fieldname}_total', int(total)) if value.isdecimal(): self._set_field(fieldname, int(value)) - else: + elif value: self._set_field(fieldname, value) def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: @@ -1598,12 +1611,12 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _decode_string(self, bytestring: bytes) -> str: return self._unpad(bytestring.decode('utf-16', 'replace')) - def _decode_ext_desc(self, value_type: int, value: bytes) -> int | str | None: + def _decode_ext_desc(self, value_type: int, value: bytes) -> str | None: """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string return self._decode_string(value) if 1 < value_type < 6: # DWORD / QWORD / WORD - return self._bytes_to_int_le(value) + return str(self._bytes_to_int_le(value)) return None def _parse_tag(self, fh: BinaryIO) -> None: @@ -1633,8 +1646,9 @@ def _parse_tag(self, fh: BinaryIO) -> None: } for i_field_name, length in data_blocks.items(): bytestring = fh.read(length) - if not i_field_name.startswith('_'): - self._set_field(i_field_name, self._decode_string(bytestring)) + value = self._decode_string(bytestring) + if not i_field_name.startswith('_') and value: + self._set_field(i_field_name, value) elif object_id == self._ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 descriptor_count = self._bytes_to_int_le(fh.read(2)) @@ -1656,7 +1670,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: if field_name in {'track', 'disc'}: if isinstance(field_value, int) or field_value.isdecimal(): self._set_field(field_name, int(field_value)) - else: + elif field_value: self._set_field(field_name, field_value) elif object_id == self._ASF_FILE_PROPERTY_OBJECT and self._parse_duration: fh.seek(40, os.SEEK_CUR) From a5b92da18979efe70e9a3d7ab022120bd45c51a5 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 29 May 2024 11:06:15 +0300 Subject: [PATCH 202/305] Add 'flatten' parameter to as_dict() --- tinytag/__main__.py | 2 +- tinytag/tests/test_all.py | 56 ++++++++++------ tinytag/tests/test_cli.py | 10 +-- tinytag/tinytag.py | 138 +++++++++++++++++++++++++------------- 4 files changed, 131 insertions(+), 75 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 6b280f0..3c08d5f 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -44,7 +44,7 @@ def _pop_switch(name: str) -> bool: def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool: - data = tag._as_dict() + data = tag.as_dict(flatten=True) del data['images'] if formatting == 'json': print(json.dumps(data)) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 94ae7f3..9a1324a 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -560,10 +560,12 @@ testfolder = os.path.join(os.path.dirname(__file__)) -def compare_tag(results: dict[str, dict[str, Any]], expected: dict[str, dict[str, Any]], +def compare_tag(results: dict[str, Any], + expected: dict[str, Any], file: str, prev_path: str | None = None) -> None: - def compare_values(path: str, result_val: int | float | str | dict[str, Any], - expected_val: int | float | str | dict[str, Any]) -> bool: + def compare_values(path: str, + result_val: str | int | float, + expected_val: str | int | float) -> bool: # lets not copy *all* the lyrics inside the fixture if (path == 'extra.lyrics' and isinstance(expected_val, list) and isinstance(result_val, list)): @@ -572,7 +574,7 @@ def compare_values(path: str, result_val: int | float | str | dict[str, Any], return result_val == pytest.approx(expected_val) return result_val == expected_val - def error_fmt(value: int | float | str | dict[str, Any]) -> str: + def error_fmt(value: str | int | float) -> str: return f'{repr(value)} ({type(value)})' assert isinstance(results, dict) @@ -595,10 +597,9 @@ def error_fmt(value: int | float | str | dict[str, Any]) -> str: def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True, duration=True) - results = { - key: val for key, val in tag._as_dict().items() - if val is not None and key not in ('filename', 'images') - } + results = tag.as_dict(flatten=False) + for attr_name in ('filename', 'images'): + del results[attr_name] compare_tag(results, expected, filename) assert tag.images.any is None @@ -608,10 +609,9 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - filename = os.path.join(testfolder, testfile) excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"} tag = TinyTag.get(filename, tags=True, duration=False) - results = { - key: val for key, val in tag._as_dict().items() - if val is not None and key not in ('filename', 'images') - } + results = tag.as_dict(flatten=False) + for attr_name in ('filename', 'images'): + del results[attr_name] expected = { key: val for key, val in expected.items() if key not in excluded_attrs } @@ -624,14 +624,12 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] filename = os.path.join(testfolder, testfile) allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False, duration=True) - results = { - key: val for key, val in tag._as_dict().items() - if val is not None and key not in ('filename', 'images') - } + results = tag.as_dict(flatten=False) + for attr_name in ('filename', 'extra', 'images'): + del results[attr_name] expected = { key: val for key, val in expected.items() if key in allowed_attrs } - expected["extra"] = {} compare_tag(results, expected, filename) assert tag.images.any is None @@ -816,13 +814,27 @@ def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert ( "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " - "'bitdepth': None, 'samplerate': 44100, 'artist': 'Anais Mitchell', 'albumartist': None, " - "'composer': None, 'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " - "'title': 'cosmic american', 'track': 3, 'track_total': 11, 'genre': None, " + "'samplerate': 44100, 'artist': 'Anais Mitchell', " + "'album': 'Hymns for the Exiled', " + "'title': 'cosmic american', 'track': 3, 'track_total': 11, " "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 " "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': " "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" - "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'front_cover': [], " - "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], 'extra': {}}" + "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'extra': {}}" ) in str(tag) + + +def test_to_str_flatten() -> None: + tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) + assert ( + "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " + "'samplerate': 44100, 'artist': ['Anais Mitchell'], " + "'album': ['Hymns for the Exiled'], " + "'title': ['cosmic american'], 'track': 3, 'track_total': 11, " + "'year': ['2004'], 'comment': ['Waterbug Records, www.anaismitchell.com'], " + "'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 " + "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': " + "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" + "163829'], 'itunes_cddb_tracknumber': ['3'], 'images': {}" + ) in str(tag.as_dict(flatten=True)) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 1fca347..cdac459 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -76,14 +76,14 @@ def test_meta_data_output_default_json() -> None: output = run_cli(mp3_with_image) data = json.loads(output) assert data - assert set(data.keys()) == tinytag_attributes + assert set(data.keys()).issubset(tinytag_attributes) def test_meta_data_output_format_json() -> None: output = run_cli('-f json ' + mp3_with_image) data = json.loads(output) assert data - assert set(data.keys()) == tinytag_attributes + assert set(data.keys()).issubset(tinytag_attributes) def test_meta_data_output_format_csv() -> None: @@ -91,7 +91,7 @@ def test_meta_data_output_format_csv() -> None: lines = [line for line in output.split(os.linesep) if line] assert all(',' in line for line in lines) attributes = set(line.split(',')[0] for line in lines) - assert set(attributes) == tinytag_attributes + assert set(attributes).issubset(tinytag_attributes) def test_meta_data_output_format_tsv() -> None: @@ -99,13 +99,13 @@ def test_meta_data_output_format_tsv() -> None: lines = [line for line in output.split(os.linesep) if line] assert all('\t' in line for line in lines) attributes = set(line.split('\t')[0] for line in lines) - assert set(attributes) == tinytag_attributes + assert set(attributes).issubset(tinytag_attributes) def test_meta_data_output_format_tabularcsv() -> None: output = run_cli('-f tabularcsv ' + mp3_with_image) header, _line, _rest = output.split(os.linesep) - assert set(header.split(',')) == tinytag_attributes + assert set(header.split(',')).issubset(tinytag_attributes) def test_fail_on_unsupported_file() -> None: diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 1977e6f..40c7ec4 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -108,6 +108,9 @@ def __init__(self) -> None: self._load_image = False self._tags_parsed = False + def __repr__(self) -> str: + return str(self.as_dict(flatten=False)) + @classmethod def get(cls, filename: bytes | str | PathLike[Any] | None = None, @@ -151,11 +154,34 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: """Check if a specific file is supported based on its file extension.""" return cls._get_parser_for_filename(filename) is not None - def __repr__(self) -> str: - return str(self._as_dict()) - - def _as_dict(self) -> dict[str, Any]: - return {k: v for k, v in self.__dict__.items() if not k.startswith('_')} + def as_dict(self, flatten: bool = True) -> dict[ + str, + str | int | float | list[str | TagImage] | dict[str, list[str | TagImage]] + ]: + """Return a dictionary representation of the tag.""" + fields: dict[ + str, + str | int | float | list[str | TagImage] | dict[str, list[str | TagImage]] + ] = {} + for key, value in self.__dict__.items(): + if key.startswith('_'): + continue + if flatten and key == 'extra': + for extra_key, extra_values in value.items(): + if extra_key in fields: + fields[extra_key] += extra_values + else: + fields[extra_key] = extra_values + continue + if key == 'images': + value = value.as_dict(flatten) + if value is None: + continue + if flatten and key != 'filename' and isinstance(value, str): + fields[key] = [value] + else: + fields[key] = value + return fields @classmethod def _get_parser_for_filename( @@ -266,19 +292,6 @@ def _set_field(self, fieldname: str, value: str | int | float) -> None: print(f'Setting field "{fieldname}" to "{new_value!r}"') self.__dict__[fieldname] = new_value - def _set_image_field(self, fieldname: str, value: TagImage) -> None: - write_dest = self.images.__dict__ - if fieldname.startswith(self._EXTRA_PREFIX): - fieldname = fieldname[len(self._EXTRA_PREFIX):] - write_dest = self.images.extra - old_values = write_dest.get(fieldname) - values = [value] - if old_values is not None: - values = old_values + values - if DEBUG: - print(f'Setting image field "{fieldname}"') - write_dest[fieldname] = values - def _determine_duration(self, fh: BinaryIO) -> None: raise NotImplementedError @@ -287,20 +300,18 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _update(self, other: TinyTag) -> None: # update the values of this tag with the values from another tag - excluded_attrs = {'extra', 'images'} - for standard_key, standard_value in other._as_dict().items(): - if (standard_key not in excluded_attrs - and standard_value is not None): - self._set_field(standard_key, standard_value) - for extra_key, extra_values in other.extra.items(): - for extra_value in extra_values: - self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) - for image_key, images in other.images._as_dict().items(): - for image in images: - self._set_image_field(image_key, image) - for image_extra_key, images_extra in other.images.extra.items(): - for image_extra in images_extra: - self._set_image_field(self._EXTRA_PREFIX + image_extra_key, image_extra) + for key, value in other.as_dict(flatten=False).items(): + if isinstance(value, dict): + if key != 'extra': + continue + for extra_key, extra_values in value.items(): + for extra_value in extra_values: + if isinstance(extra_value, str): + self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) + continue + if value is not None and not isinstance(value, list): + self._set_field(key, value) + self.images._update(other.images) @staticmethod def _bytes_to_int_le(b: bytes) -> int: @@ -333,6 +344,8 @@ def audio_offset(self) -> None: class TagImages: """A class containing images embedded in an audio file.""" + _EXTRA_PREFIX = 'extra.' + def __init__(self) -> None: self.front_cover: list[TagImage] = [] self.back_cover: list[TagImage] = [] @@ -341,27 +354,58 @@ def __init__(self) -> None: self.other: list[TagImage] = [] self.extra: dict[str, list[TagImage]] = {} + def __repr__(self) -> str: + return str(self.as_dict(flatten=False)) + @property def any(self) -> TagImage | None: """Return a cover image. If not present, fall back to any other available image. """ - for image_list in self._as_dict().values(): + for image_list in self.as_dict(flatten=True).values(): for image in image_list: return image - for extra_image_list in self.extra.values(): - for extra_image in extra_image_list: - return extra_image return None - def __repr__(self) -> str: - return str(vars(self)) + def as_dict(self, flatten: bool = True) -> dict[str, list[TagImage]]: + """Return a dictionary representation of the tag images.""" + images: dict[str, list[TagImage]] = {} + for key, value in self.__dict__.items(): + if key.startswith('_'): + continue + if flatten and key == 'extra': + for extra_key, extra_values in value.items(): + if extra_key in images: + images[extra_key] += extra_values + else: + images[extra_key] = extra_values + continue + if value or key == 'extra': + images[key] = value + return images - def _as_dict(self) -> dict[str, list[TagImage]]: - return { - k: v for k, v in self.__dict__.items() - if not k.startswith('_') and k != 'extra' - } + def _set_field(self, fieldname: str, value: TagImage) -> None: + write_dest = self.__dict__ + if fieldname.startswith(self._EXTRA_PREFIX): + fieldname = fieldname[len(self._EXTRA_PREFIX):] + write_dest = self.extra + old_values = write_dest.get(fieldname) + values = [value] + if old_values is not None: + values = old_values + values + if DEBUG: + print(f'Setting image field "{fieldname}"') + write_dest[fieldname] = values + + def _update(self, other: TagImages) -> None: + for key, value in other.as_dict(flatten=False).items(): + if isinstance(value, dict): + for extra_key, extra_values in value.items(): + for image_extra in extra_values: + self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) + continue + for image in value: + self._set_field(key, image) class TagImage: @@ -655,7 +699,7 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) if fieldname.startswith('images.'): if self._load_image: - self._set_image_field(fieldname[len('images.'):], value) + self.images._set_field(fieldname[len('images.'):], value) elif fieldname: self._set_field(fieldname, value) # if no action was specified using dict or callable, jump over atom @@ -1117,7 +1161,7 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: description = self._decode_string(content[desc_start_pos:desc_end_pos]) field_name, image = self._create_tag_image( content[desc_end_pos:], pic_type, mime_type, description) - self._set_image_field(field_name, image) + self.images._set_field(field_name, image) elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: @@ -1328,7 +1372,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N if DEBUG: print('Found Vorbis TagImage', key, value[:64]) fieldname, fieldvalue = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) - self._set_image_field(fieldname, fieldvalue) + self.images._set_field(fieldname, fieldvalue) else: if DEBUG: print('Found Vorbis Comment', key, value[:64]) @@ -1537,7 +1581,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._update(oggtag) elif block_type == self.METADATA_PICTURE and self._load_image: fieldname, value = self._parse_image(fh) - self._set_image_field(fieldname, value) + self.images._set_field(fieldname, value) elif block_type >= 127: break # invalid block type else: From 90f22b3e5b1509e83f785f579c8c65f187c4cc95 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 29 May 2024 13:11:48 +0300 Subject: [PATCH 203/305] Improve CSV output --- tinytag/__main__.py | 24 ++++++++++++++++-------- tinytag/tests/test_all.py | 3 +-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 3c08d5f..e6d0867 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,7 +1,9 @@ # pylint: disable=missing-module-docstring,protected-access from __future__ import annotations +from io import StringIO from os.path import splitext +import csv import json import os import sys @@ -47,20 +49,26 @@ def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> b data = tag.as_dict(flatten=True) del data['images'] if formatting == 'json': - print(json.dumps(data)) + print(json.dumps(data, ensure_ascii=False, indent=2)) + return header_printed + if formatting not in {'csv', 'tsv', 'tabularcsv'}: return header_printed for field, value in data.items(): if isinstance(value, str): data[field] = value.replace('\x00', ';') # use a more friendly separator for output - if formatting == 'csv': - print('\n'.join(f'{field},{value!r}' for field, value in data.items())) - elif formatting == 'tsv': - print('\n'.join(f'{field}\t{value!r}' for field, value in data.items())) - elif formatting == 'tabularcsv': + csv_file = StringIO() + delimiter = '\t' if formatting == 'tsv' else ',' + writer = csv.writer(csv_file, delimiter=delimiter, lineterminator='\n') + if formatting == 'tabularcsv': if not header_printed: - print(','.join(field for field, value in data.items())) + writer.writerow(data.keys()) header_printed = True - print(','.join(f'"{value!r}"' for field, value in data.items())) + writer.writerow(data.values()) + value = csv_file.getvalue().strip() + else: + writer.writerows(data.items()) + value = csv_file.getvalue() + print(value) return header_printed diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9a1324a..e0e5cb2 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -24,8 +24,7 @@ testfiles = dict([ # MP3 ('samples/vbri.mp3', - {'extra': {}, - 'channels': 2, 'samplerate': 44100, + {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, 'filesize': 8192, 'genre': 'Dance', From 3bac1885f0053096dad0d3f790d8d8d0e3140277 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 29 May 2024 13:41:09 +0300 Subject: [PATCH 204/305] Increase test coverage --- tinytag/tests/samples/aiff_with_image.aiff | Bin 23238 -> 38636 bytes tinytag/tests/test_all.py | 23 +++---- tinytag/tests/test_cli.py | 5 ++ tinytag/tinytag.py | 69 +++++++++++---------- 4 files changed, 54 insertions(+), 43 deletions(-) diff --git a/tinytag/tests/samples/aiff_with_image.aiff b/tinytag/tests/samples/aiff_with_image.aiff index 6dc60952ca44a9a7f1cf70e5bb22e55b81408061..fec9460a4939f09f7e7e7ccf536a679f8d7b07ef 100644 GIT binary patch delta 15563 zcmb`u1ymeulqTFrf`_010(1xvJPB@r4(<+(yF+k?KoJNU+$FfXyCygU*Wm8%(lp!O z%*yQ9-I>|_|2pSAU3E%s-M6crd+&3f>a9aMEJH>YS5TG%ff6>O(X$B?K1+$Rqk=#n zWM(c^DOEPa8dY>VHM)?Zln4lkNwex;LG9JQY=GP;5j!ny49!hHSlOGJgYcv8$pG}{ z&(WSgLq|hH!@xkt#3sbS#=^oT#V5ceq#y%RQjn38zoB_g_vS4#H90vwHv{uWRt^ph zFdeS|58J2r>>O-Q3m_3B3=9lxENl`S91=Dvaw@j}iv{@CW1l)fc<4x~NUA7EZ$Zd- zNGNznPu(Ce2!!+u>0cJao&U>%gp7jv>^T}b1|}BbfV!6;WF!<6WK@)A&rlJQfaHzX z4?@LzhEK&R^qfG+5bdo4A=|g;EOct&s$WFPdHDr}Ma9)MwRQCkjZMwJySjUN`}zk4Cnl$+XJ+UA&abU+Y;JAu?C$NKonKsD zUEkc^-T&hl5(wpA{Q?j_|69-S5S}5UqN1Rp{o@%DvKwNc;GsUFVttM;q=aVZK=79B z8#GNmssZRlrFIsCv*nWX%QU zaSK3T+B~HJa_pjCzpe{gl~&8lf0L!vrxlC6Y-|dXA_@K6=_kD*z#jdXe2HlH6h?gZ z<$ky~k?b!={i(#K7>}JHq#GN>1rOueOOl)I?8;l-^)eBuhcA{U)x`0{BQm}$6XnfXjB0mf5Lp78YCRjHuaLxkI*(tdp}TbznaikFR)iKjIDf zbR6$wzm8_``|a5BnS7h^KO zMdSd-vvyd}{&S!Iz08T+Q3Ru{JE9`IMSR&QBM;)e>Gu3Mne6zr=trZR1*774Bf;aA98Cwnu$c^P%kVdUfOfRg!@fVy4Z)pk22(E zSNCL~4U6&}>08~3eb@6p6MG4)WkXtGv|H=hi_*D)fJX2LT!@sMVC>YITk%w|A#YDB zA`8H_f1lxAuhKnw5rP?r3i_z(_rht!J@bW8=&^HTKr_4{Yi>Uwb}!4W>oH;3I*Yu9 z$S9ZI0Ew5fJw80gT|{k`bEu6BHG)ETo>xzj&Ztxt@Q^@bnT<65O#VppR~yE4hBKWJ zbetUsfSGjS`mAJr7J+_tkRu}*hMnTD;Rb0Pjf9k2T_2#9N? zBQh_a6M&vTFCp?4C;{0mWo~O`ZJB#FvoWJU{t`>Xq#H%vT#O_NIr5P2=S@H`{`VN_ znfGXK=-Y>kaH3LIcCRRPk5fQi<|O$3>jmzm*Rz;wWiRxpc*xDT*6RLNevX%cDVKWe z0;ysTRj9b!u4mD&O|VFhiG?@yx~_!$ZNVDLo4qR5h9}T>zj;!(4{r#UsQM}$15U6p(PhNtQa}BDsTv8ymIk)dtUv+Zf zDrDc^auz;^>J-5~lT2JvsNMZG5WY{}oo!#l1frn;gC0+OYLPCF3ksI@Un%b;Fw(Tu zHjZyLg_tOg7gw3lrTbz(ffz?)?)d@z7YAhJ88$W^WqD;eZI$}&4#o3h3hy!|9J#swTm`}NC2Gcf*BJ+Cx~eDn!tH@n^B@D|bk@TZ?S~&8B*;$^F^$ z-$QjdUWumEV}1@1&$#5Q)e@#zOr|O*=LK4*tOMy=e2$;nvo1{d3IH4NYjr;kJ>^RA z_7WQ=bi@P|6|V?$!4+yLVUS~1ir6gt#&{;LY6rpEfD{C+J? zm41>3^j*tH>#z&3Vw*LBM> zJmc`MM-wNyJAfO-E1sKjeucBvv9F2>B%gdVy|&Xeas;T2MCh;Xq$szmQlXUAY&l0} z!a_ccyb#$_2&Tz>s0@ClF9TGhkx~+L3J|F7O~UOu3G?QzoSiHVPvcz&Y(`P?tP zS@kSjg2VC^XzxymAZ!)9Y%VjiQch{xS%NA+ ze0I9!+id)ziS|&A*#HGexg<9B!nyy^y80}!d-R3-cR7|iczIT?pJP)jA_PXk>=!HBdr@bgw!Q{MZHz^}W! zxyutGn9{h*Be?t8)iu0eQGbXuaR0R^t{djJq#c{TXuQ8jH&A8;AzJ5F3sL`zgdoK}D? zGhE1oH*G||Glnt%E3VPXjGqo02LaqvG1UE6Je%GgWqHC5T5uD_kmO61zofXI!lFo= zc`i8ydrFA^GpT^Wt3{Ya_J^k1ajmkj_!Tbj2o9Y@% zdF&cu`rsdps}31o(yc$pd^_eu-_Vrx7+B={7U6Ibx_g;4v#6_WgR~yEd1Lyi=v^B& z5!ZYSyq5Q1aIhPC+cYCHWV4x+^$gE);f$d@NimZIgHjaxh``y@R7zBg zc7IL{jc$Mq@aF|cm_MUJx+s~dsrtKyBpIipV$|2)>Zm(+v#MM4%S_EP%Ym#?`ww4U zoi5Of5<8%d-xaEwP@9u(WC|Rlm*>d(F6a+a{0=btLElnMgux5`R_^F3-WZ*&Tc{C_ zQaMW06B-ffrFTz3m|jl9p(N(;`rqO#@NYN%+d4tde|M10uH*MN_rz&bhB0A8v4uP%ZZ%DqKX6cTM=di_J39)t=ywuYo>_XSN%eltpb@w zC_tpGS1h@sU&Uv8=kS~8aE}33C4AGhNi5%IF(cVo4M~PI)>3@I$&nkmrbIq#;=4&C9vq|f&K_XZsTPdJqRc?`>-y>^a>L#zM8bAW&Eg8#dH z|1U-`?Unz4Na^zVC!p7{Mks?5DJ+-1p~ z#~rLk2AqU|SIh1pH%S?r)`>K+F^-muJ`I4}SB^0+2A5dAw0TTdUpV$u^Eba8;i$i> zJ+Dy$bddZvx4SG%}TDSfYD_t3aA>6~j@2D<#% zRgK_J9jt=w{^uo$A6cerqj>sF`^K(0v_&?xbKk_gv{5-qXH<#8pzu;J!L;I-BHINV zrR4_tcP^j3)Lt6q#=~ZyDpOu?;>uI+hp9ZgnFChs}g-*q{e#iK_y@a4+TwRO0jmLW%>QmCJ`sdE4Ty=Z9Xi z?Y(n8SxZ8{drzx>69+ym9$hO6x(8Z<7amt7ZfAsb7scS^9tB>kU2&7IS{Uycn%Q&E3Js&SUR3Na&>LA5*1ErQp1g7Gv5 zx=N$L4$=2O0H+KVB+&H=-l4ctLb2f#o86+wv2fApr|j)(cUFAi?m42+1hi8hIzp&L ztMBGdE&aOtESboQk02hwFm~l1=CO}@e+A+$?kKI5ffqU-J`To2iu`&4;m*{US2xu` z+P{r?M)&PZG!+Ot!+BB|diAm1#Bhh#H^&QpH%f-CcQl5$o-Ai9Mm+~dmtYBI@b>i7 zd|p}kz6q?*-K!RgYePEjfhW-0RBLB5C9NmW+_Gn4Ra5Ay%R*Wy1V!^=e)P+NSxD4F z^$Fw^dk2Jn(ca}!Ty0KHogM{8l}suS)rw2~ zd>gTOb?-TYvlE@tksDtYKvE>X7$pN&}_d83;X$~tC`w#q-F zHX6JtTh?l(vcHIX23LzE3V>(B4WU#Wz*cS$$`w4m#AdBu1;>FQ>IvlH4$jcul36ff z0x-EmuASG;T51?KG};?!Wm}NoB)yRPD{?;5tAWKzLEJNLlnc&TA#=MgXtBstS15EB z2P$Kg_^EB0iZz8Nd;QaAOaG#t*?z>luUMplB?*9HjPt>I8nf92{c`EP=z+PnQ zy5QmuyCll3Z1hxW(f6W-7IB-5INmS6w)Q7y5Ah|BV)frUk-`aB8fDdP!PPPFF)Ixh zC*i572}g(X3c5bJ6KQ!pv}9b>wBX9F`UtlcF{-!0Wne3J6xQi0+nVqJizm>=`u;qw z+1HGIW$z5xF!=ekCT^~S@zpz^hQ8=JN?YQj1BP@yRKn4)$LOPBoBIUv6w}|Nf6z ziqfVLG~<-?<1HH&le9Zb9?F8sQt#)>8t?P4&xlNRtC`6Ce|18%GBknb*S`r+sKmc+ zHY(b`;L9lU94mvATtI?1;;4_9>)F!by&hUpwda}luAeCR&1(u zsz=|$htVxf!pwMHG&EpJwK#622e;`7)D=HOTl>RluOR_8WV`CQL483)QK~d-U&Qg7 zP(UZ_o64^ey@lxNwctxzi34bFnYByLRSc_u$&bJ%5F}hVocK7c4H(8>oK7Mj)jtr}SVR-Zrt#*4SM zrbF8O)wstEgDY!N=Ia>QRPWV?^-o@q0O8KWv-Y}u?HDL8HnQ{!i$lANT%mvyfs3Ul zxpt3(IsJ0A=tkBixN|1Hyn`+B{SmSIC~6ct9PO+;1(1v}VR(zC0%K-lqP*y;97w2h zA^=>P!0QmLfi)(jnXrnjM~gwVEP;35iOKW|^UCxGV~bQbz=}Yg+0t&J>z%0*BksDq z!aCoy<Yew-lOluTsqG5qQ6!JbKgf9T> zj+k|4uIsV+zG{+bw687@mF4{G=H0cu3BCQPK^Iej-!+^pMoi3oB=`z@>8}ppgiGRZ zCAzuNZ`KL>>bGdO2!f57L!+!eVS_-{V<%qaQK6}@NZILYT)SNZ46m0pbUS0wvG`>}W97#nOHhD-jw673R`x*g zez`|TbcxR6b;a7AxUV-FZM3o8drJNr0Mxk+jLBr#^71QO2m0>v0xGl{p<{13w!wV{mf`RPBVt}D$WE%~Bh&lP zjqBqZZP#N&g3t_5wZhl zH%|SCw8>eBxsF6M^=KMEs&l<4SELuKr&vznl9B@TG$nr#Es!de9m{D+@T(M*>IJd7 zQ*?ZAfnU%=A z9j$0+`}~id(_UBuM`pyYR{h)~gdK6~y&eI~B-J$T0d=);#AI%f3_FP&b1o5!G47LU z7gzRkhIp@Ser-#&v2E9;#2veaS-XbLmyxJJft0sIv^Wub^XDg0jV6qrM~4ZVAv-h8 zsr++x+4!O!U=IaF@7WyaIX4KkVs3LLzxyprLx==}4*mhHM9I z+AdmqNbcrN9=&rsu?>h+`6eb}UGjJFbNPhCYiGtsq3q_#ELv{}3X`gqm1cci-~=8a z6A(b*Ep_!SKRfgur5s?W_{qgTm`r?;Ry5M6{mW>89 zN<-!qp@j>$T7%9?$hQYsv?`IvktWt^E8s^}-80YNtbEJiLWPF-5D(YOYs&2w^lV9L zjdjJs=O7|&xjJH3*1j-SQsezT*m{1#8dVBSwBKEeUu&Q!KMPf|8wEk4=4#ICbwngiVhLYgm*0Bx<7 zS}oZv9{$f8l{6^#YSuE1&I>Xcbnia)|B+`OSx4t7`C~A}LL<4UF8d&mu8Zll2pd?g^_u$6xHS+x`E9tp#Bh6hd~| zXdlDeX12LOAGcmNLe_NBGhArX)}<)rC7*c<9qBev3DK;+$enEoEUhA(PXVrY7=KN~ zubWt$-8JhZ+@O;}g#)xt;S!uFEGyEpREkpK21xMpSU((R`nRF`yki=~(_Cj7iE^!q}r>v$@HfAmCSgXJ+n!V=@*EjDqeFB=<#6AQafa zXzy6KBwzFr1qUNfyZTg9o(|_sDnZqq?|k1>1Uszw$i&?lFQ5F9jSE7hbu}$4ubRDL zOcpw6iKx@GUlQR=m#W(*&Ku2x`*?Ne(-y8# z>^5s{VPIy!JxCa-#ybGxDT)7Rz1H64w$iAgsWw$Y&)D@$vj1(Gn9{pveL+O|EZFND z0z}_-kF}3srlGKTgS0^Y${n-B| z(l|wH6<;E}=5=+38V$59qJx8mWyVxKwIdh^=hFW^7>N)^v(7 zg?2f3k75=*%uc;DA^udPs7lP%9J1l#fr9CQEsSES{Z7o z%3RH@(yW>Q-^SbeZq+}ySzWu~VyCH|dg0B5hEU|#@Ahbr`@KNtTvzH=&0-R0p|~g@ z3*C`eC*ju4`F5ib<ywTaHl<%dznB}|jck>?%35Xu5ix%F%B($2{r)h_j zfJ7LQw7>irP1EVol*GsCKW)A|km%CC%*O^aQvbq~wBX!Q-;gEk{njmj+>DhBxOvy& zSptMz4`U;c=)5Z{PR{VzjXSiupKebe>hNB^33UrQFkH*hjrUs3?%;C^-LYjN>5Z8F zfyxyLqT*TYJ6f^p-oz(0<&j+KSW>pSB%{6THUl$Lo|F5eS!Z#p->&x z&K{zjT+!!T$*3*@Cpi-t)0QHd+;={Pz)}9Se#ItBd9r2Rx>vHlq~Ngx=;1}~bn*!? z2Z62`y#xcs!#QPlMAyQ_WvRq;PC!Vl?_ELZMg0woA9Faxk}j&Xd}OD{IK{E&_fHSY zii6R{cm}CuvD8Ol?YoAi-?+`br;vO2RmIwnxM<8?FE>unF@MIEYV9zYOf_v1K9}&fD%{ObMsqD%1fxBG0=H!E8 z?~=IYj;24JRm4fnCgM_kZo_=o_I>j z;_~Sq8jNjf|L@>GPax^pDG;9v&@b>QACkusU#vM#!!&~S$6LWkAsMQ6t{t@z4evjI zgTpxJJ-+(o4Vs+WF4Cy2LsvPF_v) zV09(T9bWgcg0HrKv%n~g=eI-1kld#RwpS_Qu2=6`yevO0XqX6S62grd8INmE)#d8Y zD&jf@tA|V$w;Zda=(C3bLKUJ0{)!uU$Y-F;vybGSK067WFSNpFq0G@U$N`ZYa%vWH z;Xg~+pUX(+P4;~PnKq$aaQ;lo50&0a^i;y61Oc>GXg?v6sTGgsGW0@PdVbSl|z{ew8pT^Jyp*v*x%O$#Jp*Gkasdcqq^(=a#s zr{Xo%OrPP-HXP_0Gd2^t3|3NoJH`s^Sk~b(Y|d3eTNfWXM#SbmkIB%gBsxy%Z+4Brhq_L%la4p zo48BPzX82`dWS$gER0EMXWG^!5qqZGR!K%2n5EzJ<@NGX3D%52KF^m`#av4#moChg*?ypaoSM|BSie(BmdcOqZgK; zG?_C+)%CUd!_rshYr*OMB`rDg4~cB?0+B`nLMOTb>3M*i=g07Rp&72#$g?8zdIWrt z_fvS|+GQPS%yx;qY+AkID1fT2Fh^ikXjvsfCGdvg;2VyR`;`Fw*3-+XtupOl{o@8~ zNLHyOwfoQ9P_-aMaU2h}0_gYhOTMWrB{$CW1KE!4b>jNe*61+*D$5S4s|AY z7ii^X0NAc!5PGvoY2gwF=Yh@mX3qW|F!8f^{iTWzEUwJRX{HiS16Fi^=1f(*eGOfg zYyZCKBcAb?y9qyEo)OtcG%#gV=QG4-uO6K#w&p_Y%4d%k|3FsF0^3_`8`#0Oq($uu zUwp1JY<&WPt}>VBuO~CXP~%6dlgB5}C@jDZIF3t~a7r-hTij0I$&IffW6uyno$fdy z2Jau)yu1(o2KUJIR=(<;wBK(JIggwqA`Vi&CGv2kk$bxen#G~0_8ja450%NsSVF;U zOnS0>>^Q}^V^5%~1FGcYYAKqVPm@;h8eOZAD-|%@PI(-d>_qF#`|@APa2<(T0$(4X zpr~}-vxNVd8Vy1eGt-6;CDC;)f@uYCnSmE8;h3D(LoEdM`7NZ1LbTo4JaIMYzUta} zdr}6e3po7P>@PwE&~B$wwdt8R{$e7fmwM3cTh)z}kSlv|buQ{~BdJH>HdV++kde5I zD(yQ?l`y6sy0R?l^jQ<%u}Sf$>1hI~W0*4TOF}VUJSv8e#w82{Gm3_VEKT1IU6(ZA z>)`pKN%mzTN~QGueJshFKWh?(OYQz`F8OfY-7u{O*6CPw#v(H+HE}6A5a!mEx4{wt zDr&Fr^^&k8XIC}%tF-|H()z^x?8I4lWQG6 zti$*oXDQTS5_Kf4bagu8w;dmIv|}Zzy2ha=U+Ai0zm)uXDCOgxC{_=mZ)}lqYU7w5 zyD|zRl|prXvpO@K7;GAe;f!7KxYmVxR?5h@2Th}MXO+eSVruyQveZ86cvMOg+qNVI zjy9fLJzAj%mgPIJVHKjCy^sX!<(!o}Xh!=pyovZuj;G~1e{@Ll;!_B7X)T3CQhG@T4l_x z>em)Ggbc(}7eeA+Yg|L(UBL^U4hMpQg~0FF2Pb)ocXYc!Fa~e=NZ6x(&|Oe{#yNTR z0wSq!Wq7@tUl1wX9tEpM1oh5D@JKV-8BxsTMe1%N+$VvZ6v}*Na>^DM`sVEGX!n-1cbu1Hpo9P{=&dyH6 zv`=J_5!FIk+gfO@cnxwcK27%K!^)y?n09k(U@)hg z0$fo4uoAqAf*M(%KT^?SXP)<29{SPeqUkObc8}TJct-Rm=nHxku6`@P-nXa8HcWHU zu5NC;_%mhOy;yZ=wJC2YrC22)Qf$)>(RvkE9(M!Z?QTSN0^tUTJO!Wh9pHHE6|1DA z!|6c8*x`}-`=?>0l9=4H)&{oVHQ{3~%z<}H(Kd5N2!K{ZK-`P`<~FblvC>_&uessm zL(b&Ef&3&#@L`yi+guW4xp(6J}+V_I1InK;5NkZupF?s1)z$zzN(AIV55v$hrAaZ$$$(4GQ@Zc#tbyZ~#W5n*Pz}${zuwM{BFP zqVb4)`|tUe;BqU?;W#vA_o&D%IA?`9+TYUkpk{isgO#gcCr=_ExvA}9(TpeJF#Tu& zW?fQhAEEZT?0t(T8C04<2vNZ5=KrHQLF2Em%w=s2aEBa#t`H(OvYlUVHtoecF%$UHv zcS}nQ*VMfF3svK|crfsC93%2HTu=?BT$XkI{&H!~ra&=lz3xG28rLf{*_^W^k1dKz;7VOqjK-5`}# z|L;vvxWb4|vO4t{(Z2mUH|2rG zrWQWPk@q`DlW9h-G`%2A$aAWizG+{$aMK|D{gGaR{+sKU`!)mX`~?ru-8qZIkfLJ8 z{hLV>&aQ8}9Usn%389YN(+TIM#rE1%BY%#7rVyGNx_7B!MBfY%0a;Oo|0tBp^-?Lt zlIzenG!9w*nazT`r^m-x?6>*|8T2jYLhYo@mrA3sSMZ1Pl}EeZEer-$qw3io)8FDP zs2~psr*GhX{Ma`eeF6R_ZP-1sc~cW}67!t0pW21--|*}}RTk;9qoM#L z1REs9IcCOUzRy=n{H9GHFuw>M)%UitUuOl(-rY}a4~{W7Q0p1B%)sZTME*2CQ! zao+Qu?K^7B{GR>k$M;@;xn=KAvLitEn5J#5i7KT^*fr$d0((`W7lT#3d1(@SwP<{RxV>4MX?8lZrka(2P%w_g0JHT9ANQM@dV8;jGS^c6k~Vw#j* zHZ2HY3 zF1wp+(nsWCv-Cyg9&w-*f@xN9wE5{0qFH1=RK}>IX+NJ;S5t-+yv@*|ty{c~Y$hee zwdVsDo@l$a)1U@4|3G^@l$_fa(7YMt12MPm1*&`?O^t1DwH#jYs{9?7qFPBO84H^+$ zo#rPHq75dliwS=pjYe762~V@G$Zj(C&0(qKig;_Wx>?)!9e$4S3cRe$NOlSCO*$2$ zTM6YtG$Mt{Jxnu(sKr8$BalOnU&~h6)<^qbMQ$lkyi%~cb4A(x13=15&gav}Sd4P! zHt5Uy9!_w#TC1Ia$8tG$7>RMO)_Qv z7Kb~`G07QP%uqaULIaiE@7JHZ+hevuQHI?#g{fH<9aBbcTUdCOup=MVu>3sG{qiIkvXgQSfTg!9uB?A7 zX7Rs04E20e@EoOyjU5J=tg6TBlPZ^|QkSQpcj?Z>o6U?^{}r__S8$rcRkjxI5=z;? zbx6uSh+KyR+4VN_u&mbwOJF`znsd7mtjhz^SUo}EhReC z8GNiTW)HB4o*H&vp4tW9zF~9cL18g*N?YDCAtaFs{%RGDmQ`}KC+v{Ysym`7*7G-j zPzg#E(;F+UEQ%e3XhoFDjJ}{YEdyJ^H^>oz_z4tlr=+QtC0|@SZeK+IWdwhH!b@U- zLpG?2+W%5uzs(biu|H2&E$#|R`n>ws8B40&q6P~rFR^L;rzBy(T5fSakO30BiHJw! zc#F`0_9|~XU%K}>xBAg=PL^JO4~#*^KkbDcuy8Y}*S%Z1($RBa>IC6hABwqK-?gLy zr|zkl=_~vFD~O^k+(N8uP9|*7STF+p(Nq1km>ssa#Yxf6%2RW1if#hzJ`Owh0gCeK z!)*H0fSbds`=(9(96ViCMB=M=ao@eWFSv!4 zu;$*#1_dThkL&!tjoI|xHH64U9w_4i1Q0JZh&c5<^aq8|6%6CPO}Yk)b7>_J^7u%)Q&sVRcyWeaxUFC2gI&z~os>40N_`fc}b(sC%GT zZ{Vgp5B3^7Sn?@i=Y3CP)37=7AjFK8nQ4DyMpt@}{G{mzb#?v5y&6NSn8i=gNUEz{ zh}IT;xrZ62-T<{)px@#`Mz^}MiD-1UfI}m^V@)$wSHaPVV`S$s=aI2Z+h3ujxi1!_ z5`IPkPA<1pE7AO0Kdfp9;6MYU6)|{FXl8}%*6Jage||~a7;nPO>RFs0v_F3;{A_nZ zseMtlXL@CRW?{}ON<9B_DLu%|$?ssZ7YOO2+vS@TGR@QaYZ&d;4+(FA1m)>vxAE~v zJJ(4cZl+nvBCva(4U@hwKb^^Wh2sawmGSJ}fmCh$oYn*Wl+!Rk)M_TB^N*qk5v{sg z&d9>@vP}@^(+5USO=Q2*x2==8hpG8JV<%xue)WPcC&)TFcLT{3*2i{JyID`{7#EvS zeE-bRon|-9{i6zScyz3p*@pLGWg&7W9kkDC54u{%qgdp*nebPez*PrkmY6CXJ%7)f zJj3ot_Chz{dJ@nWo@1xB34~jVIN)^tVJr22jLn#6By+5xwpA9Q`1Py&-XmOckJBk# z!=kNL;CblA>dZ;z8`7^m6uG32o-;owX+tXMz9E!L9xo7YMSVJ7ScB`w1WM~aOW8$T z#hMD?iRc(`Vt$1KbtF_1M*#`WSyF^lt?9=P?Ep4}{=#&DkttGK5l9mZwb2)+X0yTU zwH4+txeaBJ8S-lLH`iaD#Qe>#mbZL(QkRl$j>Wu}OFbeSK+43ODL!3>1dR`i3>DvqG0VOX3`KO=*T9wV&5WBR@M+{6 z;Hv*{f*=QE9{3ufyv@+4#9YC1#3-e(3^2>qjzwBhc;v2FcrV> zKHeaW#TcTKv2EpCTOWC>-7Of*IHM^DRExH%dXck0bdJoS_v@T>$ljvgUpk_ zUFLD)96GPp6Y3(giOwX0LcQ^Z#MXuopHXR?^$NB`?x|;;E;Snram0YdkR-0knwhdm z7+LoK6<+n!eYGI@nucEysHgw|PTvKmRVY(FYnWPgsmX#@0HLb#q7dj$b#Dd$MNYM} z>W;sr&92U$J(4w3fdWCM+i&@_ecG`#P|)&B^lnbZGK@QXqaDwjHK(zbrY>(f8> zPyX}Y2BXFCJKt>vUC7cGB{7cq>1VN8iCMFD8$4`5;G;rFh1N=Cd&2go{-U3*qqpN? zp0^Bx+BFfa%39RIh_p&}^MBG*%TL)Uea8An)h@LhN{?jmU+swVsD$o(;9O|jVju~T zC|}H&31v!rMMt8`ZpFY|l=?o;i|29c&v#FhZ})0!QoI~WdZ>8}5Rb)6d?^cBLM7#o d+5h89l7Ce$6aI^k9(ej&GY-8%iCu+a{TE}N6&L^j delta 65 zcmaE}mg(45Mh-XsAYTTCsC^ST7KubjdAb-gGcYhPnh1n=h8O`^$`kh~b1^VD26#F% VFofRR`0{q>=8uzpGf$e#3IJ#?6l?$h diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index e0e5cb2..5871c99 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -439,7 +439,7 @@ # WMA ('samples/test2.wma', - {'extra': {'track': ['0'], + {'extra': {'_track': ['0'], 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], 'encodingtime': ['128861118183900000'], 'wmfsdkversion': ['11.0.5721.5145'], 'wmfsdkneeded': ['0.0.0.0000'], 'isvbr': ['1'], 'peakvalue': ['30369'], @@ -763,6 +763,12 @@ def test_image_loading_extra(path: str) -> None: "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " "'description': None}" ) + assert str(tag.images) == ( + "{'extra': {'bright_colored_fish': [{'name': 'extra.bright_colored_fish', " + "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" + "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" + "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" + ) def test_mp3_utf_8_invalid_string() -> None: @@ -825,15 +831,10 @@ def test_to_str() -> None: def test_to_str_flatten() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) + tag = TinyTag.get(os.path.join(testfolder, 'samples/id3_multiple_artists.mp3')) assert ( - "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " - "'samplerate': 44100, 'artist': ['Anais Mitchell'], " - "'album': ['Hymns for the Exiled'], " - "'title': ['cosmic american'], 'track': 3, 'track_total': 11, " - "'year': ['2004'], 'comment': ['Waterbug Records, www.anaismitchell.com'], " - "'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 " - "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': " - "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" - "163829'], 'itunes_cddb_tracknumber': ['3'], 'images': {}" + "'filesize': 2007, 'duration': 0.1306122448979592, 'channels': 1, " + "'bitrate': 57.39124999999999, 'samplerate': 44100, 'artist': " + "['artist1', 'artist2', 'artist3', 'artist4', 'artist5', 'artist6', 'artist7'], " + "'genre': ['something 1'], 'images': {}" ) in str(tag.as_dict(flatten=True)) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index cdac459..285811a 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -108,6 +108,11 @@ def test_meta_data_output_format_tabularcsv() -> None: assert set(header.split(',')).issubset(tinytag_attributes) +def test_meta_data_output_format_invalid() -> None: + output = run_cli('-f invalid ' + mp3_with_image) + assert not output + + def test_fail_on_unsupported_file() -> None: with pytest.raises(CalledProcessError): run_cli(bogus_file) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 40c7ec4..4ce26f0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -259,9 +259,12 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: str | int | float) -> None: + def _set_field(self, fieldname: str, value: str | int | float, + check_conflict: bool = True) -> None: if fieldname.startswith(self._EXTRA_PREFIX): fieldname = fieldname[len(self._EXTRA_PREFIX):] + if check_conflict and fieldname in self.__dict__: + fieldname = '_' + fieldname extra_values = self.extra.get(fieldname, []) if not isinstance(value, str) or value in extra_values: return @@ -275,14 +278,11 @@ def _set_field(self, fieldname: str, value: str | int | float) -> None: if isinstance(new_value, str): # First value goes in tag, others in tag.extra values = new_value.split('\x00') - new_value = values[0] - start_pos = 0 if old_value else 1 - if len(values) > 1: - for i_value in values[start_pos:]: - self._set_field(self._EXTRA_PREFIX + fieldname, i_value) - elif old_value and new_value != old_value: - self._set_field(self._EXTRA_PREFIX + fieldname, new_value) - return + for index, i_value in enumerate(values): + if index or old_value and i_value != old_value: + self._set_field(self._EXTRA_PREFIX + fieldname, i_value, check_conflict=False) + continue + new_value = i_value if old_value: return elif not new_value and old_value: @@ -300,17 +300,18 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _update(self, other: TinyTag) -> None: # update the values of this tag with the values from another tag - for key, value in other.as_dict(flatten=False).items(): - if isinstance(value, dict): - if key != 'extra': - continue - for extra_key, extra_values in value.items(): - for extra_value in extra_values: - if isinstance(extra_value, str): - self._set_field(self._EXTRA_PREFIX + extra_key, extra_value) + for key, value in other.__dict__.items(): + if key.startswith('_'): + continue + if value is None: continue - if value is not None and not isinstance(value, list): + if not isinstance(value, dict): self._set_field(key, value) + for extra_key, extra_values in other.extra.items(): + for extra_value in extra_values: + if isinstance(extra_value, str): + self._set_field( + self._EXTRA_PREFIX + extra_key, extra_value, check_conflict=False) self.images._update(other.images) @staticmethod @@ -362,17 +363,22 @@ def any(self) -> TagImage | None: """Return a cover image. If not present, fall back to any other available image. """ - for image_list in self.as_dict(flatten=True).values(): - for image in image_list: + images: dict[str, list[TagImage]] = self.__dict__ + for image_list in images.values(): + if isinstance(image_list, list): + for image in image_list: + return image + for extra_image_list in self.extra.values(): + for image in extra_image_list: return image return None - def as_dict(self, flatten: bool = True) -> dict[str, list[TagImage]]: + def as_dict(self, flatten: bool = True) -> dict[ + str, list[TagImage] | dict[str, list[TagImage]] + ]: """Return a dictionary representation of the tag images.""" - images: dict[str, list[TagImage]] = {} + images: dict[str, list[TagImage] | dict[str, list[TagImage]]] = {} for key, value in self.__dict__.items(): - if key.startswith('_'): - continue if flatten and key == 'extra': for extra_key, extra_values in value.items(): if extra_key in images: @@ -398,14 +404,13 @@ def _set_field(self, fieldname: str, value: TagImage) -> None: write_dest[fieldname] = values def _update(self, other: TagImages) -> None: - for key, value in other.as_dict(flatten=False).items(): - if isinstance(value, dict): - for extra_key, extra_values in value.items(): - for image_extra in extra_values: - self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) - continue - for image in value: - self._set_field(key, image) + for key, value in other.__dict__.items(): + if isinstance(value, list): + for image in value: + self._set_field(key, image) + for extra_key, extra_values in other.extra.items(): + for image_extra in extra_values: + self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) class TagImage: From 9c978220558180b37f82ff4eed19a580cd9694be Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 31 May 2024 17:14:07 +0300 Subject: [PATCH 205/305] Add extra dict subclasses for easier typing --- tinytag/__init__.py | 6 +- .../tests/samples/flac_multiple_fields.flac | Bin 235 -> 266 bytes tinytag/tests/test_all.py | 49 ++++--- tinytag/tinytag.py | 135 ++++++++++-------- 4 files changed, 110 insertions(+), 80 deletions(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 0e62032..683b303 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,8 +1,10 @@ """Audio file metadata reader""" from .tinytag import ( - ParseError, TinyTag, TagImage, TagImages, TinyTagException, UnsupportedFormatError + ParseError, TinyTag, TagExtra, TagImage, TagImages, TagImagesExtra, + TinyTagException, UnsupportedFormatError ) __all__ = ( - "ParseError", "TinyTag", "TagImage", "TagImages", "TinyTagException", "UnsupportedFormatError" + "ParseError", "TinyTag", "TagExtra", "TagImage", "TagImages", "TagImagesExtra", + "TinyTagException", "UnsupportedFormatError" ) diff --git a/tinytag/tests/samples/flac_multiple_fields.flac b/tinytag/tests/samples/flac_multiple_fields.flac index 33f27ebc039bd488a272ad3ccbd47dd1bbd64937..bcbdd23ba8cdf8b0329ebf8ce30f3942168012dd 100644 GIT binary patch delta 72 zcmaFO*u^wKm+{C%JrzcdiNOu#(hLj?;jT`>o*}Nb86_nJ#a8 None: assert image.mime_type == 'image/jpeg' -@pytest.mark.parametrize('path', [ - 'samples/ogg_with_image.ogg', -]) -def test_image_loading_extra(path: str) -> None: - tag = TinyTag.get(os.path.join(testfolder, path), image=True) +def test_image_loading_extra() -> None: + tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) image = tag.images.extra['bright_colored_fish'][0] assert image.data is not None assert tag.images.any is not None @@ -763,12 +760,6 @@ def test_image_loading_extra(path: str) -> None: "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " "'description': None}" ) - assert str(tag.images) == ( - "{'extra': {'bright_colored_fish': [{'name': 'extra.bright_colored_fish', " - "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" - "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" - "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" - ) def test_mp3_utf_8_invalid_string() -> None: @@ -831,10 +822,30 @@ def test_to_str() -> None: def test_to_str_flatten() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/id3_multiple_artists.mp3')) + tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_multiple_fields.flac')) assert ( - "'filesize': 2007, 'duration': 0.1306122448979592, 'channels': 1, " - "'bitrate': 57.39124999999999, 'samplerate': 44100, 'artist': " - "['artist1', 'artist2', 'artist3', 'artist4', 'artist5', 'artist6', 'artist7'], " - "'genre': ['something 1'], 'images': {}" + "'filesize': 266, 'duration': 0.1, 'channels': 1, 'bitrate': 21.28, 'bitdepth': 16, " + "'samplerate': 44100, 'artist': ['artist 1', 'artist 2', 'artist 3'], " + "'album': ['album 1', 'album 2'], 'genre': ['genre 1', 'genre 2'], " + "'url': ['https://example.com'], 'images': {}" ) in str(tag.as_dict(flatten=True)) + + +def test_to_str_images() -> None: + tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + assert str(tag.images) == ( + "{'extra': {'bright_colored_fish': [{'name': 'extra.bright_colored_fish', " + "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" + "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" + "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" + ) + + +def test_to_str_images_flatten() -> None: + tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + assert str(tag.images.as_dict(flatten=True)) == ( + "{'bright_colored_fish': [{'name': 'extra.bright_colored_fish', " + "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" + "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" + "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}" + ) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4ce26f0..c8454bf 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -40,7 +40,7 @@ from functools import reduce from os import PathLike from sys import stderr -from typing import Any, BinaryIO +from typing import Any, BinaryIO, Dict, List from warnings import warn import base64 @@ -99,7 +99,7 @@ def __init__(self) -> None: self.genre: str | None = None self.year: str | None = None self.comment: str | None = None - self.extra: dict[str, list[str]] = {} + self.extra = TagExtra() self.images = TagImages() self._filehandler: BinaryIO | None = None self._default_encoding: str | None = None # allow override for some file formats @@ -107,6 +107,7 @@ def __init__(self) -> None: self._parse_tags = True self._load_image = False self._tags_parsed = False + self.__dict__: dict[str, str | int | float | TagExtra | TagImages] def __repr__(self) -> str: return str(self.as_dict(flatten=False)) @@ -156,29 +157,33 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: def as_dict(self, flatten: bool = True) -> dict[ str, - str | int | float | list[str | TagImage] | dict[str, list[str | TagImage]] + str | int | float | TagExtra | list[str | TagImage] + | dict[str, list[TagImage] | TagImagesExtra] ]: """Return a dictionary representation of the tag.""" fields: dict[ str, - str | int | float | list[str | TagImage] | dict[str, list[str | TagImage]] + str | int | float | TagExtra | list[str | TagImage] + | dict[str, list[TagImage] | TagImagesExtra] ] = {} for key, value in self.__dict__.items(): if key.startswith('_'): continue - if flatten and key == 'extra': + if isinstance(value, TagImages): + fields[key] = value.as_dict(flatten) + elif not isinstance(value, TagExtra): + if value is None: + continue + if flatten and key != 'filename' and isinstance(value, str): + fields[key] = [value] + else: + fields[key] = value + elif flatten: for extra_key, extra_values in value.items(): - if extra_key in fields: - fields[extra_key] += extra_values - else: - fields[extra_key] = extra_values - continue - if key == 'images': - value = value.as_dict(flatten) - if value is None: - continue - if flatten and key != 'filename' and isinstance(value, str): - fields[key] = [value] + extra_fields = fields.get(extra_key) + if not isinstance(extra_fields, list): + extra_fields = fields[extra_key] = [] + extra_fields += extra_values else: fields[key] = value return fields @@ -303,16 +308,15 @@ def _update(self, other: TinyTag) -> None: for key, value in other.__dict__.items(): if key.startswith('_'): continue - if value is None: - continue - if not isinstance(value, dict): + if isinstance(value, TagExtra): + for extra_key, extra_values in other.extra.items(): + for extra_value in extra_values: + self._set_field( + self._EXTRA_PREFIX + extra_key, extra_value, check_conflict=False) + elif isinstance(value, TagImages): + self.images._update(value) + elif value is not None: self._set_field(key, value) - for extra_key, extra_values in other.extra.items(): - for extra_value in extra_values: - if isinstance(extra_value, str): - self._set_field( - self._EXTRA_PREFIX + extra_key, extra_value, check_conflict=False) - self.images._update(other.images) @staticmethod def _bytes_to_int_le(b: bytes) -> int: @@ -343,6 +347,10 @@ def audio_offset(self) -> None: 'removed in a future 2.x release', DeprecationWarning, stacklevel=2) +class TagExtra(Dict[str, List[str]]): + """A dictionary containing additional fields of an audio file.""" + + class TagImages: """A class containing images embedded in an audio file.""" _EXTRA_PREFIX = 'extra.' @@ -353,7 +361,8 @@ def __init__(self) -> None: self.leaflet: list[TagImage] = [] self.media: list[TagImage] = [] self.other: list[TagImage] = [] - self.extra: dict[str, list[TagImage]] = {} + self.extra = TagImagesExtra() + self.__dict__: dict[str, list[TagImage] | TagImagesExtra] def __repr__(self) -> str: return str(self.as_dict(flatten=False)) @@ -363,54 +372,62 @@ def any(self) -> TagImage | None: """Return a cover image. If not present, fall back to any other available image. """ - images: dict[str, list[TagImage]] = self.__dict__ - for image_list in images.values(): - if isinstance(image_list, list): - for image in image_list: - return image - for extra_image_list in self.extra.values(): - for image in extra_image_list: + for value in self.__dict__.values(): + if isinstance(value, TagImagesExtra): + for extra_images in value.values(): + for image in extra_images: + return image + continue + for image in value: return image return None - def as_dict(self, flatten: bool = True) -> dict[ - str, list[TagImage] | dict[str, list[TagImage]] - ]: + def as_dict(self, flatten: bool = True) -> dict[str, list[TagImage] | TagImagesExtra]: """Return a dictionary representation of the tag images.""" - images: dict[str, list[TagImage] | dict[str, list[TagImage]]] = {} + images: dict[str, list[TagImage] | TagImagesExtra] = {} for key, value in self.__dict__.items(): - if flatten and key == 'extra': + if not isinstance(value, TagImagesExtra): + if value: + images[key] = value + elif flatten: for extra_key, extra_values in value.items(): - if extra_key in images: - images[extra_key] += extra_values - else: - images[extra_key] = extra_values - continue - if value or key == 'extra': + extra_images = images.get(extra_key) + if not isinstance(extra_images, list): + extra_images = images[extra_key] = [] + extra_images += extra_values + else: images[key] = value return images def _set_field(self, fieldname: str, value: TagImage) -> None: - write_dest = self.__dict__ if fieldname.startswith(self._EXTRA_PREFIX): fieldname = fieldname[len(self._EXTRA_PREFIX):] - write_dest = self.extra - old_values = write_dest.get(fieldname) - values = [value] - if old_values is not None: - values = old_values + values - if DEBUG: - print(f'Setting image field "{fieldname}"') - write_dest[fieldname] = values + extra_values = self.extra.get(fieldname, []) + extra_values.append(value) + if DEBUG: + print(f'Setting extra image field "{fieldname}"') + self.extra[fieldname] = extra_values + return + values = self.__dict__.get(fieldname, []) + if isinstance(values, list): + values.append(value) + if DEBUG: + print(f'Setting image field "{fieldname}"') + self.__dict__[fieldname] = values def _update(self, other: TagImages) -> None: for key, value in other.__dict__.items(): - if isinstance(value, list): - for image in value: - self._set_field(key, image) - for extra_key, extra_values in other.extra.items(): - for image_extra in extra_values: - self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) + if isinstance(value, TagImagesExtra): + for extra_key, extra_values in value.items(): + for image_extra in extra_values: + self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) + continue + for image in value: + self._set_field(key, image) + + +class TagImagesExtra(Dict[str, List["TagImage"]]): + """A dictionary containing additional images embedded in an audio file.""" class TagImage: From 1972293dc9470892d17f7e1eab6d335e0da86143 Mon Sep 17 00:00:00 2001 From: Kutu <87788540+kutu-dev@users.noreply.github.com> Date: Wed, 5 Jun 2024 17:31:41 +0200 Subject: [PATCH 206/305] Add missing typing metadata (#211) Co-authored-by: Kutu --- setup.cfg | 1 + tinytag/py.typed | 0 2 files changed, 1 insertion(+) create mode 100644 tinytag/py.typed diff --git a/setup.cfg b/setup.cfg index 9c1bb77..997047d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,6 +35,7 @@ classifiers = Topic :: Multimedia Topic :: Multimedia :: Sound/Audio Topic :: Multimedia :: Sound/Audio :: Analysis + Typing :: Typed license = MIT license_files = LICENSE long_description = file: README.md diff --git a/tinytag/py.typed b/tinytag/py.typed new file mode 100644 index 0000000..e69de29 From 5ccdf6177b028e1ee6d1ec57189863271b6fe635 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 12 Jun 2024 16:30:42 +0300 Subject: [PATCH 207/305] Add project icon (#203) --- MANIFEST.in | 2 ++ data/icons/icon.svg | 1 + data/icons/icon_bg.png | Bin 0 -> 19042 bytes data/icons/icon_bg.svg | 1 + data/icons/icon_bg_round.png | Bin 0 -> 13016 bytes data/icons/icon_bg_round.svg | 1 + 6 files changed, 5 insertions(+) create mode 100644 data/icons/icon.svg create mode 100644 data/icons/icon_bg.png create mode 100644 data/icons/icon_bg.svg create mode 100644 data/icons/icon_bg_round.png create mode 100644 data/icons/icon_bg_round.svg diff --git a/MANIFEST.in b/MANIFEST.in index 51a0f07..4ed75df 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,6 @@ include *.md +include *.png +include *.svg include *.toml include *.txt include LICENSE diff --git a/data/icons/icon.svg b/data/icons/icon.svg new file mode 100644 index 0000000..09f2ed9 --- /dev/null +++ b/data/icons/icon.svg @@ -0,0 +1 @@ + diff --git a/data/icons/icon_bg.png b/data/icons/icon_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..77e3a31fd1601d3896fe76140e164e51379746e4 GIT binary patch literal 19042 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 X-Am5 diff --git a/data/icons/icon_bg_round.png b/data/icons/icon_bg_round.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc58e66ab5f557a24e654cffcbcf46292c3dca7 GIT binary patch literal 13016 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 Z!AEDOr2RjOK)#I-$=Hhc From 06b5b6d13c2d18bc976e65d902240c60912b43e7 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 12 Jun 2024 21:46:34 +0300 Subject: [PATCH 208/305] Update project URL --- README.md | 4 ++-- setup.cfg | 2 +- .../tests/please download the test samples from github.txt | 2 +- tinytag/tinytag.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0c18f7e..7d948d4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ tinytag is a Python library for reading audio file metadata -[![Build Status](https://img.shields.io/github/actions/workflow/status/devsnd/tinytag/tests.yml -)](https://github.com/devsnd/tinytag/actions?query=workflow:%22Tests%22) +[![Build Status](https://img.shields.io/github/actions/workflow/status/tinytag/tinytag/tests.yml +)](https://github.com/tinytag/tinytag/actions?query=workflow:%22Tests%22) [![Coverage Status](https://img.shields.io/coverallsCoverage/github/devsnd/tinytag )](https://coveralls.io/r/devsnd/tinytag) [![PyPI Version](https://img.shields.io/pypi/v/tinytag diff --git a/setup.cfg b/setup.cfg index 997047d..15d2849 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ name = tinytag version = 2.0.0 author = Tom Wallroth author_email = tomwallroth@gmail.com -url = https://github.com/devsnd/tinytag +url = https://github.com/tinytag/tinytag description = Read audio file metadata keywords = metadata diff --git a/tinytag/tests/please download the test samples from github.txt b/tinytag/tests/please download the test samples from github.txt index b76b820..c1e3678 100644 --- a/tinytag/tests/please download the test samples from github.txt +++ b/tinytag/tests/please download the test samples from github.txt @@ -1,3 +1,3 @@ If you installed tinytag from pip, it is missing the test samples needed to run the test suite. please download the sources (including the test samples) -from github to run the test suite. See: https://github.com/devsnd/tinytag +from github to run the test suite. See: https://github.com/tinytag/tinytag diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 48a2939..8e1af42 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -3,7 +3,7 @@ # Copyright (c) 2021-2024 Mat (mathiascode) # # Sources on GitHub: -# http://github.com/devsnd/tinytag/ +# http://github.com/tinytag/tinytag # MIT License From e23d5dfa8a8c08e08c8f5760dd6973e4e0d0d333 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 13 Jun 2024 14:58:26 +0300 Subject: [PATCH 209/305] Remove Appveyor CI config It no longer works since transferring the repo, and we switched to GitHub Actions anyway. --- .appveyor.yml | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 .appveyor.yml diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index d6dba94..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,20 +0,0 @@ -image: -- Visual Studio 2017 - -stack: python 3 - -environment: - PY_DIR: C:\Python37-x64 - -clone_depth: 3 - -build: off - -init: -- cmd: set PATH=%PY_DIR%;%PY_DIR%\Scripts;%PATH% - -install: -- pip install .[tests] - -test_script: -- pytest --cov From 6c3ccfd7e9eeca6f6230d769e6c7b7f7f810b6ee Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 13 Jun 2024 17:47:43 +0300 Subject: [PATCH 210/305] Use new Coveralls project (#213) --- .github/workflows/tests.yml | 21 +++++++++++++-------- README.md | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5dcc691..87fe55b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,20 +46,25 @@ jobs: python -m mypy -p tinytag - name: Unit tests - run: python -m pytest --cov + run: python -m pytest --cov --cov-report=lcov:coverage/lcov.info env: TINYTAG_DEBUG: true - name: Build package run: python -m build - - name: Coverage report - if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' - run: coverage lcov - - name: Coveralls uses: coverallsapp/github-action@master - if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12' with: - github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: coverage.lcov + flag-name: run-${{ join(matrix.*, '-') }} + parallel: true + + finish: + needs: tests + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Coveralls finished + uses: coverallsapp/github-action@master + with: + parallel-finished: true diff --git a/README.md b/README.md index 7d948d4..f5b3fc5 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ tinytag is a Python library for reading audio file metadata [![Build Status](https://img.shields.io/github/actions/workflow/status/tinytag/tinytag/tests.yml )](https://github.com/tinytag/tinytag/actions?query=workflow:%22Tests%22) -[![Coverage Status](https://img.shields.io/coverallsCoverage/github/devsnd/tinytag -)](https://coveralls.io/r/devsnd/tinytag) +[![Coverage Status](https://img.shields.io/coverallsCoverage/github/tinytag/tinytag +)](https://coveralls.io/r/tinytag/tinytag) [![PyPI Version](https://img.shields.io/pypi/v/tinytag )](https://pypi.org/project/tinytag/) [![PyPI Downloads](https://img.shields.io/pypi/dm/tinytag From 2a92c6f18a1f904017838c39a4f6e8de70ae6b83 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 20 Jun 2024 23:58:59 +0300 Subject: [PATCH 211/305] ID3: fix invalid sample rate/duration in some cases (#214) --- tinytag/tests/samples/id3_frames.mp3 | Bin 0 -> 27576 bytes tinytag/tests/test_all.py | 3 ++ tinytag/tinytag.py | 43 ++++++++++++++------------- 3 files changed, 26 insertions(+), 20 deletions(-) create mode 100644 tinytag/tests/samples/id3_frames.mp3 diff --git a/tinytag/tests/samples/id3_frames.mp3 b/tinytag/tests/samples/id3_frames.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..6342e3d88023a47616bf6dfbf1a699f537821335 GIT binary patch literal 27576 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 z

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 literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index e16cf3f..eac6c9d 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -240,6 +240,9 @@ 'extra': {'other_artists': ['artist2', 'artist3', 'artist4', 'artist5', 'artist6', 'artist7']}, 'samplerate': 44100, 'artist': 'artist1', 'genre': 'something 1'}), + ('samples/id3_frames.mp3', + {'filesize': 27576, 'bitrate': 50.03636363636364, 'channels': 1, + 'duration': 3.96, 'samplerate': 16000, 'extra': {}}), # OGG ('samples/empty.ogg', diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 8e1af42..6c59652 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -849,23 +849,24 @@ def _determine_duration(self, fh: BinaryIO) -> None: max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3._SAMPLES_PER_FRAME frame_size_accu = 0 audio_offset = 0 - header_bytes = 4 frames = 0 # count frames for determining mp3 duration bitrate_accu = 0 # add up bitrates to find average bitrate to detect - last_bitrates = [] # CBR mp3s (multiple frames with same bitrates) + last_bitrates = set() # CBR mp3s (multiple frames with same bitrates) # seek to first position after id3 tag (speedup for large header) + first_mpeg_id = None fh.seek(self._bytepos_after_id3v2) file_offset = fh.tell() walker = io.BytesIO(fh.read()) while True: # reading through garbage until 11 '1' sync-bits are found - b = walker.read() - walker.seek(-len(b), os.SEEK_CUR) - if len(b) < 4: + header = walker.read(4) + header_len = len(header) + walker.seek(-header_len, os.SEEK_CUR) + if header_len < 4: if frames: self.bitrate = bitrate_accu / frames break # EOF - _sync, conf, bitrate_freq, rest = struct.unpack('BBBB', b[0:4]) + _sync, conf, bitrate_freq, rest = struct.unpack('BBBB', header) br_id = (bitrate_freq >> 4) & 0x0F # biterate id sr_id = (bitrate_freq >> 2) & 0x03 # sample rate id padding = 1 if bitrate_freq & 0x02 > 0 else 0 @@ -873,46 +874,48 @@ def _determine_duration(self, fh: BinaryIO) -> None: layer_id = (conf >> 1) & 0x03 channel_mode = (rest >> 6) & 0x03 # check for eleven 1s, validate bitrate and sample rate - if (not b[:2] > b'\xFF\xE0' or br_id > 14 or br_id == 0 or sr_id == 3 - or layer_id == 0 or mpeg_id == 1): # noqa - idx = b.find(b'\xFF', 1) # invalid frame, find next sync header + if (not header[:2] > b'\xFF\xE0' + or (first_mpeg_id is not None and first_mpeg_id != mpeg_id) + or br_id > 14 or br_id == 0 or sr_id == 3 or layer_id == 0 or mpeg_id == 1): + idx = header.find(b'\xFF', 1) # invalid frame, find next sync header if idx == -1: - idx = len(b) # not found: jump over the current peek buffer + idx = header_len # not found: jump over the current peek buffer walker.seek(max(idx, 1), os.SEEK_CUR) continue + if first_mpeg_id is None: + first_mpeg_id = mpeg_id self.channels = self._CHANNELS_PER_CHANNEL_MODE[channel_mode] frame_bitrate = self._BITRATE_BY_VERSION_BY_LAYER[mpeg_id][layer_id][br_id] self.samplerate = samplerate = self._SAMPLE_RATES[mpeg_id][sr_id] + frame_length = (144000 * frame_bitrate) // samplerate + padding # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate if frames == 0 and self._USE_XING_HEADER: - xing_header_offset = b.find(b'Xing') + walker_offset = walker.tell() + frame_content = walker.read(frame_length) + xing_header_offset = frame_content.find(b'Xing') if xing_header_offset != -1: - walker.seek(xing_header_offset, os.SEEK_CUR) + walker.seek(walker_offset + xing_header_offset) xframes, byte_count = self._parse_xing_header(walker) if xframes > 0 and byte_count > 0: # MPEG-2 Audio Layer III uses 576 samples per frame samples_per_frame = 576 if mpeg_id <= 2 else self._SAMPLES_PER_FRAME self.duration = duration = xframes * samples_per_frame / samplerate - # self.duration = (xframes * self._SAMPLES_PER_FRAME / samplerate - # / self.channels) # noqa self.bitrate = byte_count * 8 / duration / 1000 return - continue + walker.seek(walker_offset) frames += 1 # it's most probably an mp3 frame bitrate_accu += frame_bitrate if frames == 1: audio_offset = file_offset + walker.tell() if frames <= self._CBR_DETECTION_FRAME_COUNT: - last_bitrates.append(frame_bitrate) - walker.seek(4, os.SEEK_CUR) # jump over peeked bytes + last_bitrates.add(frame_bitrate) - frame_length = (144000 * frame_bitrate) // samplerate + padding frame_size_accu += frame_length # if bitrate does not change over time its probably CBR - is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(set(last_bitrates)) == 1) + is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(last_bitrates) == 1) if frames == max_estimation_frames or is_cbr: # try to estimate duration fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) @@ -924,7 +927,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: return if frame_length > 1: # jump over current frame body - walker.seek(frame_length - header_bytes, os.SEEK_CUR) + walker.seek(frame_length, os.SEEK_CUR) if self.samplerate: self.duration = frames * self._SAMPLES_PER_FRAME / self.samplerate From 568544dc805d801f547bc94a246e282ffc9e4677 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 18 Aug 2024 18:01:36 +0300 Subject: [PATCH 212/305] TagImage: remove 'extra.' prefix from name --- tinytag/tests/test_all.py | 8 ++++---- tinytag/tinytag.py | 5 ++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 9981ccb..2bea739 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -755,10 +755,10 @@ def test_image_loading_extra() -> None: assert tag.images.any is not None assert tag.images.any.data == image.data assert image.mime_type == 'image/jpeg' - assert image.name == 'extra.bright_colored_fish' + assert image.name == 'bright_colored_fish' assert len(image.data) == 1220 assert str(image) == ( - "{'name': 'extra.bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00" + "{'name': 'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00" "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " "'description': None}" @@ -837,7 +837,7 @@ def test_to_str_flatten() -> None: def test_to_str_images() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) assert str(tag.images) == ( - "{'extra': {'bright_colored_fish': [{'name': 'extra.bright_colored_fish', " + "{'extra': {'bright_colored_fish': [{'name': 'bright_colored_fish', " "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" @@ -847,7 +847,7 @@ def test_to_str_images() -> None: def test_to_str_images_flatten() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) assert str(tag.images.as_dict(flatten=True)) == ( - "{'bright_colored_fish': [{'name': 'extra.bright_colored_fish', " + "{'bright_colored_fish': [{'name': 'bright_colored_fish', " "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}" diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ce48fa6..3f5f137 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1091,7 +1091,10 @@ def _create_tag_image(cls, data: bytes, pic_type: int, mime_type: str | None = N field_name = cls._UNKNOWN_IMAGE_TYPE if 0 <= pic_type <= len(cls._IMAGE_TYPES): field_name = cls._IMAGE_TYPES[pic_type] - image = TagImage(field_name, data) + name = field_name + if field_name.startswith(cls._EXTRA_PREFIX): + name = field_name[len(cls._EXTRA_PREFIX):] + image = TagImage(name, data) if mime_type: image.mime_type = mime_type if description: From 1a803005052116c8d6ffe1f3f5f038307bc00822 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Aug 2024 15:07:13 +0300 Subject: [PATCH 213/305] Remove 'Tag' prefix from class names --- tinytag/__init__.py | 8 ++-- tinytag/tests/test_all.py | 3 +- tinytag/tinytag.py | 80 +++++++++++++++++++-------------------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 683b303..10b3713 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,10 +1,10 @@ """Audio file metadata reader""" from .tinytag import ( - ParseError, TinyTag, TagExtra, TagImage, TagImages, TagImagesExtra, - TinyTagException, UnsupportedFormatError + TinyTag, Extra, Image, Images, ImagesExtra, + TinyTagException, ParseError, UnsupportedFormatError ) __all__ = ( - "ParseError", "TinyTag", "TagExtra", "TagImage", "TagImages", "TagImagesExtra", - "TinyTagException", "UnsupportedFormatError" + "TinyTag", "Extra", "Image", "Images", "ImagesExtra", + "TinyTagException", "ParseError", "UnsupportedFormatError" ) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 2bea739..5785dc6 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -18,7 +18,8 @@ import pytest -from tinytag.tinytag import TinyTag, TinyTagException, _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff +from tinytag import TinyTag, TinyTagException +from tinytag.tinytag import _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff testfiles = dict([ diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3f5f137..e6cd5b2 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -99,15 +99,15 @@ def __init__(self) -> None: self.genre: str | None = None self.year: str | None = None self.comment: str | None = None - self.extra = TagExtra() - self.images = TagImages() + self.extra = Extra() + self.images = Images() self._filehandler: BinaryIO | None = None self._default_encoding: str | None = None # allow override for some file formats self._parse_duration = True self._parse_tags = True self._load_image = False self._tags_parsed = False - self.__dict__: dict[str, str | int | float | TagExtra | TagImages] + self.__dict__: dict[str, str | int | float | Extra | Images] def __repr__(self) -> str: return str(self.as_dict(flatten=False)) @@ -157,21 +157,21 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: def as_dict(self, flatten: bool = True) -> dict[ str, - str | int | float | TagExtra | list[str | TagImage] - | dict[str, list[TagImage] | TagImagesExtra] + str | int | float | Extra | list[str | Image] + | dict[str, list[Image] | ImagesExtra] ]: """Return a dictionary representation of the tag.""" fields: dict[ str, - str | int | float | TagExtra | list[str | TagImage] - | dict[str, list[TagImage] | TagImagesExtra] + str | int | float | Extra | list[str | Image] + | dict[str, list[Image] | ImagesExtra] ] = {} for key, value in self.__dict__.items(): if key.startswith('_'): continue - if isinstance(value, TagImages): + if isinstance(value, Images): fields[key] = value.as_dict(flatten) - elif not isinstance(value, TagExtra): + elif not isinstance(value, Extra): if value is None: continue if flatten and key != 'filename' and isinstance(value, str): @@ -308,12 +308,12 @@ def _update(self, other: TinyTag) -> None: for key, value in other.__dict__.items(): if key.startswith('_'): continue - if isinstance(value, TagExtra): + if isinstance(value, Extra): for extra_key, extra_values in other.extra.items(): for extra_value in extra_values: self._set_field( self._EXTRA_PREFIX + extra_key, extra_value, check_conflict=False) - elif isinstance(value, TagImages): + elif isinstance(value, Images): self.images._update(value) elif value is not None: self._set_field(key, value) @@ -347,33 +347,33 @@ def audio_offset(self) -> None: 'removed in a future 2.x release', DeprecationWarning, stacklevel=2) -class TagExtra(Dict[str, List[str]]): +class Extra(Dict[str, List[str]]): """A dictionary containing additional fields of an audio file.""" -class TagImages: +class Images: """A class containing images embedded in an audio file.""" _EXTRA_PREFIX = 'extra.' def __init__(self) -> None: - self.front_cover: list[TagImage] = [] - self.back_cover: list[TagImage] = [] - self.leaflet: list[TagImage] = [] - self.media: list[TagImage] = [] - self.other: list[TagImage] = [] - self.extra = TagImagesExtra() - self.__dict__: dict[str, list[TagImage] | TagImagesExtra] + self.front_cover: list[Image] = [] + self.back_cover: list[Image] = [] + self.leaflet: list[Image] = [] + self.media: list[Image] = [] + self.other: list[Image] = [] + self.extra = ImagesExtra() + self.__dict__: dict[str, list[Image] | ImagesExtra] def __repr__(self) -> str: return str(self.as_dict(flatten=False)) @property - def any(self) -> TagImage | None: + def any(self) -> Image | None: """Return a cover image. If not present, fall back to any other available image. """ for value in self.__dict__.values(): - if isinstance(value, TagImagesExtra): + if isinstance(value, ImagesExtra): for extra_images in value.values(): for image in extra_images: return image @@ -382,11 +382,11 @@ def any(self) -> TagImage | None: return image return None - def as_dict(self, flatten: bool = True) -> dict[str, list[TagImage] | TagImagesExtra]: + def as_dict(self, flatten: bool = True) -> dict[str, list[Image] | ImagesExtra]: """Return a dictionary representation of the tag images.""" - images: dict[str, list[TagImage] | TagImagesExtra] = {} + images: dict[str, list[Image] | ImagesExtra] = {} for key, value in self.__dict__.items(): - if not isinstance(value, TagImagesExtra): + if not isinstance(value, ImagesExtra): if value: images[key] = value elif flatten: @@ -399,7 +399,7 @@ def as_dict(self, flatten: bool = True) -> dict[str, list[TagImage] | TagImagesE images[key] = value return images - def _set_field(self, fieldname: str, value: TagImage) -> None: + def _set_field(self, fieldname: str, value: Image) -> None: if fieldname.startswith(self._EXTRA_PREFIX): fieldname = fieldname[len(self._EXTRA_PREFIX):] extra_values = self.extra.get(fieldname, []) @@ -415,9 +415,9 @@ def _set_field(self, fieldname: str, value: TagImage) -> None: print(f'Setting image field "{fieldname}"') self.__dict__[fieldname] = values - def _update(self, other: TagImages) -> None: + def _update(self, other: Images) -> None: for key, value in other.__dict__.items(): - if isinstance(value, TagImagesExtra): + if isinstance(value, ImagesExtra): for extra_key, extra_values in value.items(): for image_extra in extra_values: self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) @@ -426,11 +426,11 @@ def _update(self, other: TagImages) -> None: self._set_field(key, image) -class TagImagesExtra(Dict[str, List["TagImage"]]): +class ImagesExtra(Dict[str, List["Image"]]): """A dictionary containing additional images embedded in an audio file.""" -class TagImage: +class Image: """A class representing an image embedded in an audio file.""" def __init__(self, name: str, data: bytes, mime_type: str | None = None) -> None: self.name = name @@ -452,7 +452,7 @@ class _MP4(TinyTag): class _Parser: atom_decoder_by_type: dict[ - int, Callable[[bytes], int | str | bytes | TagImage]] | None = None + int, Callable[[bytes], int | str | bytes | Image]] | None = None _CUSTOM_FIELD_NAME_MAPPING = { 'artists': 'artist', 'conductor': 'extra.conductor', @@ -490,8 +490,8 @@ def _unpack_integer_unsigned(cls, value: bytes) -> str: @classmethod def _make_data_atom_parser( - cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes | TagImage]]: - def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | TagImage]: + cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: + def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | Image]: data_type = struct.unpack('>I', data_atom[:4])[0] if cls.atom_decoder_by_type is None: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 @@ -501,8 +501,8 @@ def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | TagImage 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS # 16: duration in millis - 13: lambda x: TagImage('front_cover', x, 'image/jpeg'), # JPEG - 14: lambda x: TagImage('front_cover', x, 'image/png'), # PNG + 13: lambda x: Image('front_cover', x, 'image/jpeg'), # JPEG + 14: lambda x: Image('front_cover', x, 'image/png'), # PNG 21: cls._unpack_integer, # BE Signed int 22: cls._unpack_integer_unsigned, # BE Unsigned int # 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 @@ -553,7 +553,7 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: break @classmethod - def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | TagImage]: + def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | Image]: fh = io.BytesIO(data) header_size = 8 field_name = None @@ -1087,14 +1087,14 @@ def __parse_custom_field(self, content: str) -> bool: @classmethod def _create_tag_image(cls, data: bytes, pic_type: int, mime_type: str | None = None, - description: str | None = None) -> tuple[str, TagImage]: + description: str | None = None) -> tuple[str, Image]: field_name = cls._UNKNOWN_IMAGE_TYPE if 0 <= pic_type <= len(cls._IMAGE_TYPES): field_name = cls._IMAGE_TYPES[pic_type] name = field_name if field_name.startswith(cls._EXTRA_PREFIX): name = field_name[len(cls._EXTRA_PREFIX):] - image = TagImage(name, data) + image = Image(name, data) if mime_type: image.mime_type = mime_type if description: @@ -1398,7 +1398,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N if key_lowercase == "metadata_block_picture" and self._load_image: if DEBUG: - print('Found Vorbis TagImage', key, value[:64]) + print('Found Vorbis Image', key, value[:64]) fieldname, fieldvalue = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) self.images._set_field(fieldname, fieldvalue) else: @@ -1625,7 +1625,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._tags_parsed = True @classmethod - def _parse_image(cls, fh: BinaryIO) -> tuple[str, TagImage]: + def _parse_image(cls, fh: BinaryIO) -> tuple[str, Image]: # https://xiph.org/flac/format.html#metadata_block_picture pic_type, mime_type_len = struct.unpack('>2I', fh.read(8)) mime_type = fh.read(mime_type_len).decode('utf-8', 'replace') From b1682be20a0a6aeae35bebef5e42e8f4d3ad2119 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Aug 2024 15:51:29 +0300 Subject: [PATCH 214/305] as_dict: remove 'flatten' parameter Better not to expose this as public API, since nobody has asked for it. Always return a flat dictionary instead. --- tinytag/__main__.py | 2 +- tinytag/tests/test_all.py | 37 +++++++++++++++--------- tinytag/tinytag.py | 59 ++++++++++++++++----------------------- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index e6d0867..5a9d996 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -46,7 +46,7 @@ def _pop_switch(name: str) -> bool: def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool: - data = tag.as_dict(flatten=True) + data = tag.as_dict() del data['images'] if formatting == 'json': print(json.dumps(data, ensure_ascii=False, indent=2)) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 5785dc6..320dcd6 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -600,7 +600,10 @@ def error_fmt(value: str | int | float) -> str: def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True, duration=True) - results = tag.as_dict(flatten=False) + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } for attr_name in ('filename', 'images'): del results[attr_name] compare_tag(results, expected, filename) @@ -612,7 +615,10 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - filename = os.path.join(testfolder, testfile) excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"} tag = TinyTag.get(filename, tags=True, duration=False) - results = tag.as_dict(flatten=False) + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } for attr_name in ('filename', 'images'): del results[attr_name] expected = { @@ -627,7 +633,10 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] filename = os.path.join(testfolder, testfile) allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False, duration=True) - results = tag.as_dict(flatten=False) + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } for attr_name in ('filename', 'extra', 'images'): del results[attr_name] expected = { @@ -814,40 +823,42 @@ def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert ( "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " - "'samplerate': 44100, 'artist': 'Anais Mitchell', " - "'album': 'Hymns for the Exiled', " - "'title': 'cosmic american', 'track': 3, 'track_total': 11, " - "'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " + "'bitdepth': None, 'samplerate': 44100, 'artist': 'Anais Mitchell', " + "'albumartist': None, 'composer': None, 'album': 'Hymns for the Exiled', 'disc': None, " + "'disc_total': None, 'title': 'cosmic american', 'track': 3, 'track_total': 11, " + "'genre': None, 'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 " "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': " "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" - "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'extra': {}}" + "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'front_cover': [], " + "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], 'extra': {}}" ) in str(tag) -def test_to_str_flatten() -> None: +def test_to_str_flat_dict() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_multiple_fields.flac')) assert ( "'filesize': 266, 'duration': 0.1, 'channels': 1, 'bitrate': 21.28, 'bitdepth': 16, " "'samplerate': 44100, 'artist': ['artist 1', 'artist 2', 'artist 3'], " "'album': ['album 1', 'album 2'], 'genre': ['genre 1', 'genre 2'], " "'url': ['https://example.com'], 'images': {}" - ) in str(tag.as_dict(flatten=True)) + ) in str(tag.as_dict()) def test_to_str_images() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) assert str(tag.images) == ( - "{'extra': {'bright_colored_fish': [{'name': 'bright_colored_fish', " + "{'front_cover': [], 'back_cover': [], 'leaflet': [], 'media': [], 'other': [], " + "'extra': {'bright_colored_fish': [{'name': 'bright_colored_fish', " "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" ) -def test_to_str_images_flatten() -> None: +def test_to_str_images_flat_dict() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) - assert str(tag.images.as_dict(flatten=True)) == ( + assert str(tag.images.as_dict()) == ( "{'bright_colored_fish': [{'name': 'bright_colored_fish', " "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e6cd5b2..c82aaa2 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -110,7 +110,7 @@ def __init__(self) -> None: self.__dict__: dict[str, str | int | float | Extra | Images] def __repr__(self) -> str: - return str(self.as_dict(flatten=False)) + return str({key: value for key, value in self.__dict__.items() if not key.startswith('_')}) @classmethod def get(cls, @@ -155,37 +155,28 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: """Check if a specific file is supported based on its file extension.""" return cls._get_parser_for_filename(filename) is not None - def as_dict(self, flatten: bool = True) -> dict[ - str, - str | int | float | Extra | list[str | Image] - | dict[str, list[Image] | ImagesExtra] - ]: - """Return a dictionary representation of the tag.""" - fields: dict[ - str, - str | int | float | Extra | list[str | Image] - | dict[str, list[Image] | ImagesExtra] - ] = {} + def as_dict(self) -> dict[str, str | int | float | list[str | Image] | dict[str, list[Image]]]: + """Return a flat dictionary representation of the tag.""" + fields: dict[str, str | int | float | list[str | Image] | dict[str, list[Image]]] = {} for key, value in self.__dict__.items(): if key.startswith('_'): continue if isinstance(value, Images): - fields[key] = value.as_dict(flatten) - elif not isinstance(value, Extra): + fields[key] = value.as_dict() + continue + if not isinstance(value, Extra): if value is None: continue - if flatten and key != 'filename' and isinstance(value, str): + if key != 'filename' and isinstance(value, str): fields[key] = [value] else: fields[key] = value - elif flatten: - for extra_key, extra_values in value.items(): - extra_fields = fields.get(extra_key) - if not isinstance(extra_fields, list): - extra_fields = fields[extra_key] = [] - extra_fields += extra_values - else: - fields[key] = value + continue + for extra_key, extra_values in value.items(): + extra_fields = fields.get(extra_key) + if not isinstance(extra_fields, list): + extra_fields = fields[extra_key] = [] + extra_fields += extra_values return fields @classmethod @@ -365,7 +356,7 @@ def __init__(self) -> None: self.__dict__: dict[str, list[Image] | ImagesExtra] def __repr__(self) -> str: - return str(self.as_dict(flatten=False)) + return str({key: value for key, value in self.__dict__.items() if not key.startswith('_')}) @property def any(self) -> Image | None: @@ -382,21 +373,19 @@ def any(self) -> Image | None: return image return None - def as_dict(self, flatten: bool = True) -> dict[str, list[Image] | ImagesExtra]: - """Return a dictionary representation of the tag images.""" - images: dict[str, list[Image] | ImagesExtra] = {} + def as_dict(self) -> dict[str, list[Image]]: + """Return a flat dictionary representation of the tag images.""" + images: dict[str, list[Image]] = {} for key, value in self.__dict__.items(): if not isinstance(value, ImagesExtra): if value: images[key] = value - elif flatten: - for extra_key, extra_values in value.items(): - extra_images = images.get(extra_key) - if not isinstance(extra_images, list): - extra_images = images[extra_key] = [] - extra_images += extra_values - else: - images[key] = value + continue + for extra_key, extra_values in value.items(): + extra_images = images.get(extra_key) + if not isinstance(extra_images, list): + extra_images = images[extra_key] = [] + extra_images += extra_values return images def _set_field(self, fieldname: str, value: Image) -> None: From df15753a06772846d15b55243c85b374a82a7c86 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Aug 2024 16:07:47 +0300 Subject: [PATCH 215/305] as_dict: correct type hint --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c82aaa2..e8b57f8 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -155,9 +155,9 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: """Check if a specific file is supported based on its file extension.""" return cls._get_parser_for_filename(filename) is not None - def as_dict(self) -> dict[str, str | int | float | list[str | Image] | dict[str, list[Image]]]: + def as_dict(self) -> dict[str, str | int | float | list[str] | dict[str, list[Image]]]: """Return a flat dictionary representation of the tag.""" - fields: dict[str, str | int | float | list[str | Image] | dict[str, list[Image]]] = {} + fields: dict[str, str | int | float | list[str] | dict[str, list[Image]]] = {} for key, value in self.__dict__.items(): if key.startswith('_'): continue From e22b0831780dc8049d0307ddb3b0af791ee90d96 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Aug 2024 16:12:24 +0300 Subject: [PATCH 216/305] Show help if no filename is provided --- tinytag/__main__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 5a9d996..9663b71 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -8,7 +8,7 @@ import os import sys -from tinytag.tinytag import TinyTag, TinyTagException +from tinytag import TinyTag, TinyTagException def _usage() -> None: @@ -73,15 +73,15 @@ def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> b def _run() -> int: - display_help = _pop_switch('--help') or _pop_switch('-h') - if display_help: - _usage() - return 0 + header_printed = False save_image_path = _pop_param('--save-image', None) or _pop_param('-i', None) formatting = (_pop_param('--format', None) or _pop_param('-f', None)) or 'json' skip_unsupported = _pop_switch('--skip-unsupported') or _pop_switch('-s') filenames = sys.argv[1:] - header_printed = False + display_help = not filenames or _pop_switch('--help') or _pop_switch('-h') + if display_help: + _usage() + return 0 for i, filename in enumerate(filenames): if skip_unsupported and not (TinyTag.is_supported(filename) and os.path.isfile(filename)): From a31fa78ae270a16fd4f7fcb0b95399cb4ed61b8c Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Aug 2024 16:31:22 +0300 Subject: [PATCH 217/305] as_dict: update comments --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e8b57f8..22fedb0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -156,7 +156,7 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: return cls._get_parser_for_filename(filename) is not None def as_dict(self) -> dict[str, str | int | float | list[str] | dict[str, list[Image]]]: - """Return a flat dictionary representation of the tag.""" + """Return a flat dictionary representation of available metadata.""" fields: dict[str, str | int | float | list[str] | dict[str, list[Image]]] = {} for key, value in self.__dict__.items(): if key.startswith('_'): @@ -374,7 +374,7 @@ def any(self) -> Image | None: return None def as_dict(self) -> dict[str, list[Image]]: - """Return a flat dictionary representation of the tag images.""" + """Return a flat dictionary representation of available images.""" images: dict[str, list[Image]] = {} for key, value in self.__dict__.items(): if not isinstance(value, ImagesExtra): From 69cc78ce28e8386f74ce337fa6b767a5c39d7312 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 29 Aug 2024 16:37:57 +0300 Subject: [PATCH 218/305] Update changelog --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f5b3fc5..235a7d7 100644 --- a/README.md +++ b/README.md @@ -109,21 +109,23 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ### 2.0.0 (Unreleased) - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str +- **BREAKING:** 'extra' dict stores values in list form - **BREAKING:** TinyTagException no longer inherits LookupError - **BREAKING:** TinyTag subclasses are now private - **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 +- Add type hints to codebase - Mark 'ignore_errors' parameter for TinyTag.get() as obsolete - Mark 'audio_offset' attribute as obsolete - Deprecate 'get_image()' method in favor of 'images.any' property - Provide access to custom metadata fields through the 'extra' dict - Provide access to all available images - Add more standard 'extra' fields +- ID3: Fix invalid sample rate/duration in some cases - FLAC: Apply ID3 tags after Vorbis -- OGG/WMA: set missing 'channels' field -- WMA: set missing 'extra.copyright' field -- WMA: raise exception if file is invalid -- Add type hints to codebase +- OGG/WMA: Set missing 'channels' field +- WMA: Set missing 'extra.copyright' field +- WMA: Raise exception if file is invalid - Various optimizations ### 1.10.1 (2023-10-26) From 879c57eab282138fe93eec77f623917703040ec0 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 17 Oct 2024 16:00:00 +0300 Subject: [PATCH 219/305] Cleanups related to imports --- tinytag/__main__.py | 9 +- tinytag/tests/test_all.py | 20 ++-- tinytag/tinytag.py | 242 +++++++++++++++++++------------------- 3 files changed, 132 insertions(+), 139 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 9663b71..7f62910 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,13 +1,14 @@ # pylint: disable=missing-module-docstring,protected-access from __future__ import annotations -from io import StringIO -from os.path import splitext + import csv import json -import os import sys +from io import StringIO +from os.path import isfile, splitext + from tinytag import TinyTag, TinyTagException @@ -84,7 +85,7 @@ def _run() -> int: return 0 for i, filename in enumerate(filenames): - if skip_unsupported and not (TinyTag.is_supported(filename) and os.path.isfile(filename)): + if skip_unsupported and not (TinyTag.is_supported(filename) and isfile(filename)): continue try: tag = TinyTag.get(filename, image=save_image_path is not None) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 320dcd6..7470de6 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1,21 +1,15 @@ -# tests can be extended using other bigger files that are not going to be -# checked into git, by placing them into the custom_samples folder -# -# see custom_samples/instructions.txt -# - # pylint: disable=missing-function-docstring,missing-module-docstring,protected-access - from __future__ import annotations -from typing import Any -import io -import os -import pathlib +import os.path import shutil import sys +from io import BytesIO +from pathlib import Path +from typing import Any + import pytest from tinytag import TinyTag, TinyTagException @@ -648,7 +642,7 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any] def test_pathlib_compatibility() -> None: testfile = next(iter(testfiles.keys())) - filename = pathlib.Path(testfolder) / testfile + filename = Path(testfolder) / testfile TinyTag.get(filename) assert TinyTag.is_supported(filename) @@ -659,7 +653,7 @@ def test_file_obj_compatibility() -> None: with open(filename, 'rb') as file_handle: tag = TinyTag.get(file_obj=file_handle) file_handle.seek(0) - tag_bytesio = TinyTag.get(file_obj=io.BytesIO(file_handle.read())) + tag_bytesio = TinyTag.get(file_obj=BytesIO(file_handle.read())) assert tag.filesize == tag_bytesio.filesize diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 22fedb0..8846ed3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -36,21 +36,19 @@ from __future__ import annotations +from base64 import b64decode from collections.abc import Callable, Iterator from functools import reduce -from os import PathLike +from io import BytesIO +from os import PathLike, SEEK_CUR, SEEK_END, SEEK_SET, environ, fsdecode +from re import match +from struct import unpack from sys import stderr from typing import Any, BinaryIO, Dict, List from warnings import warn -import base64 -import io -import os -import re -import struct - -DEBUG = bool(os.environ.get('TINYTAG_DEBUG')) # some of the parsers can print debug info +DEBUG = bool(environ.get('TINYTAG_DEBUG')) # some of the parsers can print debug info class TinyTagException(Exception): @@ -131,7 +129,7 @@ def get(cls, warn('ignore_errors argument is obsolete, and will be removed in a future ' '2.x release', DeprecationWarning, stacklevel=2) try: - file_obj.seek(0, os.SEEK_END) + file_obj.seek(0, SEEK_END) filesize = file_obj.tell() file_obj.seek(0) parser_class = cls._get_parser_class(filename, file_obj) @@ -192,7 +190,7 @@ def _get_parser_for_filename( ('.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc'): _MP4, ('.aiff', '.aifc', '.aif', '.afc'): _Aiff, } - filename = os.fsdecode(filename).lower() + filename = fsdecode(filename).lower() for ext, tagclass in cls._file_extension_mapping.items(): if filename.endswith(ext): return tagclass @@ -222,7 +220,7 @@ def _get_parser_for_file_handle(cls, fh: BinaryIO) -> type[TinyTag] | None: header = fh.read(max(len(sig) for sig in cls._magic_bytes_mapping)) fh.seek(0) for magic, parser in cls._magic_bytes_mapping.items(): - if re.match(magic, header): + if match(magic, header): return parser return None @@ -312,7 +310,7 @@ def _update(self, other: TinyTag) -> None: @staticmethod def _bytes_to_int_le(b: bytes) -> int: fmt = {1: ' str: value_length = len(value) result = -1 if value_length == 1: - result = struct.unpack('>b' if signed else '>B', value)[0] + result = unpack('>b' if signed else '>B', value)[0] elif value_length == 2: - result = struct.unpack('>h' if signed else '>H', value)[0] + result = unpack('>h' if signed else '>H', value)[0] elif value_length == 4: - result = struct.unpack('>i' if signed else '>I', value)[0] + result = unpack('>i' if signed else '>I', value)[0] elif value_length == 8: - result = struct.unpack('>q' if signed else '>Q', value)[0] + result = unpack('>q' if signed else '>Q', value)[0] return str(result) @classmethod @@ -481,7 +479,7 @@ def _unpack_integer_unsigned(cls, value: bytes) -> str: def _make_data_atom_parser( cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | Image]: - data_type = struct.unpack('>I', data_atom[:4])[0] + data_type = unpack('>I', data_atom[:4])[0] if cls.atom_decoder_by_type is None: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 cls.atom_decoder_by_type = { @@ -494,8 +492,8 @@ def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | Image]: 14: lambda x: Image('front_cover', x, 'image/png'), # PNG 21: cls._unpack_integer, # BE Signed int 22: cls._unpack_integer_unsigned, # BE Unsigned int - # 23: lambda x: struct.unpack('>f', x)[0], # BE Float32 - # 24: lambda x: struct.unpack('>d', x)[0], # BE Float64 + # 23: lambda x: unpack('>f', x)[0], # BE Float32 + # 24: lambda x: unpack('>d', x)[0], # BE Float64 # 27: lambda x: x, # BMP # 28: lambda x: x, # QuickTime Metadata atom 65: cls._unpack_integer, # 8-bit Signed int @@ -521,7 +519,7 @@ def _make_number_parser( cls, fieldname1: str, fieldname2: str) -> Callable[[bytes], dict[str, int]]: def _(data_atom: bytes) -> dict[str, int]: number_data = data_atom[8:14] - numbers = struct.unpack('>HHH', number_data) + numbers = unpack('>HHH', number_data) # for some reason the first number is always irrelevant. return {fieldname1: numbers[1], fieldname2: numbers[2]} return _ @@ -529,7 +527,7 @@ def _(data_atom: bytes) -> dict[str, int]: @classmethod def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: # dunno why the genre is offset by -1 but that's how mutagen does it - idx = struct.unpack('>H', data_atom[8:])[0] - 1 + idx = unpack('>H', data_atom[8:])[0] - 1 result = {} if idx < len(_ID3._ID3V1_GENRES): result['genre'] = _ID3._ID3V1_GENRES[idx] @@ -543,13 +541,13 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: @classmethod def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | Image]: - fh = io.BytesIO(data) + fh = BytesIO(data) header_size = 8 field_name = None data_atom = b'' atom_header = fh.read(header_size) while len(atom_header) == header_size: - atom_size = struct.unpack('>I', atom_header[:4])[0] - header_size + atom_size = unpack('>I', atom_header[:4])[0] - header_size atom_type = atom_header[4:] if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() @@ -559,7 +557,7 @@ def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | Image elif atom_type == b'data': data_atom = fh.read(atom_size) else: - fh.seek(atom_size, os.SEEK_CUR) + fh.seek(atom_size, SEEK_CUR) atom_header = fh.read(header_size) # read next atom if len(data_atom) < 8 or field_name is None: return {} @@ -572,56 +570,56 @@ def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt # http://sasperger.tistory.com/103 - datafh = io.BytesIO(data) - datafh.seek(16, os.SEEK_CUR) # jump over version and flags - channels = struct.unpack('>H', datafh.read(2))[0] - datafh.seek(2, os.SEEK_CUR) # jump over bit_depth - datafh.seek(2, os.SEEK_CUR) # jump over QT compr id & pkt size - sr = struct.unpack('>I', datafh.read(4))[0] + datafh = BytesIO(data) + datafh.seek(16, SEEK_CUR) # jump over version and flags + channels = unpack('>H', datafh.read(2))[0] + datafh.seek(2, SEEK_CUR) # jump over bit_depth + datafh.seek(2, SEEK_CUR) # jump over QT compr id & pkt size + sr = unpack('>I', datafh.read(4))[0] # ES Description Atom - esds_atom_size = struct.unpack('>I', data[28:32])[0] - esds_atom = io.BytesIO(data[36:36 + esds_atom_size]) - esds_atom.seek(5, os.SEEK_CUR) # jump over version, flags and tag + esds_atom_size = unpack('>I', data[28:32])[0] + esds_atom = BytesIO(data[36:36 + esds_atom_size]) + esds_atom.seek(5, SEEK_CUR) # jump over version, flags and tag # ES Descriptor cls._read_extended_descriptor(esds_atom) - esds_atom.seek(4, os.SEEK_CUR) # jump over ES id, flags and tag + esds_atom.seek(4, SEEK_CUR) # jump over ES id, flags and tag # Decoder Config Descriptor cls._read_extended_descriptor(esds_atom) - esds_atom.seek(9, os.SEEK_CUR) - avg_br = struct.unpack('>I', esds_atom.read(4))[0] / 1000 # kbit/s + esds_atom.seek(9, SEEK_CUR) + avg_br = unpack('>I', esds_atom.read(4))[0] / 1000 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} @classmethod def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt - alac_atom_size = struct.unpack('>I', data[28:32])[0] - alac_atom = io.BytesIO(data[36:36 + alac_atom_size]) - alac_atom.seek(9, os.SEEK_CUR) - bitdepth = struct.unpack('b', alac_atom.read(1))[0] - alac_atom.seek(3, os.SEEK_CUR) - channels = struct.unpack('b', alac_atom.read(1))[0] - alac_atom.seek(6, os.SEEK_CUR) - avg_br = struct.unpack('>I', alac_atom.read(4))[0] / 1000 # kbit/s - sr = struct.unpack('>I', alac_atom.read(4))[0] + alac_atom_size = unpack('>I', data[28:32])[0] + alac_atom = BytesIO(data[36:36 + alac_atom_size]) + alac_atom.seek(9, SEEK_CUR) + bitdepth = unpack('b', alac_atom.read(1))[0] + alac_atom.seek(3, SEEK_CUR) + channels = unpack('b', alac_atom.read(1))[0] + alac_atom.seek(6, SEEK_CUR) + avg_br = unpack('>I', alac_atom.read(4))[0] / 1000 # kbit/s + sr = unpack('>I', alac_atom.read(4))[0] return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} @classmethod def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # http://stackoverflow.com/a/3639993/1191373 - walker = io.BytesIO(data) - version = struct.unpack('b', walker.read(1))[0] - walker.seek(3, os.SEEK_CUR) # jump over flags + walker = BytesIO(data) + version = unpack('b', walker.read(1))[0] + walker.seek(3, SEEK_CUR) # jump over flags if version == 0: # uses 32 bit integers for timestamps - walker.seek(8, os.SEEK_CUR) # jump over create & mod times - time_scale = struct.unpack('>I', walker.read(4))[0] - duration = struct.unpack('>I', walker.read(4))[0] + walker.seek(8, SEEK_CUR) # jump over create & mod times + time_scale = unpack('>I', walker.read(4))[0] + duration = unpack('>I', walker.read(4))[0] else: # version == 1: # uses 64 bit integers for timestamps - walker.seek(16, os.SEEK_CUR) # jump over create & mod times - time_scale = struct.unpack('>I', walker.read(4))[0] - duration = struct.unpack('>q', walker.read(8))[0] + walker.seek(16, SEEK_CUR) # jump over create & mod times + time_scale = unpack('>I', walker.read(4))[0] + duration = unpack('>q', walker.read(8))[0] return {'duration': duration / time_scale} # The parser tree: Each key is an atom name which is traversed if existing. @@ -683,7 +681,7 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], header_size = 8 atom_header = fh.read(header_size) while len(atom_header) == header_size: - atom_size = struct.unpack('>I', atom_header[:4])[0] - header_size + atom_size = unpack('>I', atom_header[:4])[0] - header_size atom_type = atom_header[4:] if curr_path is None: # keep track how we traversed in the tree curr_path = [atom_type] @@ -694,9 +692,9 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], print(f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' f'atom: {atom_type!r} len: {atom_size + header_size}') if atom_type in self._VERSIONED_ATOMS: # jump atom version for now - fh.seek(4, os.SEEK_CUR) + fh.seek(4, SEEK_CUR) if atom_type in self._FLAGGED_ATOMS: # jump atom flags for now - fh.seek(4, os.SEEK_CUR) + fh.seek(4, SEEK_CUR) sub_path = path.get(atom_type, None) # if the path leaf is a dict, traverse deeper into the tree: if isinstance(sub_path, dict): @@ -715,7 +713,7 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], self._set_field(fieldname, value) # if no action was specified using dict or callable, jump over atom else: - fh.seek(atom_size, os.SEEK_CUR) + fh.seek(atom_size, SEEK_CUR) # check if we have reached the end of this branch: if stop_pos and fh.tell() >= stop_pos: return # return to parent (next parent node in tree) @@ -880,17 +878,17 @@ def __init__(self) -> None: @staticmethod def _parse_xing_header(fh: BinaryIO) -> tuple[int, int]: # see: http://www.mp3-tech.org/programmer/sources/vbrheadersdk.zip - fh.seek(4, os.SEEK_CUR) # read over Xing header - header_flags = struct.unpack('>i', fh.read(4))[0] + fh.seek(4, SEEK_CUR) # read over Xing header + header_flags = unpack('>i', fh.read(4))[0] frames = byte_count = 0 if header_flags & 1: # FRAMES FLAG - frames = struct.unpack('>i', fh.read(4))[0] + frames = unpack('>i', fh.read(4))[0] if header_flags & 2: # BYTES FLAG - byte_count = struct.unpack('>i', fh.read(4))[0] + byte_count = unpack('>i', fh.read(4))[0] if header_flags & 4: # TOC FLAG - fh.seek(100, os.SEEK_CUR) + fh.seek(100, SEEK_CUR) if header_flags & 8: # VBR SCALE FLAG - fh.seek(4, os.SEEK_CUR) + fh.seek(4, SEEK_CUR) return frames, byte_count def _determine_duration(self, fh: BinaryIO) -> None: @@ -908,17 +906,17 @@ def _determine_duration(self, fh: BinaryIO) -> None: first_mpeg_id = None fh.seek(self._bytepos_after_id3v2) file_offset = fh.tell() - walker = io.BytesIO(fh.read()) + walker = BytesIO(fh.read()) while True: # reading through garbage until 11 '1' sync-bits are found header = walker.read(4) header_len = len(header) - walker.seek(-header_len, os.SEEK_CUR) + walker.seek(-header_len, SEEK_CUR) if header_len < 4: if frames: self.bitrate = bitrate_accu / frames break # EOF - _sync, conf, bitrate_freq, rest = struct.unpack('BBBB', header) + _sync, conf, bitrate_freq, rest = unpack('BBBB', header) br_id = (bitrate_freq >> 4) & 0x0F # biterate id sr_id = (bitrate_freq >> 2) & 0x03 # sample rate id padding = 1 if bitrate_freq & 0x02 > 0 else 0 @@ -932,7 +930,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: idx = header.find(b'\xFF', 1) # invalid frame, find next sync header if idx == -1: idx = header_len # not found: jump over the current peek buffer - walker.seek(max(idx, 1), os.SEEK_CUR) + walker.seek(max(idx, 1), SEEK_CUR) continue if first_mpeg_id is None: first_mpeg_id = mpeg_id @@ -979,21 +977,21 @@ def _determine_duration(self, fh: BinaryIO) -> None: return if frame_length > 1: # jump over current frame body - walker.seek(frame_length, os.SEEK_CUR) + walker.seek(frame_length, SEEK_CUR) if self.samplerate: self.duration = frames * self._SAMPLES_PER_FRAME / self.samplerate def _parse_tag(self, fh: BinaryIO) -> None: self._parse_id3v2(fh) if self.filesize > 128: - fh.seek(-128, os.SEEK_END) # try parsing id3v1 in last 128 bytes + fh.seek(-128, SEEK_END) # try parsing id3v1 in last 128 bytes self._parse_id3v1(fh) def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: size = major = 0 extended = False # for info on the specs, see: http://id3.org/Developer%20Information - header = struct.unpack('3sBBB4B', fh.read(10)) + header = unpack('3sBBB4B', fh.read(10)) tag = header[0].decode('ISO-8859-1', 'replace') # check if there is an ID3v2 tag at the beginning of the file if tag == 'ID3': @@ -1014,15 +1012,15 @@ def _parse_id3v2(self, fh: BinaryIO) -> None: end_pos = fh.tell() + size parsed_size = 0 if extended: # just read over the extended header. - size_bytes = struct.unpack('4B', fh.read(6)[0:4]) + size_bytes = unpack('4B', fh.read(6)[0:4]) extd_size = self._calc_size(size_bytes, 7) - fh.seek(extd_size - 6, os.SEEK_CUR) # jump over extended_header + fh.seek(extd_size - 6, SEEK_CUR) # jump over extended_header while parsed_size < size: frame_size = self._parse_frame(fh, id3version=major) if frame_size == 0: break parsed_size += frame_size - fh.seek(end_pos, os.SEEK_SET) + fh.seek(end_pos, SEEK_SET) def _parse_id3v1(self, fh: BinaryIO) -> None: if fh.read(3) != b'TAG': # check if this is an ID3 v1 tag @@ -1106,7 +1104,7 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: frame_header_data = fh.read(frame_header_size) if len(frame_header_data) != frame_header_size: return 0 - frame = struct.unpack(binformat, frame_header_data) + frame = unpack(binformat, frame_header_data) frame_id = self._decode_string(frame[0]) frame_size = self._calc_size(frame[1:1 + frame_size_bytes], bits_per_byte) if DEBUG: @@ -1309,32 +1307,32 @@ def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): - walker = io.BytesIO(packet) + walker = BytesIO(packet) if packet[0:7] == b"\x01vorbis": if self._parse_duration: (self.channels, self.samplerate, _max_bitrate, bitrate, - _min_bitrate) = struct.unpack(" None: elif check_flac_second_packet: # second packet contains FLAC metadata block if self._parse_tags: - meta_header = struct.unpack('B3B', walker.read(4)) + meta_header = unpack('B3B', walker.read(4)) block_type = meta_header[0] & 0x7f if block_type == _Flac.METADATA_VORBIS_COMMENT: self._parse_vorbis_comment(walker) @@ -1353,13 +1351,13 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif packet[0:8] == b'Speex ': # https://speex.org/docs/manual/speex-manual/node8.html if self._parse_duration: - walker.seek(36, os.SEEK_CUR) # jump over header name and irrelevant fields + walker.seek(36, SEEK_CUR) # jump over header name and irrelevant fields (self.samplerate, _, _, self.channels, - self.bitrate) = struct.unpack("<5i", walker.read(20)) + self.bitrate) = unpack("<5i", walker.read(20)) check_speex_second_packet = True elif check_speex_second_packet: if self._parse_tags: - length = struct.unpack('I', walker.read(4))[0] # starts with a comment string + length = unpack('I', walker.read(4))[0] # starts with a comment string comment = walker.read(length).decode('utf-8', 'replace') self._set_field('comment', comment) self._parse_vorbis_comment(walker, contains_vendor=False) # other tags @@ -1375,11 +1373,11 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N # discnumber tag based on: https://en.wikipedia.org/wiki/Vorbis_comment # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/Vorbis.html if contains_vendor: - vendor_length = struct.unpack('I', fh.read(4))[0] - fh.seek(vendor_length, os.SEEK_CUR) # jump over vendor - elements = struct.unpack('I', fh.read(4))[0] + vendor_length = unpack('I', fh.read(4))[0] + fh.seek(vendor_length, SEEK_CUR) # jump over vendor + elements = unpack('I', fh.read(4))[0] for _i in range(elements): - length = struct.unpack('I', fh.read(4))[0] + length = unpack('I', fh.read(4))[0] keyvalpair = fh.read(length).decode('utf-8', 'replace') if '=' in keyvalpair: key, value = keyvalpair.split('=', 1) @@ -1388,7 +1386,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N if key_lowercase == "metadata_block_picture" and self._load_image: if DEBUG: print('Found Vorbis Image', key, value[:64]) - fieldname, fieldvalue = _Flac._parse_image(io.BytesIO(base64.b64decode(value))) + fieldname, fieldvalue = _Flac._parse_image(BytesIO(b64decode(value))) self.images._set_field(fieldname, fieldvalue) else: if DEBUG: @@ -1410,13 +1408,13 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: previous_page = b'' # contains data from previous (continuing) pages header_data = fh.read(27) # read ogg page header while len(header_data) == 27: - header = struct.unpack('<4sBBqIIiB', header_data) + header = unpack('<4sBBqIIiB', header_data) # https://xiph.org/ogg/doc/framing.html oggs, version, _flags, pos, _serial, _pageseq, _crc, segments = header self._max_samplenum = max(self._max_samplenum, pos) if oggs != b'OggS' or version != 0: raise ParseError('Invalid OGG header') - segsizes = struct.unpack('B' * segments, fh.read(segments)) + segsizes = unpack('B' * segments, fh.read(segments)) total = 0 for segsize in segsizes: # read all segments total += segsize @@ -1466,18 +1464,18 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _parse_tag(self, fh: BinaryIO) -> None: # see: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html # and: https://en.wikipedia.org/wiki/WAV - riff, _size, fformat = struct.unpack('4sI4s', fh.read(12)) + riff, _size, fformat = unpack('4sI4s', fh.read(12)) if riff != b'RIFF' or fformat != b'WAVE': raise ParseError('Invalid WAV header') if self._parse_duration: self.bitdepth = 16 # assume 16bit depth (CD quality) chunk_header = fh.read(8) while len(chunk_header) == 8: - subchunkid, subchunksize = struct.unpack('4sI', chunk_header) + subchunkid, subchunksize = unpack('4sI', chunk_header) subchunksize += subchunksize % 2 # IFF chunks are padded to an even number of bytes if subchunkid == b'fmt ' and self._parse_duration: - _, channels, samplerate = struct.unpack('HHI', fh.read(8)) - _, _, bitdepth = struct.unpack(' None: elif subchunkid == b'LIST' and self._parse_tags: is_info = fh.read(4) # check INFO header if is_info != b'INFO': # jump over non-INFO sections - fh.seek(subchunksize - 4, os.SEEK_CUR) + fh.seek(subchunksize - 4, SEEK_CUR) else: - sub_fh = io.BytesIO(fh.read(subchunksize - 4)) + sub_fh = BytesIO(fh.read(subchunksize - 4)) field = sub_fh.read(4) while len(field) == 4: - data_length = struct.unpack('I', sub_fh.read(4))[0] + data_length = unpack('I', sub_fh.read(4))[0] data_length += data_length % 2 # IFF chunks are padded to an even size data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte fieldname = self._RIFF_MAPPING.get(field) @@ -1541,7 +1539,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: id3 = None header = fh.read(4) if header[:3] == b'ID3': # parse ID3 header if it exists - fh.seek(-4, os.SEEK_CUR) + fh.seek(-4, SEEK_CUR) id3 = _ID3() id3._filehandler = fh id3._parse_tags = self._parse_tags @@ -1553,7 +1551,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data) == 4: - meta_header = struct.unpack('B3B', header_data) + meta_header = unpack('B3B', header_data) block_type = meta_header[0] & 0x7f is_last_block = meta_header[0] & 0x80 size = self._bytes_to_int(meta_header[1:4]) @@ -1562,7 +1560,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: stream_info_header = fh.read(size) if len(stream_info_header) < 34: # invalid streaminfo break - header_values = struct.unpack('HH3s3s8B16s', stream_info_header) + header_values = unpack('HH3s3s8B16s', stream_info_header) # From the xiph documentation: # py | # ---------------------------------------------- @@ -1576,8 +1574,8 @@ def _parse_tag(self, fh: BinaryIO) -> None: # | <36> Total samples in stream. # 16s| <128> MD5 signature # min_blk, max_blk, min_frm, max_frm = header[0:4] - # min_frm = self._bytes_to_int(struct.unpack('3B', min_frm)) - # max_frm = self._bytes_to_int(struct.unpack('3B', max_frm)) + # min_frm = self._bytes_to_int(unpack('3B', min_frm)) + # max_frm = self._bytes_to_int(unpack('3B', max_frm)) # channels--. bits total samples # |----- samplerate -----| |-||----| |---------~ ~----| # 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 @@ -1616,11 +1614,11 @@ def _parse_tag(self, fh: BinaryIO) -> None: @classmethod def _parse_image(cls, fh: BinaryIO) -> tuple[str, Image]: # https://xiph.org/flac/format.html#metadata_block_picture - pic_type, mime_type_len = struct.unpack('>2I', fh.read(8)) + pic_type, mime_type_len = unpack('>2I', fh.read(8)) mime_type = fh.read(mime_type_len).decode('utf-8', 'replace') - description_len = struct.unpack('>I', fh.read(4))[0] + description_len = unpack('>I', fh.read(4))[0] description = fh.read(description_len).decode('utf-8', 'replace') - _width, _height, _depth, _colors, pic_len = struct.unpack('>5I', fh.read(20)) + _width, _height, _depth, _colors, pic_len = unpack('>5I', fh.read(20)) return _ID3._create_tag_image(fh.read(pic_len), pic_type, mime_type, description) @@ -1719,7 +1717,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: value_type = self._bytes_to_int_le(fh.read(2)) value_len = self._bytes_to_int_le(fh.read(2)) if value_type == 1: - fh.seek(value_len, os.SEEK_CUR) # skip byte values + fh.seek(value_len, SEEK_CUR) # skip byte values continue field_name = self._ASF_MAPPING.get(name) # try to get normalized field name if field_name is None: # custom field @@ -1734,20 +1732,20 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif field_value: self._set_field(field_name, field_value) elif object_id == self._ASF_FILE_PROPERTY_OBJECT and self._parse_duration: - fh.seek(40, os.SEEK_CUR) + fh.seek(40, SEEK_CUR) play_duration = self._bytes_to_int_le(fh.read(8)) / 10000000 - fh.seek(8, os.SEEK_CUR) + fh.seek(8, SEEK_CUR) preroll = self._bytes_to_int_le(fh.read(8)) / 1000 - fh.seek(16, os.SEEK_CUR) + fh.seek(16, SEEK_CUR) # According to the specification, we need to subtract the preroll from play_duration # to get the actual duration of the file self.duration = max(play_duration - preroll, 0.0) elif object_id == self._ASF_STREAM_PROPERTIES_OBJECT and self._parse_duration: stream_type = fh.read(16) - fh.seek(24, os.SEEK_CUR) # skip irrelevant fields + fh.seek(24, SEEK_CUR) # skip irrelevant fields type_specific_data_length = self._bytes_to_int_le(fh.read(4)) error_correction_data_length = self._bytes_to_int_le(fh.read(4)) - fh.seek(6, os.SEEK_CUR) # skip irrelevant fields + fh.seek(6, SEEK_CUR) # skip irrelevant fields already_read = 0 if stream_type == self._STREAM_TYPE_ASF_AUDIO_MEDIA: codec_id_format_tag = self._bytes_to_int_le(fh.read(2)) @@ -1755,15 +1753,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: self.samplerate = self._bytes_to_int_le(fh.read(4)) avg_bytes_per_second = self._bytes_to_int_le(fh.read(4)) self.bitrate = avg_bytes_per_second * 8 / 1000 - fh.seek(2, os.SEEK_CUR) # skip irrelevant field + fh.seek(2, SEEK_CUR) # skip irrelevant field bits_per_sample = self._bytes_to_int_le(fh.read(2)) if codec_id_format_tag == 355: # lossless self.bitdepth = bits_per_sample already_read = 16 - fh.seek(type_specific_data_length - already_read, os.SEEK_CUR) - fh.seek(error_correction_data_length, os.SEEK_CUR) + fh.seek(type_specific_data_length - already_read, SEEK_CUR) + fh.seek(error_correction_data_length, SEEK_CUR) else: - fh.seek(object_size - 24, os.SEEK_CUR) # read over onknown object ids + fh.seek(object_size - 24, SEEK_CUR) # read over onknown object ids self._tags_parsed = True @@ -1810,21 +1808,21 @@ class _Aiff(TinyTag): } def _parse_tag(self, fh: BinaryIO) -> None: - chunk_id, _size, form = struct.unpack('>4sI4s', fh.read(12)) + chunk_id, _size, form = unpack('>4sI4s', fh.read(12)) if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): raise ParseError('Invalid AIFF header') chunk_header = fh.read(8) while len(chunk_header) == 8: - sub_chunk_id, sub_chunk_size = struct.unpack('>4sI', chunk_header) + sub_chunk_id, sub_chunk_size = unpack('>4sI', chunk_header) sub_chunk_size += sub_chunk_size % 2 # IFF chunks are padded to an even number of bytes if sub_chunk_id in self._AIFF_MAPPING and self._parse_tags: value = self._unpad(fh.read(sub_chunk_size).decode('utf-8', 'replace')) self._set_field(self._AIFF_MAPPING[sub_chunk_id], value) elif sub_chunk_id == b'COMM' and self._parse_duration: - channels, num_frames, bitdepth = struct.unpack('>hLh', fh.read(8)) + channels, num_frames, bitdepth = unpack('>hLh', fh.read(8)) self.channels, self.bitdepth = channels, bitdepth try: - exponent, mantissa = struct.unpack('>HQ', fh.read(10)) # Extended precision + exponent, mantissa = unpack('>HQ', fh.read(10)) # Extended precision samplerate = int(mantissa * (2 ** (exponent - 0x3FFF - 63))) duration = num_frames / samplerate bitrate = samplerate * channels * bitdepth / 1000 From aa1b72c295389c4ae257989e54834fd17d57236a Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 17 Oct 2024 21:32:42 +0300 Subject: [PATCH 220/305] Use Flit build backend (#221) Setuptools is deemphasized nowadays, no longer installed by default in a bunch of places since Python 3.12, and we don't need the legacy baggage it brings. Flit is a small build backend that fits our needs. --- .github/workflows/tests.yml | 9 +- .gitignore | 26 +--- MANIFEST.in | 10 -- pyproject.toml | 113 ++++++++++++++++++ release.py | 23 ---- setup.cfg | 92 -------------- setup.py | 6 - {data => tinytag}/icons/icon.svg | 0 {data => tinytag}/icons/icon_bg.png | Bin {data => tinytag}/icons/icon_bg.svg | 0 {data => tinytag}/icons/icon_bg_round.png | Bin {data => tinytag}/icons/icon_bg_round.svg | 0 ... download the test samples from github.txt | 3 - tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 5 - 15 files changed, 121 insertions(+), 168 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100755 release.py delete mode 100644 setup.cfg delete mode 100755 setup.py rename {data => tinytag}/icons/icon.svg (100%) rename {data => tinytag}/icons/icon_bg.png (100%) rename {data => tinytag}/icons/icon_bg.svg (100%) rename {data => tinytag}/icons/icon_bg_round.png (100%) rename {data => tinytag}/icons/icon_bg_round.svg (100%) delete mode 100644 tinytag/tests/please download the test samples from github.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 87fe55b..a4720b6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,7 @@ jobs: matrix: os: [ubuntu-latest, macos-13, windows-latest] python: [ - '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-beta.1', 'pypy-3.7', 'pypy-3.8', + '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10', 'graalpy-24' ] exclude: @@ -30,10 +30,10 @@ jobs: cache-dependency-path: setup.py - name: Install dependencies - run: python -m pip install build setuptools .[tests] + run: python -m pip install build flit .[tests] - name: PEP 8 style checks - run: python -m pycodestyle + run: python -m pycodestyle --max-line-length=100 . - name: Linting if: matrix.python != 'graalpy-24' @@ -53,6 +53,9 @@ jobs: - name: Build package run: python -m build + - name: Build package without isolation + run: python -m build --no-isolation + - name: Coveralls uses: coverallsapp/github-action@master with: diff --git a/.gitignore b/.gitignore index aaa81a0..0db6c42 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,15 @@ *.py[cod] -# C extensions -*.so - # Packages -*.egg *.egg-info dist build -eggs -parts -bin -var -sdist -develop-eggs -.installed.cfg -lib -lib64 __pycache__ -# Installer logs -pip-log.txt - # Unit test / coverage reports .coverage -.tox -nosetests.xml -test-results/ .mypy_cache - -# Translations -*.mo +.pytest_cache # Mr Developer .mr.developer.cfg @@ -44,6 +23,3 @@ venv # Visual Studio Code .vscode - -# custom test samples -tinytag/tests/custom_samples diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4ed75df..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,10 +0,0 @@ -include *.md -include *.png -include *.svg -include *.toml -include *.txt -include LICENSE - -global-exclude *.pyc -global-exclude .gitignore -global-exclude .DS_Store diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1066e11 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,113 @@ +[build-system] +requires = ["flit_core>=3.2"] +build-backend = "flit_core.buildapi" + +[project] +name = "tinytag" +version = "2.0.0" +description = "Read audio file metadata" +authors = [ + {name = "Tom Wallroth", email = "tomwallroth@gmail.com"}, + {name = "Mat (mathiascode)"} +] +keywords = [ + "metadata", + "audio", + "music", + "mp3", + "m4a", + "wav", + "ogg", + "opus", + "flac", + "wma", + "aiff" +] +classifiers = [ + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Topic :: Internet :: WWW/HTTP", + "Topic :: Multimedia", + "Topic :: Multimedia :: Sound/Audio", + "Topic :: Multimedia :: Sound/Audio :: Analysis", + "Typing :: Typed" +] +license = {file = "LICENSE"} +readme = "README.md" +requires-python = ">=3.7" + +[project.urls] +Homepage = "https://github.com/tinytag/tinytag" + +[project.optional-dependencies] +tests = [ + "pycodestyle", + "pylint", + "pytest", + "pytest-cov" +] + +[tool.flit.sdist] +exclude = [ + ".gitignore", + ".github/", + "tinytag/icons/", + "tinytag/tests/" +] + +[tool.pylint.master] +disable = [ + "bad-plugin-value", + "invalid-name", + "protected-access", + "too-many-lines", + "too-many-arguments", + "too-many-boolean-expressions", + "too-many-branches", + "too-many-instance-attributes", + "too-many-locals", + "too-many-nested-blocks", + "too-many-statements", + "too-few-public-methods", + "too-many-positional-arguments", + "unknown-option-value" +] +enable = [ + "consider-using-augmented-assign", + "use-implicit-booleaness-not-comparison-to-string" +] +load-plugins = [ + "pylint.extensions.bad_builtin", + "pylint.extensions.check_elif", + "pylint.extensions.code_style", + "pylint.extensions.comparison_placement", + "pylint.extensions.consider_refactoring_into_while_condition", + "pylint.extensions.emptystring", + "pylint.extensions.for_any_all", + "pylint.extensions.dict_init_mutate", + "pylint.extensions.dunder", + "pylint.extensions.eq_without_hash", + "pylint.extensions.overlapping_exceptions", + "pylint.extensions.private_import", + "pylint.extensions.set_membership", + "pylint.extensions.typing" +] +ignore-paths = "build" +py-version = "3.7" + +[tool.pylint.format] +max-line-length = 100 + +[tool.mypy] +strict = true diff --git a/release.py b/release.py deleted file mode 100755 index 2b92db6..0000000 --- a/release.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -# pylint: disable=missing-function-docstring,missing-module-docstring - -import subprocess -import sys - - -def release_package() -> None: - # Run tests - subprocess.check_call([sys.executable, "-m", "pycodestyle"]) - subprocess.check_call([sys.executable, "-m", "pylint", "--recursive=y", "."]) - subprocess.check_call([sys.executable, "-m", "mypy", "-p", "tinytag"]) - subprocess.check_call([sys.executable, "-m", "pytest"]) - - # Prepare source distribution and wheel - subprocess.check_call([sys.executable, "-m", "build", "--sdist", "--wheel"]) - - # Upload package to PyPi - subprocess.check_call([sys.executable, "-m", "twine", "upload", "dist/*"]) - - -if __name__ == "__main__": - release_package() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 15d2849..0000000 --- a/setup.cfg +++ /dev/null @@ -1,92 +0,0 @@ -[metadata] -name = tinytag -version = 2.0.0 -author = Tom Wallroth -author_email = tomwallroth@gmail.com -url = https://github.com/tinytag/tinytag -description = Read audio file metadata -keywords = - metadata - audio - music - mp3 - m4a - wav - ogg - opus - flac - wma - aiff -classifiers = - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - License :: OSI Approved :: MIT License - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Intended Audience :: Developers - Operating System :: OS Independent - Topic :: Internet :: WWW/HTTP - Topic :: Multimedia - Topic :: Multimedia :: Sound/Audio - Topic :: Multimedia :: Sound/Audio :: Analysis - Typing :: Typed -license = MIT -license_files = LICENSE -long_description = file: README.md -long_description_content_type = text/markdown - -[options] -python_requires = >= 3.7 -include_package_data = True -packages = find: -install_requires = - -[options.extras_require] -tests = - pycodestyle - pylint - pytest - pytest-cov - -[options.entry_points] -console_scripts = - -[pycodestyle] -max-line-length = 100 -exclude = build/ - -[pylint.master] -disable = - bad-plugin-value -enable = - consider-using-augmented-assign, - use-implicit-booleaness-not-comparison-to-string -load-plugins = - pylint.extensions.bad_builtin, - pylint.extensions.check_elif, - pylint.extensions.code_style, - pylint.extensions.comparison_placement, - pylint.extensions.consider_refactoring_into_while_condition, - pylint.extensions.emptystring, - pylint.extensions.for_any_all, - pylint.extensions.dict_init_mutate, - pylint.extensions.dunder, - pylint.extensions.eq_without_hash, - pylint.extensions.overlapping_exceptions, - pylint.extensions.private_import, - pylint.extensions.set_membership, - pylint.extensions.typing -ignore-paths = build -py-version = 3.7 - -[pylint.format] -max-line-length = 100 - -[mypy] -strict = True diff --git a/setup.py b/setup.py deleted file mode 100755 index 0155151..0000000 --- a/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python -# pylint: disable=missing-module-docstring - -from setuptools import setup - -setup() diff --git a/data/icons/icon.svg b/tinytag/icons/icon.svg similarity index 100% rename from data/icons/icon.svg rename to tinytag/icons/icon.svg diff --git a/data/icons/icon_bg.png b/tinytag/icons/icon_bg.png similarity index 100% rename from data/icons/icon_bg.png rename to tinytag/icons/icon_bg.png diff --git a/data/icons/icon_bg.svg b/tinytag/icons/icon_bg.svg similarity index 100% rename from data/icons/icon_bg.svg rename to tinytag/icons/icon_bg.svg diff --git a/data/icons/icon_bg_round.png b/tinytag/icons/icon_bg_round.png similarity index 100% rename from data/icons/icon_bg_round.png rename to tinytag/icons/icon_bg_round.png diff --git a/data/icons/icon_bg_round.svg b/tinytag/icons/icon_bg_round.svg similarity index 100% rename from data/icons/icon_bg_round.svg rename to tinytag/icons/icon_bg_round.svg diff --git a/tinytag/tests/please download the test samples from github.txt b/tinytag/tests/please download the test samples from github.txt deleted file mode 100644 index c1e3678..0000000 --- a/tinytag/tests/please download the test samples from github.txt +++ /dev/null @@ -1,3 +0,0 @@ -If you installed tinytag from pip, it is missing the test samples needed to -run the test suite. please download the sources (including the test samples) -from github to run the test suite. See: https://github.com/tinytag/tinytag diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 7470de6..eeb3130 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1,4 +1,4 @@ -# pylint: disable=missing-function-docstring,missing-module-docstring,protected-access +# pylint: disable=missing-function-docstring,missing-module-docstring from __future__ import annotations diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 8846ed3..b9de190 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -29,11 +29,6 @@ """Audio file metadata reader""" -# pylint: disable=invalid-name,protected-access -# pylint: disable=too-many-lines,too-many-arguments,too-many-boolean-expressions -# pylint: disable=too-many-branches,too-many-instance-attributes,too-many-locals -# pylint: disable=too-many-nested-blocks,too-many-statements,too-few-public-methods - from __future__ import annotations from base64 import b64decode From fd24daf72864d5d1f2e014b1b805763938624dfd Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 17 Oct 2024 23:15:48 +0300 Subject: [PATCH 221/305] Various cleanups related to unpacking values (#222) --- tinytag/tinytag.py | 143 +++++++++++++++++++++------------------------ 1 file changed, 68 insertions(+), 75 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b9de190..5ab11b5 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -33,7 +33,6 @@ from __future__ import annotations from base64 import b64decode from collections.abc import Callable, Iterator -from functools import reduce from io import BytesIO from os import PathLike, SEEK_CUR, SEEK_END, SEEK_SET, environ, fsdecode from re import match @@ -302,16 +301,6 @@ def _update(self, other: TinyTag) -> None: elif value is not None: self._set_field(key, value) - @staticmethod - def _bytes_to_int_le(b: bytes) -> int: - fmt = {1: ' int: - return reduce(lambda accu, elem: (accu << 8) + elem, b, 0) - @staticmethod def _unpad(s: str) -> str: # strings in mp3 and asf *may* be terminated with a zero byte at the end @@ -514,7 +503,7 @@ def _make_number_parser( cls, fieldname1: str, fieldname2: str) -> Callable[[bytes], dict[str, int]]: def _(data_atom: bytes) -> dict[str, int]: number_data = data_atom[8:14] - numbers = unpack('>HHH', number_data) + numbers = unpack('>3H', number_data) # for some reason the first number is always irrelevant. return {fieldname1: numbers[1], fieldname2: numbers[2]} return _ @@ -568,8 +557,7 @@ def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: datafh = BytesIO(data) datafh.seek(16, SEEK_CUR) # jump over version and flags channels = unpack('>H', datafh.read(2))[0] - datafh.seek(2, SEEK_CUR) # jump over bit_depth - datafh.seek(2, SEEK_CUR) # jump over QT compr id & pkt size + datafh.seek(4, SEEK_CUR) # jump over bit_depth, QT compr id & pkt size sr = unpack('>I', datafh.read(4))[0] # ES Description Atom @@ -997,7 +985,7 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: extended = (header[3] & 0x40) > 0 # experimental = (header[3] & 0x20) > 0 # footer = (header[3] & 0x10) > 0 - size = self._calc_size(header[4:8], 7) + size = self._unsynchsafe(header[4:8]) self._bytepos_after_id3v2 = size return size, extended, major @@ -1007,8 +995,7 @@ def _parse_id3v2(self, fh: BinaryIO) -> None: end_pos = fh.tell() + size parsed_size = 0 if extended: # just read over the extended header. - size_bytes = unpack('4B', fh.read(6)[0:4]) - extd_size = self._calc_size(size_bytes, 7) + extd_size = self._unsynchsafe(unpack('4B', fh.read(6)[:4])) fh.seek(extd_size - 6, SEEK_CUR) # jump over extended_header while parsed_size < size: frame_size = self._parse_frame(fh, id3version=major) @@ -1094,14 +1081,18 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 frame_header_size = 6 if id3version == 2 else 10 frame_size_bytes = 3 if id3version == 2 else 4 - binformat = '3s3B' if id3version == 2 else '4s4B2B' - bits_per_byte = 7 if id3version == 4 else 8 # only id3v2.4 is synchsafe + is_synchsafe_int = id3version == 4 frame_header_data = fh.read(frame_header_size) if len(frame_header_data) != frame_header_size: return 0 - frame = unpack(binformat, frame_header_data) - frame_id = self._decode_string(frame[0]) - frame_size = self._calc_size(frame[1:1 + frame_size_bytes], bits_per_byte) + frame_id = self._decode_string(frame_header_data[:frame_size_bytes]) + frame_size: int + if frame_size_bytes == 3: + frame_size = unpack('>I', b'\x00' + frame_header_data[3:6])[0] + elif is_synchsafe_int: + frame_size = self._unsynchsafe(unpack('4B', frame_header_data[4:8])) + else: + frame_size = unpack('>I', frame_header_data[4:8])[0] if DEBUG: print(f'Found id3 Frame {frame_id} at {fh.tell()}-{fh.tell() + frame_size} ' f'of {self.filesize}') @@ -1152,7 +1143,7 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: elif frame_id in self._IMAGE_FRAME_IDS: if self._load_image: # See section 4.14: http://id3.org/id3v2.4.0-frames - encoding = content[0:1] + encoding = content[:1] if frame_id == 'PIC': # ID3 v2.2: imgformat = self._decode_string(content[1:4]).lower() mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat) @@ -1200,7 +1191,7 @@ def _decode_string(self, bytestr: bytes, language: bool = False) -> str: bytestr = bytestr[3:] # remove language bytestr = bytestr.lstrip(b'\x00') # strip optional additional null bytes # read byte order mark to determine endianness - encoding = 'UTF-16be' if bytestr[0:2] == b'\xfe\xff' else 'UTF-16le' + encoding = 'UTF-16be' if bytestr[:2] == b'\xfe\xff' else 'UTF-16le' # strip the bom if it exists if bytestr[:2] in {b'\xfe\xff', b'\xff\xfe'}: bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] @@ -1221,9 +1212,8 @@ def _decode_string(self, bytestr: bytes, language: bool = False) -> str: return self._unpad(bytestr.decode(encoding, 'replace')) @staticmethod - def _calc_size(bytestr: tuple[int, ...], bits_per_byte: int) -> int: - # length of some mp3 header fields is described by 7 or 8-bit-bytes - return reduce(lambda accu, elem: (accu << bits_per_byte) + elem, bytestr, 0) + def _unsynchsafe(intarr: tuple[int, ...]) -> int: + return (intarr[0] << 21) + (intarr[1] << 14) + (intarr[2] << 7) + intarr[3] class _Ogg(TinyTag): @@ -1303,29 +1293,30 @@ def _parse_tag(self, fh: BinaryIO) -> None: check_speex_second_packet = False for packet in self._parse_pages(fh): walker = BytesIO(packet) - if packet[0:7] == b"\x01vorbis": + if packet[:7] == b"\x01vorbis": if self._parse_duration: (self.channels, self.samplerate, _max_bitrate, bitrate, _min_bitrate) = unpack(" None: if block_type == _Flac.METADATA_VORBIS_COMMENT: self._parse_vorbis_comment(walker) check_flac_second_packet = False - elif packet[0:8] == b'Speex ': + elif packet[:8] == b'Speex ': # https://speex.org/docs/manual/speex-manual/node8.html if self._parse_duration: walker.seek(36, SEEK_CUR) # jump over header name and irrelevant fields - (self.samplerate, _, _, self.channels, - self.bitrate) = unpack("<5i", walker.read(20)) + self.samplerate = unpack(" None: # for spec, see https://xiph.org/flac/ogg_mapping.html header_data = fh.read(4) while len(header_data) == 4: - meta_header = unpack('B3B', header_data) - block_type = meta_header[0] & 0x7f - is_last_block = meta_header[0] & 0x80 - size = self._bytes_to_int(meta_header[1:4]) + block_type = header_data[0] & 0x7f + is_last_block = header_data[0] & 0x80 + size = unpack('>I', b'\x00' + header_data[1:4])[0] # http://xiph.org/flac/format.html#metadata_block_streaminfo if block_type == self.METADATA_STREAMINFO and self._parse_duration: - stream_info_header = fh.read(size) - if len(stream_info_header) < 34: # invalid streaminfo + info_header = fh.read(size) + if len(info_header) < 34: # invalid streaminfo break - header_values = unpack('HH3s3s8B16s', stream_info_header) # From the xiph documentation: # py | # ---------------------------------------------- @@ -1568,22 +1558,19 @@ def _parse_tag(self, fh: BinaryIO) -> None: # | <5> (bits per sample)-1. # | <36> Total samples in stream. # 16s| <128> MD5 signature - # min_blk, max_blk, min_frm, max_frm = header[0:4] - # min_frm = self._bytes_to_int(unpack('3B', min_frm)) - # max_frm = self._bytes_to_int(unpack('3B', max_frm)) # channels--. bits total samples # |----- samplerate -----| |-||----| |---------~ ~----| # 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 # #---4---# #---5---# #---6---# #---7---# #--8-~ ~-12-# - self.samplerate = self._bytes_to_int(header_values[4:7]) >> 4 - self.channels = ((header_values[6] >> 1) & 0x07) + 1 + self.samplerate = samplerate = unpack('>I', b'\x00' + info_header[10:13])[0] >> 4 + self.channels = ((info_header[12] >> 1) & 0x07) + 1 self.bitdepth = ( - ((header_values[6] & 1) << 4) + ((header_values[7] & 0xF0) >> 4) + 1) - total_sample_bytes = ((header_values[7] & 0x0F),) + header_values[8:12] - total_samples = self._bytes_to_int(total_sample_bytes) - self.duration = total_samples / self.samplerate - if self.duration > 0: - self.bitrate = self.filesize / self.duration * 8 / 1000 + ((info_header[12] & 1) << 4) + ((info_header[13] & 0xF0) >> 4) + 1) + total_sample_bytes = bytes([info_header[13] & 0x0F]) + info_header[14:18] + total_samples = unpack('>Q', b'\x00\x00\x00' + total_sample_bytes)[0] + self.duration = duration = total_samples / samplerate + if duration > 0: + self.bitrate = self.filesize / duration * 8 / 1000 elif block_type == self.METADATA_VORBIS_COMMENT and self._parse_tags: oggtag = _Ogg() oggtag._filehandler = fh @@ -1609,7 +1596,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: @classmethod def _parse_image(cls, fh: BinaryIO) -> tuple[str, Image]: # https://xiph.org/flac/format.html#metadata_block_picture - pic_type, mime_type_len = unpack('>2I', fh.read(8)) + pic_type, mime_type_len = unpack('>II', fh.read(8)) mime_type = fh.read(mime_type_len).decode('utf-8', 'replace') description_len = unpack('>I', fh.read(4))[0] description = fh.read(description_len).decode('utf-8', 'replace') @@ -1669,8 +1656,18 @@ def _decode_ext_desc(self, value_type: int, value: bytes) -> str | None: """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" if value_type == 0: # Unicode string return self._decode_string(value) + fmt = None if 1 < value_type < 6: # DWORD / QWORD / WORD - return str(self._bytes_to_int_le(value)) + if len(value) == 1: + fmt = ' None: @@ -1682,15 +1679,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: raise ParseError('Invalid WMA header') while True: object_id = fh.read(16) - object_size = self._bytes_to_int_le(fh.read(8)) + object_size_data = fh.read(8) + if not object_size_data: + break + object_size = unpack(' self.filesize: break # invalid object, stop parsing. if object_id == self._ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: - title_length = self._bytes_to_int_le(fh.read(2)) - author_length = self._bytes_to_int_le(fh.read(2)) - copyright_length = self._bytes_to_int_le(fh.read(2)) - description_length = self._bytes_to_int_le(fh.read(2)) - rating_length = self._bytes_to_int_le(fh.read(2)) + (title_length, author_length, copyright_length, description_length, + rating_length) = unpack('<5H', fh.read(10)) data_blocks = { 'title': title_length, 'artist': author_length, @@ -1705,12 +1702,11 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._set_field(i_field_name, value) elif object_id == self._ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 - descriptor_count = self._bytes_to_int_le(fh.read(2)) + descriptor_count = unpack(' None: self._set_field(field_name, field_value) elif object_id == self._ASF_FILE_PROPERTY_OBJECT and self._parse_duration: fh.seek(40, SEEK_CUR) - play_duration = self._bytes_to_int_le(fh.read(8)) / 10000000 + play_duration = unpack(' None: elif object_id == self._ASF_STREAM_PROPERTIES_OBJECT and self._parse_duration: stream_type = fh.read(16) fh.seek(24, SEEK_CUR) # skip irrelevant fields - type_specific_data_length = self._bytes_to_int_le(fh.read(4)) - error_correction_data_length = self._bytes_to_int_le(fh.read(4)) + type_specific_data_length, error_correction_data_length = unpack(' Date: Fri, 18 Oct 2024 00:21:51 +0300 Subject: [PATCH 222/305] Make less common imports lazy --- tinytag/__main__.py | 4 ++-- tinytag/tinytag.py | 26 ++++++++++++++++++-------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 7f62910..6a26c01 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -2,8 +2,6 @@ from __future__ import annotations -import csv -import json import sys from io import StringIO @@ -50,10 +48,12 @@ def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> b data = tag.as_dict() del data['images'] if formatting == 'json': + import json # pylint: disable=import-outside-toplevel print(json.dumps(data, ensure_ascii=False, indent=2)) return header_printed if formatting not in {'csv', 'tsv', 'tabularcsv'}: return header_printed + import csv # pylint: disable=import-outside-toplevel for field, value in data.items(): if isinstance(value, str): data[field] = value.replace('\x00', ';') # use a more friendly separator for output diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5ab11b5..c4b89a3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -31,15 +31,21 @@ from __future__ import annotations -from base64 import b64decode -from collections.abc import Callable, Iterator +from binascii import a2b_base64 from io import BytesIO from os import PathLike, SEEK_CUR, SEEK_END, SEEK_SET, environ, fsdecode -from re import match from struct import unpack from sys import stderr -from typing import Any, BinaryIO, Dict, List -from warnings import warn + +# Lazy imports for type checking +if False: # pylint: disable=using-constant-test + from collections.abc import Callable, Iterator + from typing import Any, BinaryIO, Dict, List + + _Extra = Dict[str, List[str]] + _ImagesExtra = Dict[str, List["Image"]] +else: + _Extra = _ImagesExtra = dict DEBUG = bool(environ.get('TINYTAG_DEBUG')) # some of the parsers can print debug info @@ -120,6 +126,7 @@ def get(cls, if file_obj is None: raise ValueError('Either filename or file_obj argument is required') if 'ignore_errors' in kwargs: + from warnings import warn # pylint: disable=import-outside-toplevel warn('ignore_errors argument is obsolete, and will be removed in a future ' '2.x release', DeprecationWarning, stacklevel=2) try: @@ -193,6 +200,7 @@ def _get_parser_for_filename( @classmethod def _get_parser_for_file_handle(cls, fh: BinaryIO) -> type[TinyTag] | None: # https://en.wikipedia.org/wiki/List_of_file_signatures + from re import match # pylint: disable=import-outside-toplevel if cls._magic_bytes_mapping is None: cls._magic_bytes_mapping = { b'^ID3': _ID3, @@ -308,6 +316,7 @@ def _unpad(s: str) -> str: def get_image(self) -> bytes | None: """Deprecated, use images.any instead.""" + from warnings import warn # pylint: disable=import-outside-toplevel warn('get_image() is deprecated, and will be removed in a future 2.x release. ' 'Use images.any instead.', DeprecationWarning, stacklevel=2) image = self.images.any @@ -316,11 +325,12 @@ def get_image(self) -> bytes | None: @property def audio_offset(self) -> None: """Obsolete.""" + from warnings import warn # pylint: disable=import-outside-toplevel warn('audio_offset attribute is obsolete, and will be ' 'removed in a future 2.x release', DeprecationWarning, stacklevel=2) -class Extra(Dict[str, List[str]]): +class Extra(_Extra): """A dictionary containing additional fields of an audio file.""" @@ -397,7 +407,7 @@ def _update(self, other: Images) -> None: self._set_field(key, image) -class ImagesExtra(Dict[str, List["Image"]]): +class ImagesExtra(_ImagesExtra): """A dictionary containing additional images embedded in an audio file.""" @@ -1373,7 +1383,7 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N if key_lowercase == "metadata_block_picture" and self._load_image: if DEBUG: print('Found Vorbis Image', key, value[:64]) - fieldname, fieldvalue = _Flac._parse_image(BytesIO(b64decode(value))) + fieldname, fieldvalue = _Flac._parse_image(BytesIO(a2b_base64(value))) self.images._set_field(fieldname, fieldvalue) else: if DEBUG: From 9b3026d09b0b153fb237cbdf57d44d923cad5771 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 00:24:00 +0300 Subject: [PATCH 223/305] CI: stop testing GraalPy It's too slow. --- .github/workflows/tests.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4720b6..0f31038 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,12 +10,9 @@ jobs: matrix: os: [ubuntu-latest, macos-13, windows-latest] python: [ - '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', 'pypy-3.7', 'pypy-3.8', - 'pypy-3.9', 'pypy-3.10', 'graalpy-24' + '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', + 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10' ] - exclude: - - os: windows-latest - python: 'graalpy-24' steps: - name: Checkout code uses: actions/checkout@v4 @@ -36,7 +33,6 @@ jobs: run: python -m pycodestyle --max-line-length=100 . - name: Linting - if: matrix.python != 'graalpy-24' run: python -m pylint --recursive=y . - name: Typing From f4a72f813c240e10a6d894350e557bf055dd17d8 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 00:25:12 +0300 Subject: [PATCH 224/305] CI: use macos-latest --- .github/workflows/tests.yml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0f31038..bd05570 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,11 +8,25 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-13, windows-latest] + os: [ubuntu-latest, macos-latest, windows-latest] python: [ - '3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', - 'pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10' + '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', + 'pypy-3.8', 'pypy-3.9', 'pypy-3.10' ] + include: + - os: ubuntu-22.04 + python: 3.7 + - os: ubuntu-22.04 + python: pypy-3.7 + - os: macos-13 + python: 3.7 + - os: macos-13 + python: pypy-3.7 + - os: windows-latest + python: 3.7 + - os: windows-latest + python: pypy-3.7 + steps: - name: Checkout code uses: actions/checkout@v4 From 49992c4569b62d25e0c0df78938a71dd41dbd013 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 01:02:11 +0300 Subject: [PATCH 225/305] CI: use up-to-date version of Coveralls action The master branch is no longer updated. --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd05570..7709a90 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -67,7 +67,7 @@ jobs: run: python -m build --no-isolation - name: Coveralls - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@v2 with: flag-name: run-${{ join(matrix.*, '-') }} parallel: true @@ -78,6 +78,6 @@ jobs: runs-on: ubuntu-latest steps: - name: Coveralls finished - uses: coverallsapp/github-action@master + uses: coverallsapp/github-action@v2 with: parallel-finished: true From ae722bfb0dea7b05920efad90857fedb04833ab0 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 01:16:00 +0300 Subject: [PATCH 226/305] Remove pytest-cov in favor of coverage.py --- .github/workflows/tests.yml | 6 ++++-- pyproject.toml | 3 +-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7709a90..a30bc72 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,7 +41,7 @@ jobs: cache-dependency-path: setup.py - name: Install dependencies - run: python -m pip install build flit .[tests] + run: python -m pip install build coverage flit .[tests] - name: PEP 8 style checks run: python -m pycodestyle --max-line-length=100 . @@ -56,7 +56,9 @@ jobs: python -m mypy -p tinytag - name: Unit tests - run: python -m pytest --cov --cov-report=lcov:coverage/lcov.info + run: | + coverage run -m pytest + coverage lcov -o coverage/lcov.info env: TINYTAG_DEBUG: true diff --git a/pyproject.toml b/pyproject.toml index 1066e11..ae69615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,8 +54,7 @@ Homepage = "https://github.com/tinytag/tinytag" tests = [ "pycodestyle", "pylint", - "pytest", - "pytest-cov" + "pytest" ] [tool.flit.sdist] From fc14b1c2ceab027c66236e604c7a74027a56d51c Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 03:36:55 +0300 Subject: [PATCH 227/305] Stop using regex module (#223) It's overkill, and this is the only case it's used. --- pyproject.toml | 1 + tinytag/tinytag.py | 42 ++++++++++++++++++------------------------ 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae69615..a2b5d1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ disable = [ "too-many-instance-attributes", "too-many-locals", "too-many-nested-blocks", + "too-many-return-statements", "too-many-statements", "too-few-public-methods", "too-many-positional-arguments", diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c4b89a3..c19dda0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -75,7 +75,6 @@ class TinyTag: ) _EXTRA_PREFIX = 'extra.' _file_extension_mapping: dict[tuple[str, ...], type[TinyTag]] | None = None - _magic_bytes_mapping: dict[bytes, type[TinyTag]] | None = None def __init__(self) -> None: self.filename: bytes | str | PathLike[Any] | None = None @@ -200,30 +199,25 @@ def _get_parser_for_filename( @classmethod def _get_parser_for_file_handle(cls, fh: BinaryIO) -> type[TinyTag] | None: # https://en.wikipedia.org/wiki/List_of_file_signatures - from re import match # pylint: disable=import-outside-toplevel - if cls._magic_bytes_mapping is None: - cls._magic_bytes_mapping = { - b'^ID3': _ID3, - b'^\xff\xfb': _ID3, - b'^OggS.........................FLAC': _Ogg, - b'^OggS........................Opus': _Ogg, - b'^OggS........................Speex': _Ogg, - b'^OggS.........................vorbis': _Ogg, - b'^RIFF....WAVE': _Wave, - b'^fLaC': _Flac, - b'^\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': _Wma, - b'....ftypM4A': _MP4, # https://www.file-recovery.com/m4a-signature-format.htm - b'....ftypaax': _MP4, # Audible proprietary M4A container - b'....ftypaaxc': _MP4, # Audible proprietary M4A container - b'\xff\xf1': _MP4, # https://www.garykessler.net/library/file_sigs.html - b'^FORM....AIFF': _Aiff, - b'^FORM....AIFC': _Aiff, - } - header = fh.read(max(len(sig) for sig in cls._magic_bytes_mapping)) + header = fh.read(35) fh.seek(0) - for magic, parser in cls._magic_bytes_mapping.items(): - if match(magic, header): - return parser + if header[:3] == b'ID3' or header[:2] == b'\xff\xfb': + return _ID3 + if header[:4] == b'fLaC': + return _Flac + if ((header[4:8] == b'ftyp' and header[8:11] in {b'M4A', b'M4B', b'aax'}) + or b'\xff\xf1' in header): + return _MP4 + if (header[:4] == b'OggS' + and (header[29:33] == b'FLAC' or header[29:35] == b'vorbis' + or header[28:32] == b'Opus' or header[29:34] == b'Speex')): + return _Ogg + if header[:4] == b'RIFF' and header[8:12] == b'WAVE': + return _Wave + if header[:16] == b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': + return _Wma + if header[:4] == b'FORM' and header[8:12] in {b'AIFF', b'AIFC'}: + return _Aiff return None @classmethod From 36d701aebe37df4b01de997a530e1dc50b7ed8ee Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 07:34:34 +0300 Subject: [PATCH 228/305] Avoid unnecessary work when unpacking values (#224) - Avoid some small reads/seeks on file handles - Avoid some unnecessary unpacking of values - Remove unnecessary BytesIO streams - Various cleanups --- tinytag/tests/test_all.py | 3 +- tinytag/tinytag.py | 281 +++++++++++++++++++------------------- 2 files changed, 143 insertions(+), 141 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index eeb3130..2384e20 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -83,6 +83,8 @@ 'artist': 'Paso a paso', 'album': 'S/T', 'disc_total': 0, 'year': '2003'}), ('samples/empty_file.mp3', {'extra': {}, 'filesize': 0}), + ('samples/incomplete.mp3', + {'extra': {}, 'filesize': 3}), ('samples/silence-44khz-56k-mono-1s.mp3', {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.0265261269342902, 'filesize': 7280, 'bitrate': 56.0}), @@ -708,7 +710,6 @@ def test_mp3_length_estimation() -> None: @pytest.mark.parametrize("path,cls", [ ('samples/silence-44-s-v1.mp3', _Flac), - ('samples/incomplete.mp3', _ID3), ('samples/flac1.5sStereo.flac', _Ogg), ('samples/flac1.5sStereo.flac', _Wave), ('samples/flac1.5sStereo.flac', _Wma), diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c19dda0..b63c266 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -444,20 +444,46 @@ class _Parser: 'barcode': 'extra.barcode', 'catalognumber': 'extra.catalog_number', } + _UNPACK_SIGNED_FORMATS = { + 1: '>b', + 2: '>h', + 4: '>i', + 8: '>q' + } + _UNPACK_UNSIGNED_FORMATS = { + 1: '>B', + 2: '>H', + 4: '>I', + 8: '>Q' + } + + @classmethod + def _unpack_utf_8_string(cls, value: bytes) -> str: + return value.decode('utf-8', 'replace') + + @classmethod + def _unpack_utf_16_string(cls, value: bytes) -> str: + return value.decode('utf-16', 'replace') + + @classmethod + def _unpack_shift_jis_string(cls, value: bytes) -> str: + return value.decode('s/jis', 'replace') + + @classmethod + def _unpack_jpeg_image(cls, data: bytes) -> Image: + return Image('front_cover', data, 'image/jpeg') + + @classmethod + def _unpack_png_image(cls, data: bytes) -> Image: + return Image('front_cover', data, 'image/png') @classmethod def _unpack_integer(cls, value: bytes, signed: bool = True) -> str: - value_length = len(value) - result = -1 - if value_length == 1: - result = unpack('>b' if signed else '>B', value)[0] - elif value_length == 2: - result = unpack('>h' if signed else '>H', value)[0] - elif value_length == 4: - result = unpack('>i' if signed else '>I', value)[0] - elif value_length == 8: - result = unpack('>q' if signed else '>Q', value)[0] - return str(result) + fmts = cls._UNPACK_SIGNED_FORMATS if signed else cls._UNPACK_UNSIGNED_FORMATS + value_len = len(value) + if value_len in fmts: + return str(unpack(fmts[value_len], value)[0]) + return "" @classmethod def _unpack_integer_unsigned(cls, value: bytes) -> str: @@ -472,18 +498,14 @@ def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | Image]: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 cls.atom_decoder_by_type = { # 0: 'reserved' - 1: lambda x: x.decode('utf-8', 'replace'), # UTF-8 - 2: lambda x: x.decode('utf-16', 'replace'), # UTF-16 - 3: lambda x: x.decode('s/jis', 'replace'), # S/JIS + 1: cls._unpack_utf_8_string, # UTF-8 + 2: cls._unpack_utf_16_string, # UTF-16 + 3: cls._unpack_shift_jis_string, # S/JIS # 16: duration in millis - 13: lambda x: Image('front_cover', x, 'image/jpeg'), # JPEG - 14: lambda x: Image('front_cover', x, 'image/png'), # PNG + 13: cls._unpack_jpeg_image, # JPEG + 14: cls._unpack_png_image, # PNG 21: cls._unpack_integer, # BE Signed int 22: cls._unpack_integer_unsigned, # BE Unsigned int - # 23: lambda x: unpack('>f', x)[0], # BE Float32 - # 24: lambda x: unpack('>d', x)[0], # BE Float64 - # 27: lambda x: x, # BMP - # 28: lambda x: x, # QuickTime Metadata atom 65: cls._unpack_integer, # 8-bit Signed int 66: cls._unpack_integer, # BE 16-bit Signed int 67: cls._unpack_integer, # BE 32-bit Signed int @@ -558,11 +580,11 @@ def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html # http://xhelmboyx.tripod.com/formats/mp4-layout.txt # http://sasperger.tistory.com/103 - datafh = BytesIO(data) - datafh.seek(16, SEEK_CUR) # jump over version and flags - channels = unpack('>H', datafh.read(2))[0] - datafh.seek(4, SEEK_CUR) # jump over bit_depth, QT compr id & pkt size - sr = unpack('>I', datafh.read(4))[0] + + # jump over version and flags + channels = unpack('>H', data[16:18])[0] + # jump over bit_depth, QT compr id & pkt size + sr = unpack('>I', data[22:26])[0] # ES Description Atom esds_atom_size = unpack('>I', data[28:32])[0] @@ -582,31 +604,23 @@ def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: @classmethod def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt - alac_atom_size = unpack('>I', data[28:32])[0] - alac_atom = BytesIO(data[36:36 + alac_atom_size]) - alac_atom.seek(9, SEEK_CUR) - bitdepth = unpack('b', alac_atom.read(1))[0] - alac_atom.seek(3, SEEK_CUR) - channels = unpack('b', alac_atom.read(1))[0] - alac_atom.seek(6, SEEK_CUR) - avg_br = unpack('>I', alac_atom.read(4))[0] / 1000 # kbit/s - sr = unpack('>I', alac_atom.read(4))[0] + bitdepth = data[45] + channels = data[49] + avg_br, sr = unpack('>II', data[56:64]) + avg_br /= 1000 # kbit/s return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} @classmethod def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # http://stackoverflow.com/a/3639993/1191373 - walker = BytesIO(data) - version = unpack('b', walker.read(1))[0] - walker.seek(3, SEEK_CUR) # jump over flags + version = data[0] + # jump over flags if version == 0: # uses 32 bit integers for timestamps - walker.seek(8, SEEK_CUR) # jump over create & mod times - time_scale = unpack('>I', walker.read(4))[0] - duration = unpack('>I', walker.read(4))[0] + # jump over create & mod times + time_scale, duration = unpack('>II', data[12:20]) else: # version == 1: # uses 64 bit integers for timestamps - walker.seek(16, SEEK_CUR) # jump over create & mod times - time_scale = unpack('>I', walker.read(4))[0] - duration = unpack('>q', walker.read(8))[0] + # jump over create & mod times + time_scale, duration = unpack('>Iq', data[20:28]) return {'duration': duration / time_scale} # The parser tree: Each key is an atom name which is traversed if existing. @@ -903,7 +917,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: if frames: self.bitrate = bitrate_accu / frames break # EOF - _sync, conf, bitrate_freq, rest = unpack('BBBB', header) + _sync, conf, bitrate_freq, rest = unpack('4B', header) br_id = (bitrate_freq >> 4) & 0x0F # biterate id sr_id = (bitrate_freq >> 2) & 0x03 # sample rate id padding = 1 if bitrate_freq & 0x02 > 0 else 0 @@ -978,18 +992,17 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: size = major = 0 extended = False # for info on the specs, see: http://id3.org/Developer%20Information - header = unpack('3sBBB4B', fh.read(10)) - tag = header[0].decode('ISO-8859-1', 'replace') + header = fh.read(10) # check if there is an ID3v2 tag at the beginning of the file - if tag == 'ID3': - major, _rev = header[1:3] + if header[:3] == b'ID3': + major = header[3] if DEBUG: print(f'Found id3 v2.{major}') - # unsync = (header[3] & 0x80) > 0 - extended = (header[3] & 0x40) > 0 - # experimental = (header[3] & 0x20) > 0 - # footer = (header[3] & 0x10) > 0 - size = self._unsynchsafe(header[4:8]) + # unsync = (header[5] & 0x80) > 0 + extended = (header[5] & 0x40) > 0 + # experimental = (header[5] & 0x20) > 0 + # footer = (header[5] & 0x10) > 0 + size = self._unsynchsafe(unpack('4B', header[6:10])) self._bytepos_after_id3v2 = size return size, extended, major @@ -1296,32 +1309,31 @@ def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): - walker = BytesIO(packet) if packet[:7] == b"\x01vorbis": if self._parse_duration: - (self.channels, self.samplerate, _max_bitrate, bitrate, - _min_bitrate) = unpack(" None: elif check_flac_second_packet: # second packet contains FLAC metadata block if self._parse_tags: - meta_header = unpack('B3B', walker.read(4)) + walker = BytesIO(packet) + meta_header = walker.read(4) block_type = meta_header[0] & 0x7f if block_type == _Flac.METADATA_VORBIS_COMMENT: self._parse_vorbis_comment(walker) @@ -1341,13 +1354,12 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif packet[:8] == b'Speex ': # https://speex.org/docs/manual/speex-manual/node8.html if self._parse_duration: - walker.seek(36, SEEK_CUR) # jump over header name and irrelevant fields - self.samplerate = unpack(" Iterator[bytes]: previous_page = b'' # contains data from previous (continuing) pages header_data = fh.read(27) # read ogg page header while len(header_data) == 27: - header = unpack('<4sBBqIIiB', header_data) + version = header_data[4] + if header_data[:4] != b'OggS' or version != 0: + raise ParseError('Invalid OGG header') # https://xiph.org/ogg/doc/framing.html - oggs, version, _flags, pos, _serial, _pageseq, _crc, segments = header + pos = unpack(' None: def _parse_tag(self, fh: BinaryIO) -> None: # see: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html # and: https://en.wikipedia.org/wiki/WAV - riff, _size, fformat = unpack('4sI4s', fh.read(12)) - if riff != b'RIFF' or fformat != b'WAVE': + header = fh.read(12) + if header[:4] != b'RIFF' or header[8:12] != b'WAVE': raise ParseError('Invalid WAV header') if self._parse_duration: self.bitdepth = 16 # assume 16bit depth (CD quality) chunk_header = fh.read(8) while len(chunk_header) == 8: - subchunkid, subchunksize = unpack('4sI', chunk_header) + subchunkid = chunk_header[:4] + subchunksize = unpack('I', chunk_header[4:8])[0] subchunksize += subchunksize % 2 # IFF chunks are padded to an even number of bytes if subchunkid == b'fmt ' and self._parse_duration: - _, channels, samplerate = unpack('HHI', fh.read(8)) - _, _, bitdepth = unpack(' 0: - fh.seek(remaining_size, 1) # skip remaining data in chunk elif subchunkid == b'data' and self._parse_duration: if (self.channels is not None and self.samplerate is not None and self.bitdepth is not None): @@ -1487,12 +1499,12 @@ def _parse_tag(self, fh: BinaryIO) -> None: if is_info != b'INFO': # jump over non-INFO sections fh.seek(subchunksize - 4, SEEK_CUR) else: - sub_fh = BytesIO(fh.read(subchunksize - 4)) - field = sub_fh.read(4) + walker = BytesIO(fh.read(subchunksize - 4)) + field = walker.read(4) while len(field) == 4: - data_length = unpack('I', sub_fh.read(4))[0] + data_length = unpack('I', walker.read(4))[0] data_length += data_length % 2 # IFF chunks are padded to an even size - data = sub_fh.read(data_length).split(b'\x00', 1)[0] # strip zero-byte + data = walker.read(data_length).split(b'\x00', 1)[0] # strip zero-byte fieldname = self._RIFF_MAPPING.get(field) if fieldname: value = data.decode('utf-8', 'replace') @@ -1501,7 +1513,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._set_field(fieldname, int(value)) else: self._set_field(fieldname, value) - field = sub_fh.read(4) + field = walker.read(4) elif subchunkid in {b'id3 ', b'ID3 '} and self._parse_tags: id3 = _ID3() id3._filehandler = fh @@ -1588,7 +1600,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: else: if DEBUG: print('Unknown FLAC block type', block_type) - fh.seek(size, 1) # seek over this block + fh.seek(size, SEEK_CUR) # seek over this block if is_last_block: break @@ -1604,7 +1616,8 @@ def _parse_image(cls, fh: BinaryIO) -> tuple[str, Image]: mime_type = fh.read(mime_type_len).decode('utf-8', 'replace') description_len = unpack('>I', fh.read(4))[0] description = fh.read(description_len).decode('utf-8', 'replace') - _width, _height, _depth, _colors, pic_len = unpack('>5I', fh.read(20)) + fh.seek(16, SEEK_CUR) # jump over width, height, depth, colors + pic_len = unpack('>I', fh.read(4))[0] return _ID3._create_tag_image(fh.read(pic_len), pic_type, mime_type, description) @@ -1641,6 +1654,12 @@ class _Wma(TinyTag): 'WM/Barcode': 'extra.barcode', 'WM/CatalogNo': 'extra.catalog_number', } + _ASF_UNPACK_FORMATS = { + 1: ' None: if not self._tags_parsed: self._parse_tag(fh) - def _decode_string(self, bytestring: bytes) -> str: - return self._unpad(bytestring.decode('utf-16', 'replace')) - - def _decode_ext_desc(self, value_type: int, value: bytes) -> str | None: - """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" - if value_type == 0: # Unicode string - return self._decode_string(value) - fmt = None - if 1 < value_type < 6: # DWORD / QWORD / WORD - if len(value) == 1: - fmt = ' None: header = fh.read(30) # http://www.garykessler.net/library/file_sigs.html @@ -1690,8 +1688,9 @@ def _parse_tag(self, fh: BinaryIO) -> None: if object_size == 0 or object_size > self.filesize: break # invalid object, stop parsing. if object_id == self._ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: + walker = BytesIO(fh.read(object_size - 24)) (title_length, author_length, copyright_length, description_length, - rating_length) = unpack('<5H', fh.read(10)) + rating_length) = unpack('<5H', walker.read(10)) data_blocks = { 'title': title_length, 'artist': author_length, @@ -1700,62 +1699,63 @@ def _parse_tag(self, fh: BinaryIO) -> None: '_rating': rating_length, } for i_field_name, length in data_blocks.items(): - bytestring = fh.read(length) - value = self._decode_string(bytestring) + value = self._unpad(walker.read(length).decode('utf-16', 'replace')) if not i_field_name.startswith('_') and value: self._set_field(i_field_name, value) elif object_id == self._ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 - descriptor_count = unpack(' str: + """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" + if value_type == 0: # Unicode string + return cls._unpad(value.decode('utf-16', 'replace')) + if 1 < value_type < 6: # DWORD / QWORD / WORD + value_len = len(value) + if value_len in cls._ASF_UNPACK_FORMATS: + return str(unpack(cls._ASF_UNPACK_FORMATS[value_len], value)[0]) + return "" + class _Aiff(TinyTag): # @@ -1800,35 +1800,36 @@ class _Aiff(TinyTag): } def _parse_tag(self, fh: BinaryIO) -> None: - chunk_id, _size, form = unpack('>4sI4s', fh.read(12)) - if chunk_id != b'FORM' or form not in (b'AIFC', b'AIFF'): + header = fh.read(12) + if header[:4] != b'FORM' or header[8:12] not in {b'AIFC', b'AIFF'}: raise ParseError('Invalid AIFF header') chunk_header = fh.read(8) while len(chunk_header) == 8: - sub_chunk_id, sub_chunk_size = unpack('>4sI', chunk_header) + sub_chunk_id = chunk_header[:4] + sub_chunk_size = unpack('>I', chunk_header[4:8])[0] sub_chunk_size += sub_chunk_size % 2 # IFF chunks are padded to an even number of bytes if sub_chunk_id in self._AIFF_MAPPING and self._parse_tags: value = self._unpad(fh.read(sub_chunk_size).decode('utf-8', 'replace')) self._set_field(self._AIFF_MAPPING[sub_chunk_id], value) elif sub_chunk_id == b'COMM' and self._parse_duration: - channels, num_frames, bitdepth = unpack('>hLh', fh.read(8)) + chunk = fh.read(sub_chunk_size) + channels, num_frames, bitdepth = unpack('>hLh', chunk[:8]) self.channels, self.bitdepth = channels, bitdepth try: - exponent, mantissa = unpack('>HQ', fh.read(10)) # Extended precision + exponent, mantissa = unpack('>HQ', chunk[8:18]) # Extended precision samplerate = int(mantissa * (2 ** (exponent - 0x3FFF - 63))) duration = num_frames / samplerate bitrate = samplerate * channels * bitdepth / 1000 self.samplerate, self.duration, self.bitrate = samplerate, duration, bitrate except OverflowError: pass - fh.seek(sub_chunk_size - 18, 1) # skip remaining data in chunk elif sub_chunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: id3 = _ID3() id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) else: # some other chunk, just skip the data - fh.seek(sub_chunk_size, 1) + fh.seek(sub_chunk_size, SEEK_CUR) chunk_header = fh.read(8) self._tags_parsed = True From 1f69743dd659bdde4c0839afd24cedec08160f6d Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 17:31:24 +0300 Subject: [PATCH 229/305] Limit line length to 79 characters (#225) In order to ensure compatibility with as many style checkers as possible out of the box. --- .github/workflows/tests.yml | 3 +- pyproject.toml | 22 +- tinytag/__main__.py | 34 +- tinytag/tests/test_all.py | 1983 +++++++++++++++++++++++++---------- tinytag/tests/test_cli.py | 49 +- tinytag/tinytag.py | 736 +++++++------ 6 files changed, 1896 insertions(+), 931 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a30bc72..33cd154 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -38,13 +38,12 @@ jobs: with: python-version: ${{ matrix.python }} cache: 'pip' - cache-dependency-path: setup.py - name: Install dependencies run: python -m pip install build coverage flit .[tests] - name: PEP 8 style checks - run: python -m pycodestyle --max-line-length=100 . + run: python -m pycodestyle . - name: Linting run: python -m pylint --recursive=y . diff --git a/pyproject.toml b/pyproject.toml index a2b5d1f..e525742 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,25 +67,22 @@ exclude = [ [tool.pylint.master] disable = [ - "bad-plugin-value", "invalid-name", - "protected-access", - "too-many-lines", "too-many-arguments", "too-many-boolean-expressions", "too-many-branches", "too-many-instance-attributes", + "too-many-lines", "too-many-locals", + "too-many-positional-arguments", "too-many-nested-blocks", "too-many-return-statements", "too-many-statements", "too-few-public-methods", - "too-many-positional-arguments", "unknown-option-value" ] enable = [ - "consider-using-augmented-assign", - "use-implicit-booleaness-not-comparison-to-string" + "consider-using-augmented-assign" ] load-plugins = [ "pylint.extensions.bad_builtin", @@ -93,21 +90,22 @@ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.comparison_placement", "pylint.extensions.consider_refactoring_into_while_condition", - "pylint.extensions.emptystring", - "pylint.extensions.for_any_all", + "pylint.extensions.consider_ternary_expression", "pylint.extensions.dict_init_mutate", + "pylint.extensions.docstyle", "pylint.extensions.dunder", + "pylint.extensions.empty_comment", "pylint.extensions.eq_without_hash", + "pylint.extensions.for_any_all", + "pylint.extensions.no_self_use", "pylint.extensions.overlapping_exceptions", "pylint.extensions.private_import", + "pylint.extensions.redefined_loop_name", + "pylint.extensions.redefined_variable_type", "pylint.extensions.set_membership", "pylint.extensions.typing" ] -ignore-paths = "build" py-version = "3.7" -[tool.pylint.format] -max-line-length = 100 - [tool.mypy] strict = true diff --git a/tinytag/__main__.py b/tinytag/__main__.py index 6a26c01..a186c3f 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -44,23 +44,24 @@ def _pop_switch(name: str) -> bool: return False -def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> bool: +def _print_tag(tag: TinyTag, fmt: str, header_printed: bool = False) -> bool: data = tag.as_dict() del data['images'] - if formatting == 'json': + if fmt == 'json': import json # pylint: disable=import-outside-toplevel print(json.dumps(data, ensure_ascii=False, indent=2)) return header_printed - if formatting not in {'csv', 'tsv', 'tabularcsv'}: + if fmt not in {'csv', 'tsv', 'tabularcsv'}: return header_printed import csv # pylint: disable=import-outside-toplevel for field, value in data.items(): if isinstance(value, str): - data[field] = value.replace('\x00', ';') # use a more friendly separator for output + # use a more friendly separator for output + data[field] = value.replace('\x00', ';') csv_file = StringIO() - delimiter = '\t' if formatting == 'tsv' else ',' + delimiter = '\t' if fmt == 'tsv' else ',' writer = csv.writer(csv_file, delimiter=delimiter, lineterminator='\n') - if formatting == 'tabularcsv': + if fmt == 'tabularcsv': if not header_printed: writer.writerow(data.keys()) header_printed = True @@ -75,8 +76,8 @@ def _print_tag(tag: TinyTag, formatting: str, header_printed: bool = False) -> b def _run() -> int: header_printed = False - save_image_path = _pop_param('--save-image', None) or _pop_param('-i', None) - formatting = (_pop_param('--format', None) or _pop_param('-f', None)) or 'json' + image_path = _pop_param('--save-image', None) or _pop_param('-i', None) + fmt = (_pop_param('--format', None) or _pop_param('-f', None)) or 'json' skip_unsupported = _pop_switch('--skip-unsupported') or _pop_switch('-s') filenames = sys.argv[1:] display_help = not filenames or _pop_switch('--help') or _pop_switch('-h') @@ -85,21 +86,22 @@ def _run() -> int: return 0 for i, filename in enumerate(filenames): - if skip_unsupported and not (TinyTag.is_supported(filename) and isfile(filename)): + if (skip_unsupported + and not (TinyTag.is_supported(filename) and isfile(filename))): continue try: - tag = TinyTag.get(filename, image=save_image_path is not None) - if save_image_path: + tag = TinyTag.get(filename, image=image_path is not None) + if image_path: # allow for saving the image of multiple files - actual_save_image_path = save_image_path + actual_image_path = image_path if len(filenames) > 1: - actual_save_image_path, ext = splitext(actual_save_image_path) - actual_save_image_path += f'{i:05d}{ext}' + actual_image_path, ext = splitext(actual_image_path) + actual_image_path += f'{i:05d}{ext}' image = tag.images.any if image is not None: - with open(actual_save_image_path, 'wb') as file_handle: + with open(actual_image_path, 'wb') as file_handle: file_handle.write(image.data) - header_printed = _print_tag(tag, formatting, header_printed) + header_printed = _print_tag(tag, fmt, header_printed) except (OSError, TinyTagException) as exc: sys.stderr.write(f'{filename}: {exc}\n') return 1 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 2384e20..de1d43f 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -17,544 +17,1354 @@ testfiles = dict([ - # MP3 - ('samples/vbri.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, - 'duration': 0.47020408163265304, 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', - 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, - 'filesize': 8192, 'genre': 'Dance', - 'comment': 'Ripped by THSLIVE', 'bitrate': 125.33333333333333}), - ('samples/cbr.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.48866995073891617, - 'album': 'I Can Walk On Water I Can Fly', 'year': '2007', - 'title': 'I Can Walk On Water I Can Fly', 'artist': 'Basshunter', 'track': 1, - 'filesize': 8186, 'bitrate': 128.0, 'genre': 'Dance', - 'comment': 'Ripped by THSLIVE'}), - # the output of the lame encoder was 185.4 bitrate, but this is good enough for now - ('samples/vbr_xing_header.mp3', - {'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, - 'duration': 3.944489795918367, 'filesize': 91731}), - ('samples/vbr_xing_header_2channel.mp3', - {'extra': {'encoder_settings': ['LAME 32bits version 3.99.5 (http://lame.sf.net)'], - 'tlen': ['249976']}, - 'filesize': 2000, 'album': "The Harpers' Masque", - 'artist': 'Knodel and Valencia', 'bitrate': 46.276128290848305, - 'channels': 2, 'duration': 250.04408163265308, 'samplerate': 22050, - 'title': 'Lochaber No More', 'year': '1992'}), - ('samples/id3v22-test.mp3', - {'extra': {'encoded_by': ['iTunes v4.6'], - 'itunnorm': [' 0000044E 00000061 00009B67 000044C3 00022478 00022182 ' - '00007FCC 00007E5C 0002245E 0002214E'], - 'itunes_cddb_1': ['9D09130B+174405+11+150+14097+27391+43983+65786+84877+' - '99399+113226+132452+146426+163829'], - 'itunes_cddb_tracknumber': ['3']}, - 'channels': 2, 'samplerate': 44100, 'track_total': 11, 'duration': 0.13836297152858082, - 'album': 'Hymns for the Exiled', 'year': '2004', 'title': 'cosmic american', - 'artist': 'Anais Mitchell', 'track': 3, 'filesize': 5120, - 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com'}), - ('samples/silence-44-s-v1.mp3', - {'extra': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', - 'duration': 3.738712956446946, 'album': 'Quod Libet Test Data', 'year': '2004', - 'title': 'Silence', 'artist': 'piman', 'track': 2, 'filesize': 15070, - 'bitrate': 32.0}), - ('samples/id3v1-latin1.mp3', - {'extra': {}, 'genre': 'Rock', - 'album': 'The Young Americans', 'title': 'Play Dead', 'filesize': 256, 'track': 12, - 'artist': 'Björk', 'year': '1993', 'comment': ' '}), - ('samples/UTF16.mp3', - {'extra': {'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], - 'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'], - 'musicbrainz album artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], - 'musicbrainz disc id': ['p.5xoyYRtCVFe2gt0mfTfsXrO9U-'], - 'musicip puid': ['6ff97581-1c73-fc05-b4e4-a4ccee12ec84'], 'asin': ['B003KVNV4S'], - 'musicbrainz album status': ['Official'], 'musicbrainz album type': ['Album'], - 'musicbrainz album release country': ['United States'], - 'ufid': ['http://musicbrainz.org\x00cf639964-eabb-4c40-9673-c2117e456ea5'], - 'publisher': ['4AD'], 'tdat': ['1105'], - 'wxxx': ['WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet'], - 'media': ['Digital'], 'tlen': ['203733'], - 'encoder_settings': ['LAME 32bits version 3.98.4 (http://www.mp3dev.org/)']}, - 'track_total': 11, 'track': 7, 'artist': 'The National', - 'year': '2010', 'album': 'High Violet', 'title': 'Lemonworld', 'filesize': 20480, - 'genre': 'Indie', 'comment': 'Track 7'}), - ('samples/utf-8-id3v2.mp3', - {'extra': {}, 'genre': 'Acustico', - 'track_total': 21, 'track': 1, 'filesize': 2119, 'title': 'Gran día', - 'artist': 'Paso a paso', 'album': 'S/T', 'disc_total': 0, 'year': '2003'}), - ('samples/empty_file.mp3', - {'extra': {}, 'filesize': 0}), - ('samples/incomplete.mp3', - {'extra': {}, 'filesize': 3}), - ('samples/silence-44khz-56k-mono-1s.mp3', - {'extra': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.0265261269342902, - 'filesize': 7280, 'bitrate': 56.0}), - ('samples/silence-22khz-mono-1s.mp3', - {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, - 'bitrate': 32.0, 'duration': 1.0438932496075353}), - ('samples/id3v24-long-title.mp3', - {'extra': - {'copyright': ['2013 Marathon Artists under exclsuive license from Courtney Barnett']}, - 'track': 1, 'disc_total': 1, 'composer': 'Courtney Barnett', - 'album': 'The Double EP: A Sea of Split Peas', 'filesize': 10000, - 'track_total': 12, 'genre': 'AlternRock', - 'title': 'Out of the Woodwork', 'artist': 'Courtney Barnett', - 'albumartist': 'Courtney Barnett', 'disc': 1, - 'comment': 'Amazon.com Song ID: 240853806', 'year': '2013'}), - ('samples/utf16be.mp3', - {'extra': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, 'album': 'party mix', - 'artist': 'The B52s', 'genre': 'Rock', 'year': '1981'}), - ('samples/id3v22_image.mp3', - {'extra': {'rva': ['\x10'], 'bpm': ['131']}, 'title': 'Kids (MGMT Cover) ', - 'filesize': 35924, - 'album': 'winniecooper.net ', 'artist': 'The Kooks', 'year': '2008', - 'genre': '.'}), - ('samples/id3v22.TCO.genre.mp3', - {'extra': {'encoded_by': ['iTunes 11.0.4'], - 'itunnorm': [' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A 0000902E ' - '00008D36 00020882 000321D6'], - 'itunsmpb': [' 00000000 00000210 000007B9 00000000008FB737 00000000 008242F1 ' - '00000000 00000000 00000000 00000000 00000000 00000000'], - 'itunpgap': ['0']}, - 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', - 'genre': 'Pop', 'title': 'Applause'}), - ('samples/id3_comment_utf_16_with_bom.mp3', - {'extra': {'copyright': ['(c) 2008 nin'], 'isrc': ['USTC40852229'], 'bpm': ['60'], - 'url': ['www.nin.com'], 'encoded_by': ['LAME 3.97']}, - 'filesize': 19980, - 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', 'artist': 'Nine Inch Nails', - 'disc': 1, 'disc_total': 2, 'title': '1 Ghosts I', 'track': 1, 'track_total': 36, - 'year': '2008', 'comment': '3/4 time'}), - ('samples/id3_comment_utf_16_double_bom.mp3', - {'extra': {'label': ['Unclear']}, 'filesize': 512, 'album': 'The Embrace', - 'artist': 'Johannes Heil & D.Diggler', 'comment': 'Unclear', - 'title': 'The Embrace (Romano Alfieri Remix)', - 'year': '2012'}), - ('samples/id3_genre_id_out_of_bounds.mp3', - {'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', - 'genre': '(255)', 'title': '01 GREAT BIG WHITE WORLD', - 'year': '0'}), - ('samples/image-text-encoding.mp3', - {'extra': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, - 'title': 'image-encoding', 'bitrate': 32.0, - 'duration': 1.0438932496075353}), - ('samples/id3v1_does_not_overwrite_id3v2.mp3', - {'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', - 'artist': 'Blind Guardian', - 'extra': {'love rating': ['L'], 'publisher': ['Century Media'], 'popm': ['MusicBee\x00Ä']}, - 'genre': 'Power Metal', 'title': 'Time What Is Time', 'track': 1, - 'year': '1992'}), - ('samples/non_ascii_filename_äää.mp3', - {'extra': {'encoder_settings': ['Lavf58.20.100']}, 'filesize': 80919, 'channels': 2, - 'duration': 5.067755102040817, 'samplerate': 44100, 'bitrate': 127.6701030927835}), - ('samples/chinese_id3.mp3', - {'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', - 'artist': 'ËÕÔÆ', 'bitrate': 128.0, 'channels': 2, - 'duration': 0.052244897959183675, 'genre': 'ÐÝÏÐÒôÀÖ', 'samplerate': 44100, - 'title': '½ÇÂäÖ®¸è', 'track': 1}), - ('samples/cut_off_titles.mp3', - {'extra': {'encoder_settings': ['Lavf54.29.104']}, 'filesize': 1000, 'album': 'ERB', - 'artist': 'Epic Rap Battles Of History', - 'bitrate': 192.0, 'channels': 2, 'duration': 0.052244897959183675, - 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky'}), - ('samples/id3_xxx_lang.mp3', - {'extra': {'script': ['Latn'], - 'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'], - 'musicbrainz album type': ['album'], - 'musicbrainz album artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'], - 'musicbrainz artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'], - 'barcode': ['724386668721'], - 'musicbrainz album id': ['38b555fe-24c7-37b3-ad1b-f6dea9f1aafa'], - 'musicbrainz release track id': ['7f7c31a5-0905-39ba-ba72-68db91d3b9da'], - 'catalog_number': ['7243 8 66687 2 1'], - 'musicbrainz release group id': ['0f21095a-e629-389c-981a-d9569e9673c9'], - 'musicbrainz album status': ['official'], - 'asin': ['B000641ZIQ'], 'musicbrainz album release country': ['US'], - 'isrc': ['USVI20400513'], 'lyrics': ['Don\'t fret, precious'], - 'replaygain_track_gain': ['-3.95 dB'], 'replaygain_track_peak': ['0.999969'], - 'replaygain_album_gain': ['-8.26 dB'], 'publisher': ['Virgin Records America'], - 'media': ['CD'], 'tso2': ['Perfect Circle, A'], - 'ufid': ['http://musicbrainz.org\x00d2b8f0e6-735a-42ee-adf0-7eca4e65cd72'], - 'tsop': ['Perfect Circle, A'], 'original_year': ['2004'], 'tdat': ['0211'], - 'ipls': ['producer\x00Billy Howerdel\x00producer\x00Maynard James Keenan' - '\x00engineer\x00Billy Howerdel\x00engineer\x00Critter']}, - 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', - 'artist': 'A Perfect Circle', 'composer': 'Billy Howerdel/Maynard James Keenan', - 'bitrate': 192.0, 'channels': 2, 'duration': 0.13198711063372717, 'genre': 'Rock', - 'samplerate': 44100, 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', - 'track': 10, 'comment': ' ', 'disc': 1, 'disc_total': 1, - 'track_total': 12, 'year': '2004'}), - ('samples/mp3/vbr/vbr8.mp3', - {'filesize': 9504, 'bitrate': 8.25, 'channels': 1, 'duration': 9.216, - 'extra': {}, 'samplerate': 8000}), - ('samples/mp3/vbr/vbr8stereo.mp3', - {'filesize': 9504, 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, - 'extra': {}, 'samplerate': 8000}), - ('samples/mp3/vbr/vbr11.mp3', - {'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 1, - 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), - ('samples/mp3/vbr/vbr11stereo.mp3', - {'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 2, - 'duration': 9.195102040816327, 'extra': {}, 'samplerate': 11025}), - ('samples/mp3/vbr/vbr16.mp3', - {'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 1, - 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), - ('samples/mp3/vbr/vbr16stereo.mp3', - {'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 2, - 'duration': 9.144, 'extra': {}, 'samplerate': 16000}), - ('samples/mp3/vbr/vbr22.mp3', - {'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 1, - 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), - ('samples/mp3/vbr/vbr22stereo.mp3', - {'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 2, - 'duration': 9.11673469387755, 'extra': {}, 'samplerate': 22050}), - ('samples/mp3/vbr/vbr32.mp3', - {'filesize': 37008, 'bitrate': 32.50592885375494, 'channels': 1, - 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), - ('samples/mp3/vbr/vbr32stereo.mp3', - {'filesize': 37008, 'bitrate': 32.50592885375494, 'channels': 2, - 'duration': 9.108, 'extra': {}, 'samplerate': 32000}), - ('samples/mp3/vbr/vbr44.mp3', - {'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 1, - 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), - ('samples/mp3/vbr/vbr44stereo.mp3', - {'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 2, - 'duration': 9.09061224489796, 'extra': {}, 'samplerate': 44100}), - ('samples/mp3/vbr/vbr48.mp3', - {'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 1, - 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), - ('samples/mp3/vbr/vbr48stereo.mp3', - {'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 2, - 'duration': 9.072, 'extra': {}, 'samplerate': 48000}), - ('samples/id3v24_genre_null_byte.mp3', - {'extra': {}, 'filesize': 256, 'album': '\u79d8\u5bc6', 'albumartist': 'aiko', - 'artist': 'aiko', 'disc': 1, 'genre': 'Pop', - 'title': '\u661f\u306e\u306a\u3044\u4e16\u754c', 'track': 10, 'year': '2008'}), - ('samples/vbr_xing_header_short.mp3', - {'filesize': 432, 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, - 'extra': {}, 'samplerate': 8000}), - ('samples/id3_multiple_artists.mp3', - {'filesize': 2007, 'bitrate': 57.39124999999999, 'channels': 1, - 'duration': 0.1306122448979592, - 'extra': {'artist': ['artist2', 'artist3', 'artist4', 'artist5', - 'artist6', 'artist7']}, - 'samplerate': 44100, 'artist': 'artist1', 'genre': 'something 1'}), - ('samples/id3_frames.mp3', - {'filesize': 27576, 'bitrate': 50.03636363636364, 'channels': 1, - 'duration': 3.96, 'samplerate': 16000, 'extra': {}}), - - # OGG - ('samples/empty.ogg', - {'extra': {}, 'duration': 3.684716553287982, - 'filesize': 4328, 'bitrate': 112.0, 'samplerate': 44100, 'channels': 2}), - ('samples/multipage-setup.ogg', - {'extra': {'transcoded': ['mp3;241'], 'replaygain_album_gain': ['-10.29 dB'], - 'replaygain_album_peak': ['1.50579047'], 'replaygain_track_peak': ['1.17979193'], - 'replaygain_track_gain': ['-10.02 dB']}, - 'genre': 'JRock', 'duration': 4.128798185941043, - 'album': 'Timeless', 'year': '2006', 'title': 'Burst', 'artist': 'UVERworld', 'track': 7, - 'filesize': 76983, 'bitrate': 160.0, - 'samplerate': 44100, 'comment': 'SRCL-6240', 'channels': 2}), - ('samples/test.ogg', - {'extra': {}, 'duration': 1.0, 'album': 'the boss', 'year': '2006', - 'title': 'the boss', 'artist': 'james brown', 'track': 1, - 'filesize': 7467, 'bitrate': 160.0, 'samplerate': 44100, 'channels': 2, - 'comment': 'hello!'}), - ('samples/corrupt_metadata.ogg', - {'extra': {}, 'filesize': 18648, 'bitrate': 80.0, - 'duration': 2.132358276643991, 'samplerate': 44100, 'channels': 1}), - ('samples/composer.ogg', - {'extra': {}, 'filesize': 4480, - 'album': 'An Album', 'artist': 'An Artist', 'composer': 'some composer', - 'bitrate': 112.0, 'duration': 3.684716553287982, 'channels': 2, - 'genre': 'Some Genre', 'samplerate': 44100, 'title': 'A Title', 'track': 2, - 'year': '2007', 'comment': 'A Comment'}), - ('samples/test.opus', - {'extra': {'encoder': ['Lavc57.24.102 libopus'], 'arrange': ['\u6771\u65b9'], - 'catalogid': ['ARCD0024'], 'discid': ['A212230D'], - 'event': ['\u4f8b\u5927\u796d5'], - 'lyricist': ['Haruka'], 'mastering': ['Hedonist'], - 'origin': ['\u6771\u65b9\u5e7b\u60f3\u90f7'], 'originaltitle': ['Bad Apple!!'], - 'performer': ['Masayoshi Minoshima'], 'vocal': ['nomico']}, - 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, - 'track': 1, 'disc': 1, 'title': 'Bad Apple!!', 'duration': 2.0, 'year': '2008.05.25', - 'filesize': 10000, 'artist': 'nomico', - 'album': 'Exserens - A selection of Alstroemeria Records', - 'comment': 'ARCD0018 - Lovelight', 'disc_total': 1, 'track_total': 13}), - ('samples/8khz_5s.opus', - {'extra': {'encoder': ['opusenc from opus-tools 0.2']}, 'filesize': 7251, 'channels': 1, - 'samplerate': 48000, 'duration': 5.0065}), - ('samples/test_flac.oga', - {'extra': {'copyright': ['test3'], 'isrc': ['test4'], 'lyrics': ['test7']}, - 'filesize': 9273, 'album': 'test2', 'artist': 'test6', 'comment': 'test5', - 'bitrate': 20.022488249118684, 'duration': 3.705034013605442, 'channels': 2, - 'genre': 'Acoustic', 'samplerate': 44100, 'bitdepth': 16, 'title': 'test1', 'track': 5, - 'year': '2023'}), - ('samples/test.spx', - {'extra': {}, 'filesize': 7921, 'channels': 1, 'samplerate': 16000, 'bitrate': -1, - 'duration': 2.1445625, 'artist': 'test1', 'title': 'test2', - 'comment': 'Encoded with Speex 1.2.0'}), - - # WAV - ('samples/test.wav', - {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, 'bitrate': 1411.2, - 'samplerate': 44100, 'bitdepth': 16}), - ('samples/test3sMono.wav', - {'extra': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, 'bitrate': 705.6, - 'samplerate': 44100, 'bitdepth': 16}), - ('samples/test-tagged.wav', - {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, 'album': 'thealbum', - 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'bitdepth': 16, 'title': 'thetitle', 'track': 66, 'comment': 'hello', - 'year': '2014'}), - ('samples/test-riff-tags.wav', - {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, - 'artist': 'theartisst', 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, - 'bitdepth': 16, 'title': 'thetitle', 'comment': 'hello', - 'year': '2014'}), - ('samples/silence-22khz-mono-1s.wav', - {'extra': {}, 'channels': 1, 'duration': 0.9991836734693877, 'filesize': 48160, - 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16}), - ('samples/id3_header_with_a_zero_byte.wav', - {'extra': {'title': ['Stacked']}, 'channels': 1, 'duration': 1.0, 'filesize': 44280, - 'bitrate': 352.8, 'samplerate': 22050, 'bitdepth': 16, 'artist': 'Purpley', - 'title': 'Test000', 'track': 17, - 'album': 'prototypes'}), - ('samples/adpcm.wav', - {'extra': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, - 'bitrate': 176.4, 'samplerate': 44100, 'bitdepth': 4, - 'artist': 'test artist', 'title': 'test title', 'track': 1, 'album': 'test album', - 'comment': 'test comment', 'genre': 'test genre', 'year': '1990'}), - ('samples/riff_extra_zero.wav', - {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20670, - 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, - 'artist': 'B.O.S.E.', 'title': 'Mission Bass', 'album': '808 Bass Express', - 'genre': 'Hip-Hop/Rap', 'year': '1996', 'track': 3}), - ('samples/riff_extra_zero_2.wav', - {'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20682, - 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, - 'artist': 'The Jimmy Castor Bunch', 'title': 'It\'s Just Begun', - 'album': 'The Perfect Beats, Vol. 4', 'genre': 'Pop Electronica', 'track': 7}), - ('samples/wav_invalid_track_number.wav', - {'extra': {}, 'filesize': 8908, 'bitrate': 705.6, - 'duration': 0.1, 'samplerate': 44100, 'channels': 1, - 'bitdepth': 16}), - ('samples/gsm_6_10.wav', - {'extra': {}, 'bitdepth': 1, 'bitrate': 44.1, 'channels': 1, - 'duration': 0.16507936507936508, 'filesize': 1246, 'samplerate': 44100, - 'album': 'album', 'artist': 'artist', 'title': 'track', 'track': 99, - 'year': '2010', 'comment': 'some comment here', 'genre': 'Bass'}), - - # FLAC - ('samples/flac1sMono.flac', - {'extra': {}, 'genre': 'Avantgarde', 'album': 'alb', 'year': '2014', - 'duration': 1.0, 'title': 'track', 'track': 23, 'artist': 'art', 'channels': 1, - 'filesize': 26632, 'bitrate': 213.056, 'samplerate': 44100, 'bitdepth': 16, - 'comment': 'hello'}), - ('samples/flac453sStereo.flac', - {'extra': {}, 'channels': 2, 'duration': 453.51473922902494, 'filesize': 84236, - 'bitrate': 1.4859230399999999, 'samplerate': 44100, 'bitdepth': 16}), - ('samples/flac1.5sStereo.flac', - {'extra': {}, 'channels': 2, 'album': 'alb', 'year': '2014', - 'duration': 1.4995238095238095, 'title': 'track', 'track': 23, 'artist': 'art', - 'filesize': 59868, 'bitrate': 319.39739599872973, 'genre': 'Avantgarde', - 'samplerate': 44100, 'bitdepth': 16, 'comment': 'hello'}), - ('samples/flac_application.flac', - {'extra': {'replaygain_track_peak': ['0.9976'], - 'musicbrainz_albumartistid': ['e5c7b94f-e264-473c-bb0f-37c85d4d5c70'], - 'musicbrainz_trackid': ['e65fb332-0c1e-4172-85e0-59cd37e5669e'], - 'replaygain_album_gain': ['-8.14 dB'], 'labelid': ['RTRADLP480'], - 'musicbrainz_albumid': ['359a91e9-3bb3-4b60-a823-8aaa4bad1e36'], - 'artistsort': ['Belle and Sebastian'], 'replaygain_track_gain': ['-8.08 dB'], - 'replaygain_album_peak': ['1.0000']}, - 'channels': 2, 'track_total': 11, - 'album': 'Belle and Sebastian Write About Love', 'year': '2010-10-11', 'duration': 273.64, - 'title': 'I Want the World to Stop', 'track': 4, 'artist': 'Belle and Sebastian', - 'filesize': 13000, 'bitrate': 0.38006139453296306, 'samplerate': 44100, 'bitdepth': 16}), - ('samples/no-tags.flac', - {'extra': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, - 'bitrate': 10.186943678613627, 'samplerate': 44100, 'bitdepth': 16}), - ('samples/variable-block.flac', - {'extra': {'discid': ['AA0B360B'], - 'japanese title': ['\u30a2\u30c3\u30d7\u30eb\u30b7\u30fc\u30c9 ' - '\u30aa\u30ea\u30b8\u30ca\u30eb\u30fb\u30b5\u30a6' - '\u30f3\u30c9\u30c8\u30e9\u30c3\u30af'], - 'organization': ['Sony Music Records (SRCP-371)'], - 'ripper': ['Exact Audio Copy 0.99pb5'], - 'replaygain_album_gain': ['-8.68 dB'], 'replaygain_album_peak': ['1.000000'], - 'replaygain_track_gain': ['-9.61 dB'], 'replaygain_track_peak': ['1.000000']}, - 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', - 'duration': 261.68, 'title': 'DIVE FOR YOU', 'track': 1, 'track_total': 11, - 'artist': 'Boom Boom Satellites', 'filesize': 10240, 'bitrate': 0.31305411189238763, - 'disc': 1, 'genre': 'Anime Soundtrack', 'samplerate': 44100, 'bitdepth': 16, - 'disc_total': 2, 'comment': 'Original Soundtrack', - 'composer': 'Boom Boom Satellites (Lyrics)'}), - ('samples/106-invalid-streaminfo.flac', - {'extra': {}, 'filesize': 4692}), - ('samples/106-short-picture-block-size.flac', - {'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, - 'duration': 3.684716553287982, 'samplerate': 44100, 'bitdepth': 16}), - ('samples/with_id3_header.flac', - {'extra': {'id': ['8591671910'], 'artist': ['群星'], 'album': [' '], - 'title': ['A 梦 哆啦 机器猫 短信铃声']}, 'filesize': 64837, - 'album': 'album', 'artist': 'artist', - 'title': 'title', 'track': 1, 'bitrate': 1143.72468, 'channels': 1, - 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'year': '2018', 'comment': 'comment', 'disc': 0}), - ('samples/with_padded_id3_header.flac', - {'extra': {}, 'filesize': 16070, 'album': 'album', 'artist': 'artist', - 'bitrate': 283.4748, 'channels': 1, - 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'title': 'title', 'track': 1, 'year': '2018', 'comment': 'comment'}), - ('samples/with_padded_id3_header2.flac', - {'extra': {'mcdi': ['2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00\x00\x00W5' - '\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00\x00\x00ä7\x00\x10' - '\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01\x8c\xa0'], - 'tlen': ['297666'], 'encoded_by': ['Exact Audio Copy (Sicherer Modus)'], - 'encoder_settings': ['flac.exe -T "artist=Unbekannter Künstler" -T ' - '"title=Track01" -T "album=Unbekannter Titel" -T ' - '"date=" -T "tracknumber=01" -T "genre=" -5'], - 'artist': ['Unbekannter Künstler'], 'album': ['Unbekannter Titel'], - 'title': ['Track01']}, - 'filesize': 19522, 'album': 'album', - 'artist': 'artist', 'bitrate': 344.36807999999996, - 'channels': 1, 'disc': 1, 'disc_total': 1, - 'duration': 0.45351473922902497, 'genre': 'genre', 'samplerate': 44100, 'bitdepth': 16, - 'title': 'title', 'track': 1, 'track_total': 5, 'year': '2018', - 'comment': 'comment'}), - ('samples/flac_with_image.flac', - {'extra': {}, 'filesize': 80000, - 'album': 'smilin´ in circles', 'artist': 'Andreas Kümmert', - 'bitrate': 7.6591670655816175, 'channels': 2, 'disc': 1, 'disc_total': 1, - 'duration': 83.56, 'genre': 'Blues', 'samplerate': 44100, 'bitdepth': 16, 'title': 'intro', - 'track': 1, 'track_total': 8}), - ('samples/flac_invalid_track_number.flac', - {'extra': {}, 'filesize': 235, 'bitrate': 18.8, 'channels': 1, - 'duration': 0.1, 'samplerate': 44100, 'bitdepth': 16}), - ('samples/flac_multiple_fields.flac', - {'extra': {'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'], - 'album': ['album 2'], 'url': ['https://example.com']}, - 'filesize': 266, 'album': 'album 1', 'artist': 'artist 1', - 'bitrate': 21.28, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1', - 'samplerate': 44100, 'bitdepth': 16}), - - # WMA - ('samples/test2.wma', - {'extra': {'_track': ['0'], - 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], - 'encodingtime': ['128861118183900000'], 'wmfsdkversion': ['11.0.5721.5145'], - 'wmfsdkneeded': ['0.0.0.0000'], 'isvbr': ['1'], 'peakvalue': ['30369'], - 'averagelevel': ['7291']}, - 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', - 'bitrate': 64.04, 'filesize': 5800, 'track': 1, 'albumartist': 'Foo Fighters', - 'artist': 'Foo Fighters', 'duration': 83.406, 'year': '1997', - 'genre': 'Alternative', 'composer': 'Foo Fighters', 'channels': 2}), - ('samples/lossless.wma', - {'extra': {}, - 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, 'bitdepth': 16, - 'duration': 43.133, 'channels': 2}), - ('samples/wma_invalid_track_number.wma', - {'extra': {'encoder_settings': ['Lavf60.16.100']}, - 'filesize': 3940, 'bitrate': 128.0, - 'duration': 2.1409999999999996, 'samplerate': 44100, 'channels': 1}), - - # ALAC/M4A/MP4 - ('samples/test.m4a', - {'extra': {'itunsmpb': [' 00000000 00000840 000001DC 0000000000D3E9E4 00000000 00000000 ' - '00000000 00000000 00000000 00000000 00000000 00000000'], - 'itunnorm': [' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28 00007E20 ' - '00007E90 00007BFD 00009293'], - 'itunes_cddb_ids': ['11++'], 'ufidhttp://www.cddb.com/id3/taginfo1.html': - ['3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1'], 'bpm': ['0'], - 'encoded_by': ['iTunes 10.5']}, - 'samplerate': 44100, 'duration': 314.97868480725623, 'bitrate': 256.0, 'channels': 2, - 'genre': 'Pop', 'year': '2011', 'title': 'Nothing', 'album': 'Only Our Hearts To Lose', - 'track_total': 11, 'track': 11, 'artist': 'Marian', 'filesize': 61432}), - ('samples/test2.m4a', - {'extra': {'copyright': ['℗ 1992 Ace Records'], - 'itunnorm': [' 00000371 00000481 00002E90 00002EA6 00000099 00000058 000073F3 ' - '0000768E 00000092 00000092'], - 'itunsmpb': [' 00000000 00000840 00000110 000000000070DEB0 00000000 00000000 ' - '00000000 00000000 00000000 00000000 00000000 00000000'], - 'itunmovi': ['\n\n\n\n\t' - 'asset-info\n\t\n\t\tflavor\n\t\t' - '2:256\n\t\n\n\n'], - 'tool': ['144255989988720642']}, - 'bitrate': 256.0, 'track': 1, - 'albumartist': "Millie Jackson - Get It Out 'cha System - 1978", - 'duration': 167.78739229024944, 'filesize': 223365, 'channels': 2, 'year': '1978', - 'artist': 'Millie Jackson', 'track_total': 9, 'disc_total': 1, 'genre': 'R&B/Soul', - 'album': "Get It Out 'cha System", 'samplerate': 44100, 'disc': 1, - 'title': 'Go Out and Get Some', - 'composer': "Millie Jackson - Get It Out 'cha System - 1978", - 'comment': "Millie Jackson - Get It Out 'cha System - 1978"}), - ('samples/iso8859_with_image.m4a', - {'extra': {}, 'artist': 'Major Lazer', 'filesize': 57017, - 'title': 'Cold Water (feat. Justin Bieber & M\uFFFD)', - 'album': 'Cold Water (feat. Justin Bieber & M\uFFFD) - Single', 'year': '2016', - 'samplerate': 44100, 'duration': 188.545, 'genre': 'Electronic;Music', - 'albumartist': 'Major Lazer', 'channels': 2, 'bitrate': 125.584, - 'comment': '? 2016 Mad Decent'}), - ('samples/alac_file.m4a', - {'extra': {'copyright': ['© Hyperion Records Ltd, London'], 'lyrics': ['Album notes:'], - 'upc': ['0034571177380']}, - 'artist': 'Howard Shelley', 'filesize': 20000, - 'composer': 'Clementi, Muzio (1752-1832)', - 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: Un poco andante', - 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', 'year': '2009', 'track': 14, - 'track_total': 27, 'disc': 1, 'disc_total': 1, 'samplerate': 44100, - 'duration': 166.62639455782312, 'genre': 'Classical', 'albumartist': 'Howard Shelley', - 'channels': 2, 'bitrate': 436.743, 'bitdepth': 16}), + ('samples/vbri.mp3', { + 'extra': {}, + 'channels': 2, + 'samplerate': 44100, + 'duration': 0.47020408163265304, + 'album': 'I Can Walk On Water I Can Fly', + 'year': '2007', + 'title': 'I Can Walk On Water I Can Fly', + 'artist': 'Basshunter', + 'track': 1, + 'filesize': 8192, + 'genre': 'Dance', + 'comment': 'Ripped by THSLIVE', + 'bitrate': 125.33333333333333, + }), + ('samples/cbr.mp3', { + 'extra': {}, + 'channels': 2, + 'samplerate': 44100, + 'duration': 0.48866995073891617, + 'album': 'I Can Walk On Water I Can Fly', + 'year': '2007', + 'title': 'I Can Walk On Water I Can Fly', + 'artist': 'Basshunter', + 'track': 1, + 'filesize': 8186, + 'bitrate': 128.0, + 'genre': 'Dance', + 'comment': 'Ripped by THSLIVE', + }), + ('samples/vbr_xing_header.mp3', { + 'extra': {}, + 'bitrate': 186.04383278145696, + 'channels': 1, + 'samplerate': 44100, + 'duration': 3.944489795918367, + 'filesize': 91731, + }), + ('samples/vbr_xing_header_2channel.mp3', { + 'extra': { + 'encoder_settings': [ + 'LAME 32bits version 3.99.5 (http://lame.sf.net)' + ], + 'tlen': ['249976'] + }, + 'filesize': 2000, + 'album': "The Harpers' Masque", + 'artist': 'Knodel and Valencia', + 'bitrate': 46.276128290848305, + 'channels': 2, + 'duration': 250.04408163265308, + 'samplerate': 22050, + 'title': 'Lochaber No More', + 'year': '1992', + }), + ('samples/id3v22-test.mp3', { + 'extra': { + 'encoded_by': ['iTunes v4.6'], + 'itunnorm': [ + ' 0000044E 00000061 00009B67 000044C3 00022478 00022182' + ' 00007FCC 00007E5C 0002245E 0002214E' + ], + 'itunes_cddb_1': [ + '9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+' + '113226+132452+146426+163829' + ], + 'itunes_cddb_tracknumber': ['3'], + }, + 'channels': 2, + 'samplerate': 44100, + 'track_total': 11, + 'duration': 0.13836297152858082, + 'album': 'Hymns for the Exiled', + 'year': '2004', + 'title': 'cosmic american', + 'artist': 'Anais Mitchell', + 'track': 3, + 'filesize': 5120, + 'bitrate': 160.0, + 'comment': 'Waterbug Records, www.anaismitchell.com', + }), + ('samples/silence-44-s-v1.mp3', { + 'extra': {}, + 'channels': 2, + 'samplerate': 44100, + 'genre': 'Darkwave', + 'duration': 3.738712956446946, + 'album': 'Quod Libet Test Data', + 'year': '2004', + 'title': 'Silence', + 'artist': 'piman', + 'track': 2, + 'filesize': 15070, + 'bitrate': 32.0, + }), + ('samples/id3v1-latin1.mp3', { + 'extra': {}, + 'genre': 'Rock', + 'album': 'The Young Americans', + 'title': 'Play Dead', + 'filesize': 256, + 'track': 12, + 'artist': 'Björk', + 'year': '1993', + 'comment': ' ', + }), + ('samples/UTF16.mp3', { + 'extra': { + 'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], + 'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'], + 'musicbrainz album artist id': [ + '664c3e0e-42d8-48c1-b209-1efca19c0325' + ], + 'musicbrainz disc id': ['p.5xoyYRtCVFe2gt0mfTfsXrO9U-'], + 'musicip puid': ['6ff97581-1c73-fc05-b4e4-a4ccee12ec84'], + 'asin': ['B003KVNV4S'], + 'musicbrainz album status': ['Official'], + 'musicbrainz album type': ['Album'], + 'musicbrainz album release country': ['United States'], + 'ufid': [ + 'http://musicbrainz.org\x00' + 'cf639964-eabb-4c40-9673-c2117e456ea5' + ], + 'publisher': ['4AD'], + 'tdat': ['1105'], + 'wxxx': [ + 'WIKIPEDIA_RELEASE\x00http://en.wikipedia.org/wiki/High_Violet' + ], + 'media': ['Digital'], + 'tlen': ['203733'], + 'encoder_settings': [ + 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)' + ], + }, + 'track_total': 11, + 'track': 7, + 'artist': 'The National', + 'year': '2010', + 'album': 'High Violet', + 'title': 'Lemonworld', + 'filesize': 20480, + 'genre': 'Indie', + 'comment': 'Track 7', + }), + ('samples/utf-8-id3v2.mp3', { + 'extra': {}, + 'genre': 'Acustico', + 'track_total': 21, + 'track': 1, + 'filesize': 2119, + 'title': 'Gran día', + 'artist': 'Paso a paso', + 'album': 'S/T', + 'disc_total': 0, + 'year': '2003', + }), + ('samples/empty_file.mp3', { + 'extra': {}, + 'filesize': 0 + }), + ('samples/incomplete.mp3', { + 'extra': {}, + 'filesize': 3 + }), + ('samples/silence-44khz-56k-mono-1s.mp3', { + 'extra': {}, + 'channels': 1, + 'samplerate': 44100, + 'duration': 1.0265261269342902, + 'filesize': 7280, + 'bitrate': 56.0, + }), + ('samples/silence-22khz-mono-1s.mp3', { + 'extra': {}, + 'channels': 1, + 'samplerate': 22050, + 'filesize': 4284, + 'bitrate': 32.0, + 'duration': 1.0438932496075353, + }), + ('samples/id3v24-long-title.mp3', { + 'extra': { + 'copyright': [ + '2013 Marathon Artists under exclsuive license from ' + 'Courtney Barnett' + ] + }, + 'track': 1, + 'disc_total': 1, + 'composer': 'Courtney Barnett', + 'album': 'The Double EP: A Sea of Split Peas', + 'filesize': 10000, + 'track_total': 12, + 'genre': 'AlternRock', + 'title': 'Out of the Woodwork', + 'artist': 'Courtney Barnett', + 'albumartist': 'Courtney Barnett', + 'disc': 1, + 'comment': 'Amazon.com Song ID: 240853806', + 'year': '2013', + }), + ('samples/utf16be.mp3', { + 'extra': {}, + 'title': '52-girls', + 'filesize': 2048, + 'track': 6, + 'album': 'party mix', + 'artist': 'The B52s', + 'genre': 'Rock', + 'year': '1981', + }), + ('samples/id3v22_image.mp3', { + 'extra': { + 'rva': ['\x10'], + 'bpm': ['131'] + }, + 'title': 'Kids (MGMT Cover) ', + 'filesize': 35924, + 'album': 'winniecooper.net ', + 'artist': 'The Kooks', + 'year': '2008', + 'genre': '.', + }), + ('samples/id3v22.TCO.genre.mp3', { + 'extra': { + 'encoded_by': ['iTunes 11.0.4'], + 'itunnorm': [ + ' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A' + ' 0000902E 00008D36 00020882 000321D6' + ], + 'itunsmpb': [ + ' 00000000 00000210 000007B9 00000000008FB737 00000000' + ' 008242F1 00000000 00000000 00000000 00000000 00000000' + ' 00000000' + ], + 'itunpgap': ['0'], + }, + 'filesize': 500, + 'album': 'ARTPOP', + 'artist': 'Lady GaGa', + 'genre': 'Pop', + 'title': 'Applause', + }), + ('samples/id3_comment_utf_16_with_bom.mp3', { + 'extra': { + 'copyright': ['(c) 2008 nin'], + 'isrc': ['USTC40852229'], + 'bpm': ['60'], + 'url': ['www.nin.com'], + 'encoded_by': ['LAME 3.97'], + }, + 'filesize': 19980, + 'album': 'Ghosts I-IV', + 'albumartist': 'Nine Inch Nails', + 'artist': 'Nine Inch Nails', + 'disc': 1, + 'disc_total': 2, + 'title': '1 Ghosts I', + 'track': 1, + 'track_total': 36, + 'year': '2008', + 'comment': '3/4 time', + }), + ('samples/id3_comment_utf_16_double_bom.mp3', { + 'extra': { + 'label': ['Unclear'] + }, + 'filesize': 512, + 'album': 'The Embrace', + 'artist': 'Johannes Heil & D.Diggler', + 'comment': 'Unclear', + 'title': 'The Embrace (Romano Alfieri Remix)', + 'year': '2012', + }), + ('samples/id3_genre_id_out_of_bounds.mp3', { + 'extra': {}, + 'filesize': 512, + 'album': 'MECHANICAL ANIMALS', + 'artist': 'Manson', + 'genre': '(255)', + 'title': '01 GREAT BIG WHITE WORLD', + 'year': '0', + }), + ('samples/image-text-encoding.mp3', { + 'extra': {}, + 'channels': 1, + 'samplerate': 22050, + 'filesize': 11104, + 'title': 'image-encoding', + 'bitrate': 32.0, + 'duration': 1.0438932496075353, + }), + ('samples/id3v1_does_not_overwrite_id3v2.mp3', { + 'extra': { + 'love rating': ['L'], + 'publisher': ['Century Media'], + 'popm': ['MusicBee\x00Ä'] + }, + 'filesize': 1130, + 'album': 'Somewhere Far Beyond', + 'albumartist': 'Blind Guardian', + 'artist': 'Blind Guardian', + 'genre': 'Power Metal', + 'title': 'Time What Is Time', + 'track': 1, + 'year': '1992', + }), + ('samples/non_ascii_filename_äää.mp3', { + 'extra': { + 'encoder_settings': ['Lavf58.20.100'] + }, + 'filesize': 80919, + 'channels': 2, + 'duration': 5.067755102040817, + 'samplerate': 44100, + 'bitrate': 127.6701030927835, + }), + ('samples/chinese_id3.mp3', { + 'extra': {}, + 'filesize': 1000, + 'album': '½ÇÂäÖ®¸è', + 'albumartist': 'ËÕÔÆ', + 'artist': 'ËÕÔÆ', + 'bitrate': 128.0, + 'channels': 2, + 'duration': 0.052244897959183675, + 'genre': 'ÐÝÏÐÒôÀÖ', + 'samplerate': 44100, + 'title': '½ÇÂäÖ®¸è', + 'track': 1, + }), + ('samples/cut_off_titles.mp3', { + 'extra': { + 'encoder_settings': ['Lavf54.29.104'] + }, + 'filesize': 1000, + 'album': 'ERB', + 'artist': 'Epic Rap Battles Of History', + 'bitrate': 192.0, + 'channels': 2, + 'duration': 0.052244897959183675, + 'samplerate': 44100, + 'title': 'Tony Hawk VS Wayne Gretzky', + }), + ('samples/id3_xxx_lang.mp3', { + 'extra': { + 'script': ['Latn'], + 'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'], + 'musicbrainz album type': ['album'], + 'musicbrainz album artist id': [ + '078a9376-3c04-4280-b7d7-b20e158f345d' + ], + 'musicbrainz artist id': ['078a9376-3c04-4280-b7d7-b20e158f345d'], + 'barcode': ['724386668721'], + 'musicbrainz album id': ['38b555fe-24c7-37b3-ad1b-f6dea9f1aafa'], + 'musicbrainz release track id': [ + '7f7c31a5-0905-39ba-ba72-68db91d3b9da' + ], + 'catalog_number': ['7243 8 66687 2 1'], + 'musicbrainz release group id': [ + '0f21095a-e629-389c-981a-d9569e9673c9' + ], + 'musicbrainz album status': ['official'], + 'asin': ['B000641ZIQ'], + 'musicbrainz album release country': ['US'], + 'isrc': ['USVI20400513'], + 'lyrics': ['Don\'t fret, precious'], + 'replaygain_track_gain': ['-3.95 dB'], + 'replaygain_track_peak': ['0.999969'], + 'replaygain_album_gain': ['-8.26 dB'], + 'publisher': ['Virgin Records America'], + 'media': ['CD'], + 'tso2': ['Perfect Circle, A'], + 'ufid': [ + 'http://musicbrainz.org\x00' + 'd2b8f0e6-735a-42ee-adf0-7eca4e65cd72' + ], + 'tsop': ['Perfect Circle, A'], + 'original_year': ['2004'], + 'tdat': ['0211'], + 'ipls': [ + 'producer\x00Billy Howerdel\x00' + 'producer\x00Maynard James Keenan\x00' + 'engineer\x00Billy Howerdel\x00engineer\x00Critter' + ], + }, + 'filesize': 6943, + 'album': 'eMOTIVe', + 'albumartist': 'A Perfect Circle', + 'artist': 'A Perfect Circle', + 'composer': 'Billy Howerdel/Maynard James Keenan', + 'bitrate': 192.0, + 'channels': 2, + 'duration': 0.13198711063372717, + 'genre': 'Rock', + 'samplerate': 44100, + 'title': 'Counting Bodies Like Sheep to the Rhythm of the War Drums', + 'track': 10, + 'comment': ' ', + 'disc': 1, + 'disc_total': 1, + 'track_total': 12, + 'year': '2004', + }), + ('samples/mp3/vbr/vbr8.mp3', { + 'filesize': 9504, + 'bitrate': 8.25, + 'channels': 1, + 'duration': 9.216, + 'extra': {}, + 'samplerate': 8000, + }), + ('samples/mp3/vbr/vbr8stereo.mp3', { + 'filesize': 9504, + 'bitrate': 8.25, + 'channels': 2, + 'duration': 9.216, + 'extra': {}, + 'samplerate': 8000, + }), + ('samples/mp3/vbr/vbr11.mp3', { + 'filesize': 9360, + 'bitrate': 8.143465909090908, + 'channels': 1, + 'duration': 9.195102040816327, + 'extra': {}, + 'samplerate': 11025, + }), + ('samples/mp3/vbr/vbr11stereo.mp3', { + 'filesize': 9360, + 'bitrate': 8.143465909090908, + 'channels': 2, + 'duration': 9.195102040816327, + 'extra': {}, + 'samplerate': 11025, + }), + ('samples/mp3/vbr/vbr16.mp3', { + 'filesize': 9432, + 'bitrate': 8.251968503937007, + 'channels': 1, + 'duration': 9.144, + 'extra': {}, + 'samplerate': 16000, + }), + ('samples/mp3/vbr/vbr16stereo.mp3', { + 'filesize': 9432, + 'bitrate': 8.251968503937007, + 'channels': 2, + 'duration': 9.144, + 'extra': {}, + 'samplerate': 16000, + }), + ('samples/mp3/vbr/vbr22.mp3', { + 'filesize': 9282, + 'bitrate': 8.145021489971347, + 'channels': 1, + 'duration': 9.11673469387755, + 'extra': {}, + 'samplerate': 22050, + }), + ('samples/mp3/vbr/vbr22stereo.mp3', { + 'filesize': 9282, + 'bitrate': 8.145021489971347, + 'channels': 2, + 'duration': 9.11673469387755, + 'extra': {}, + 'samplerate': 22050, + }), + ('samples/mp3/vbr/vbr32.mp3', { + 'filesize': 37008, + 'bitrate': 32.50592885375494, + 'channels': 1, + 'duration': 9.108, + 'extra': {}, + 'samplerate': 32000, + }), + ('samples/mp3/vbr/vbr32stereo.mp3', { + 'filesize': 37008, + 'bitrate': 32.50592885375494, + 'channels': 2, + 'duration': 9.108, + 'extra': {}, + 'samplerate': 32000, + }), + ('samples/mp3/vbr/vbr44.mp3', { + 'filesize': 36609, + 'bitrate': 32.21697198275862, + 'channels': 1, + 'duration': 9.09061224489796, + 'extra': {}, + 'samplerate': 44100, + }), + ('samples/mp3/vbr/vbr44stereo.mp3', { + 'filesize': 36609, + 'bitrate': 32.21697198275862, + 'channels': 2, + 'duration': 9.09061224489796, + 'extra': {}, + 'samplerate': 44100, + }), + ('samples/mp3/vbr/vbr48.mp3', { + 'filesize': 36672, + 'bitrate': 32.33862433862434, + 'channels': 1, + 'duration': 9.072, + 'extra': {}, + 'samplerate': 48000, + }), + ('samples/mp3/vbr/vbr48stereo.mp3', { + 'filesize': 36672, + 'bitrate': 32.33862433862434, + 'channels': 2, + 'duration': 9.072, + 'extra': {}, + 'samplerate': 48000, + }), + ('samples/id3v24_genre_null_byte.mp3', { + 'extra': {}, + 'filesize': 256, + 'album': '\u79d8\u5bc6', + 'albumartist': 'aiko', + 'artist': 'aiko', + 'disc': 1, + 'genre': 'Pop', + 'title': '\u661f\u306e\u306a\u3044\u4e16\u754c', + 'track': 10, + 'year': '2008', + }), + ('samples/vbr_xing_header_short.mp3', { + 'filesize': 432, + 'bitrate': 24.0, + 'channels': 1, + 'duration': 0.144, + 'extra': {}, + 'samplerate': 8000, + }), + ('samples/id3_multiple_artists.mp3', { + 'extra': { + 'artist': [ + 'artist2', + 'artist3', + 'artist4', + 'artist5', + 'artist6', + 'artist7', + ] + }, + 'filesize': 2007, + 'bitrate': 57.39124999999999, + 'channels': 1, + 'duration': 0.1306122448979592, + 'samplerate': 44100, + 'artist': 'artist1', + 'genre': 'something 1', + }), + ('samples/id3_frames.mp3', { + 'filesize': 27576, + 'bitrate': 50.03636363636364, + 'channels': 1, + 'duration': 3.96, + 'samplerate': 16000, + 'extra': {}, + }), + ('samples/empty.ogg', { + 'extra': {}, + 'duration': 3.684716553287982, + 'filesize': 4328, + 'bitrate': 112.0, + 'samplerate': 44100, + 'channels': 2, + }), + ('samples/multipage-setup.ogg', { + 'extra': { + 'transcoded': ['mp3;241'], + 'replaygain_album_gain': ['-10.29 dB'], + 'replaygain_album_peak': ['1.50579047'], + 'replaygain_track_peak': ['1.17979193'], + 'replaygain_track_gain': ['-10.02 dB'], + }, + 'genre': 'JRock', + 'duration': 4.128798185941043, + 'album': 'Timeless', + 'year': '2006', + 'title': 'Burst', + 'artist': 'UVERworld', + 'track': 7, + 'filesize': 76983, + 'bitrate': 160.0, + 'samplerate': 44100, + 'comment': 'SRCL-6240', + 'channels': 2, + }), + ('samples/test.ogg', { + 'extra': {}, + 'duration': 1.0, + 'album': 'the boss', + 'year': '2006', + 'title': 'the boss', + 'artist': 'james brown', + 'track': 1, + 'filesize': 7467, + 'bitrate': 160.0, + 'samplerate': 44100, + 'channels': 2, + 'comment': 'hello!', + }), + ('samples/corrupt_metadata.ogg', { + 'extra': {}, + 'filesize': 18648, + 'bitrate': 80.0, + 'duration': 2.132358276643991, + 'samplerate': 44100, + 'channels': 1, + }), + ('samples/composer.ogg', { + 'extra': {}, + 'filesize': 4480, + 'album': 'An Album', + 'artist': 'An Artist', + 'composer': 'some composer', + 'bitrate': 112.0, + 'duration': 3.684716553287982, + 'channels': 2, + 'genre': 'Some Genre', + 'samplerate': 44100, + 'title': 'A Title', + 'track': 2, + 'year': '2007', + 'comment': 'A Comment', + }), + ('samples/test.opus', { + 'extra': { + 'encoder': ['Lavc57.24.102 libopus'], + 'arrange': ['\u6771\u65b9'], + 'catalogid': ['ARCD0024'], + 'discid': ['A212230D'], + 'event': ['\u4f8b\u5927\u796d5'], + 'lyricist': ['Haruka'], + 'mastering': ['Hedonist'], + 'origin': ['\u6771\u65b9\u5e7b\u60f3\u90f7'], + 'originaltitle': ['Bad Apple!!'], + 'performer': ['Masayoshi Minoshima'], + 'vocal': ['nomico'], + }, + 'albumartist': 'Alstroemeria Records', + 'samplerate': 48000, + 'channels': 2, + 'track': 1, + 'disc': 1, + 'title': 'Bad Apple!!', + 'duration': 2.0, + 'year': '2008.05.25', + 'filesize': 10000, + 'artist': 'nomico', + 'album': 'Exserens - A selection of Alstroemeria Records', + 'comment': 'ARCD0018 - Lovelight', + 'disc_total': 1, + 'track_total': 13, + }), + ('samples/8khz_5s.opus', { + 'extra': { + 'encoder': ['opusenc from opus-tools 0.2'] + }, + 'filesize': 7251, + 'channels': 1, + 'samplerate': 48000, + 'duration': 5.0065, + }), + ('samples/test_flac.oga', { + 'extra': { + 'copyright': ['test3'], + 'isrc': ['test4'], + 'lyrics': ['test7'] + }, + 'filesize': 9273, + 'album': 'test2', + 'artist': 'test6', + 'comment': 'test5', + 'bitrate': 20.022488249118684, + 'duration': 3.705034013605442, + 'channels': 2, + 'genre': 'Acoustic', + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'test1', + 'track': 5, + 'year': '2023', + }), + ('samples/test.spx', { + 'extra': {}, + 'filesize': 7921, + 'channels': 1, + 'samplerate': 16000, + 'bitrate': -1, + 'duration': 2.1445625, + 'artist': 'test1', + 'title': 'test2', + 'comment': 'Encoded with Speex 1.2.0', + }), + ('samples/test.wav', { + 'extra': {}, + 'channels': 2, + 'duration': 1.0, + 'filesize': 176444, + 'bitrate': 1411.2, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/test3sMono.wav', { + 'extra': {}, + 'channels': 1, + 'duration': 3.0, + 'filesize': 264644, + 'bitrate': 705.6, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/test-tagged.wav', { + 'extra': {}, + 'channels': 2, + 'duration': 1.0, + 'filesize': 176688, + 'album': 'thealbum', + 'artist': 'theartisst', + 'bitrate': 1411.2, + 'genre': 'Acid', + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'thetitle', + 'track': 66, + 'comment': 'hello', + 'year': '2014', + }), + ('samples/test-riff-tags.wav', { + 'extra': {}, + 'channels': 2, + 'duration': 1.0, + 'filesize': 176540, + 'artist': 'theartisst', + 'bitrate': 1411.2, + 'genre': 'Acid', + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'thetitle', + 'comment': 'hello', + 'year': '2014', + }), + ('samples/silence-22khz-mono-1s.wav', { + 'extra': {}, + 'channels': 1, + 'duration': 0.9991836734693877, + 'filesize': 48160, + 'bitrate': 352.8, + 'samplerate': 22050, + 'bitdepth': 16, + }), + ('samples/id3_header_with_a_zero_byte.wav', { + 'extra': { + 'title': ['Stacked'] + }, + 'channels': 1, + 'duration': 1.0, + 'filesize': 44280, + 'bitrate': 352.8, + 'samplerate': 22050, + 'bitdepth': 16, + 'artist': 'Purpley', + 'title': 'Test000', + 'track': 17, + 'album': 'prototypes', + }), + ('samples/adpcm.wav', { + 'extra': {}, + 'channels': 1, + 'duration': 12.167256235827665, + 'filesize': 268686, + 'bitrate': 176.4, + 'samplerate': 44100, + 'bitdepth': 4, + 'artist': 'test artist', + 'title': 'test title', + 'track': 1, + 'album': 'test album', + 'comment': 'test comment', + 'genre': 'test genre', + 'year': '1990', + }), + ('samples/riff_extra_zero.wav', { + 'extra': {}, + 'channels': 2, + 'duration': 0.11609977324263039, + 'filesize': 20670, + 'bitrate': 1411.2, + 'samplerate': 44100, + 'bitdepth': 16, + 'artist': 'B.O.S.E.', + 'title': 'Mission Bass', + 'album': '808 Bass Express', + 'genre': 'Hip-Hop/Rap', + 'year': '1996', + 'track': 3, + }), + ('samples/riff_extra_zero_2.wav', { + 'extra': {}, + 'channels': 2, + 'duration': 0.11609977324263039, + 'filesize': 20682, + 'bitrate': 1411.2, + 'samplerate': 44100, + 'bitdepth': 16, + 'artist': 'The Jimmy Castor Bunch', + 'title': 'It\'s Just Begun', + 'album': 'The Perfect Beats, Vol. 4', + 'genre': 'Pop Electronica', + 'track': 7, + }), + ('samples/wav_invalid_track_number.wav', { + 'extra': {}, + 'filesize': 8908, + 'bitrate': 705.6, + 'duration': 0.1, + 'samplerate': 44100, + 'channels': 1, + 'bitdepth': 16, + }), + ('samples/gsm_6_10.wav', { + 'extra': {}, + 'bitdepth': 1, + 'bitrate': 44.1, + 'channels': 1, + 'duration': 0.16507936507936508, + 'filesize': 1246, + 'samplerate': 44100, + 'album': 'album', + 'artist': 'artist', + 'title': 'track', + 'track': 99, + 'year': '2010', + 'comment': 'some comment here', + 'genre': 'Bass', + }), + ('samples/flac1sMono.flac', { + 'extra': {}, + 'genre': 'Avantgarde', + 'album': 'alb', + 'year': '2014', + 'duration': 1.0, + 'title': 'track', + 'track': 23, + 'artist': 'art', + 'channels': 1, + 'filesize': 26632, + 'bitrate': 213.056, + 'samplerate': 44100, + 'bitdepth': 16, + 'comment': 'hello', + }), + ('samples/flac453sStereo.flac', { + 'extra': {}, + 'channels': 2, + 'duration': 453.51473922902494, + 'filesize': 84236, + 'bitrate': 1.4859230399999999, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/flac1.5sStereo.flac', { + 'extra': {}, + 'channels': 2, + 'album': 'alb', + 'year': '2014', + 'duration': 1.4995238095238095, + 'title': 'track', + 'track': 23, + 'artist': 'art', + 'filesize': 59868, + 'bitrate': 319.39739599872973, + 'genre': 'Avantgarde', + 'samplerate': 44100, + 'bitdepth': 16, + 'comment': 'hello', + }), + ('samples/flac_application.flac', { + 'extra': { + 'replaygain_track_peak': ['0.9976'], + 'musicbrainz_albumartistid': [ + 'e5c7b94f-e264-473c-bb0f-37c85d4d5c70' + ], + 'musicbrainz_trackid': ['e65fb332-0c1e-4172-85e0-59cd37e5669e'], + 'replaygain_album_gain': ['-8.14 dB'], + 'labelid': ['RTRADLP480'], + 'musicbrainz_albumid': ['359a91e9-3bb3-4b60-a823-8aaa4bad1e36'], + 'artistsort': ['Belle and Sebastian'], + 'replaygain_track_gain': ['-8.08 dB'], + 'replaygain_album_peak': ['1.0000'], + }, + 'channels': 2, + 'track_total': 11, + 'album': 'Belle and Sebastian Write About Love', + 'year': '2010-10-11', + 'duration': 273.64, + 'title': 'I Want the World to Stop', + 'track': 4, + 'artist': 'Belle and Sebastian', + 'filesize': 13000, + 'bitrate': 0.38006139453296306, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/no-tags.flac', { + 'extra': {}, + 'channels': 2, + 'duration': 3.684716553287982, + 'filesize': 4692, + 'bitrate': 10.186943678613627, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/variable-block.flac', { + 'extra': { + 'discid': ['AA0B360B'], + 'japanese title': ['アップルシード オリジナル・サウンドトラック'], + 'organization': ['Sony Music Records (SRCP-371)'], + 'ripper': ['Exact Audio Copy 0.99pb5'], + 'replaygain_album_gain': ['-8.68 dB'], + 'replaygain_album_peak': ['1.000000'], + 'replaygain_track_gain': ['-9.61 dB'], + 'replaygain_track_peak': ['1.000000'], + }, + 'channels': 2, + 'album': 'Appleseed Original Soundtrack', + 'year': '2004', + 'duration': 261.68, + 'title': 'DIVE FOR YOU', + 'track': 1, + 'track_total': 11, + 'artist': 'Boom Boom Satellites', + 'filesize': 10240, + 'bitrate': 0.31305411189238763, + 'disc': 1, + 'genre': 'Anime Soundtrack', + 'samplerate': 44100, + 'bitdepth': 16, + 'disc_total': 2, + 'comment': 'Original Soundtrack', + 'composer': 'Boom Boom Satellites (Lyrics)', + }), + ('samples/106-invalid-streaminfo.flac', { + 'extra': {}, + 'filesize': 4692 + }), + ('samples/106-short-picture-block-size.flac', { + 'extra': {}, + 'filesize': 4692, + 'bitrate': 10.186943678613627, + 'channels': 2, + 'duration': 3.684716553287982, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/with_id3_header.flac', { + 'extra': { + 'id': ['8591671910'], + 'artist': ['群星'], + 'album': [' '], + 'title': ['A 梦 哆啦 机器猫 短信铃声'], + }, + 'filesize': 64837, + 'album': 'album', + 'artist': 'artist', + 'title': 'title', + 'track': 1, + 'bitrate': 1143.72468, + 'channels': 1, + 'duration': 0.45351473922902497, + 'genre': 'genre', + 'samplerate': 44100, + 'bitdepth': 16, + 'year': '2018', + 'comment': 'comment', + 'disc': 0, + }), + ('samples/with_padded_id3_header.flac', { + 'extra': {}, + 'filesize': 16070, + 'album': 'album', + 'artist': 'artist', + 'bitrate': 283.4748, + 'channels': 1, + 'duration': 0.45351473922902497, + 'genre': 'genre', + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'title', + 'track': 1, + 'year': '2018', + 'comment': 'comment', + }), + ('samples/with_padded_id3_header2.flac', { + 'extra': { + 'mcdi': [ + '2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00' + '\x00\x00W5\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00' + '\x00\x00ä7\x00\x10\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01' + '\x8c\xa0' + ], + 'tlen': ['297666'], + 'encoded_by': ['Exact Audio Copy (Sicherer Modus)'], + 'encoder_settings': [ + 'flac.exe -T "artist=Unbekannter Künstler" ' + '-T "title=Track01" -T "album=Unbekannter Titel" ' + '-T "date=" -T "tracknumber=01" -T "genre=" -5' + ], + 'artist': ['Unbekannter Künstler'], + 'album': ['Unbekannter Titel'], + 'title': ['Track01'], + }, + 'filesize': 19522, + 'album': 'album', + 'artist': 'artist', + 'bitrate': 344.36807999999996, + 'channels': 1, + 'disc': 1, + 'disc_total': 1, + 'duration': 0.45351473922902497, + 'genre': 'genre', + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'title', + 'track': 1, + 'track_total': 5, + 'year': '2018', + 'comment': 'comment', + }), + ('samples/flac_with_image.flac', { + 'extra': {}, + 'filesize': 80000, + 'album': 'smilin´ in circles', + 'artist': 'Andreas Kümmert', + 'bitrate': 7.6591670655816175, + 'channels': 2, + 'disc': 1, + 'disc_total': 1, + 'duration': 83.56, + 'genre': 'Blues', + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'intro', + 'track': 1, + 'track_total': 8, + }), + ('samples/flac_invalid_track_number.flac', { + 'extra': {}, + 'filesize': 235, + 'bitrate': 18.8, + 'channels': 1, + 'duration': 0.1, + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/flac_multiple_fields.flac', { + 'extra': { + 'artist': ['artist 2', 'artist 3'], + 'genre': ['genre 2'], + 'album': ['album 2'], + 'url': ['https://example.com'], + }, + 'filesize': 266, + 'album': 'album 1', + 'artist': 'artist 1', + 'bitrate': 21.28, + 'channels': 1, + 'duration': 0.1, + 'genre': 'genre 1', + 'samplerate': 44100, + 'bitdepth': 16, + }), + ('samples/test2.wma', { + 'extra': { + '_track': ['0'], + 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], + 'encodingtime': ['128861118183900000'], + 'wmfsdkversion': ['11.0.5721.5145'], + 'wmfsdkneeded': ['0.0.0.0000'], + 'isvbr': ['1'], + 'peakvalue': ['30369'], + 'averagelevel': ['7291'], + }, + 'samplerate': 44100, + 'album': 'The Colour and the Shape', + 'title': 'Doll', + 'bitrate': 64.04, + 'filesize': 5800, + 'track': 1, + 'albumartist': 'Foo Fighters', + 'artist': 'Foo Fighters', + 'duration': 83.406, + 'year': '1997', + 'genre': 'Alternative', + 'composer': 'Foo Fighters', + 'channels': 2, + }), + ('samples/lossless.wma', { + 'extra': {}, + 'samplerate': 44100, + 'bitrate': 667.296, + 'filesize': 2500, + 'bitdepth': 16, + 'duration': 43.133, + 'channels': 2, + }), + ('samples/wma_invalid_track_number.wma', { + 'extra': { + 'encoder_settings': ['Lavf60.16.100'] + }, + 'filesize': 3940, + 'bitrate': 128.0, + 'duration': 2.1409999999999996, + 'samplerate': 44100, + 'channels': 1, + }), + ('samples/test.m4a', { + 'extra': { + 'itunsmpb': [ + ' 00000000 00000840 000001DC 0000000000D3E9E4 00000000' + ' 00000000 00000000 00000000 00000000 00000000 00000000' + ' 00000000' + ], + 'itunnorm': [ + ' 00000358 0000032E 000020AE 000020D9 0003A228 00032A28' + ' 00007E20 00007E90 00007BFD 00009293' + ], + 'itunes_cddb_ids': ['11++'], + 'ufidhttp://www.cddb.com/id3/taginfo1.html': [ + '3CD3N48Q241232290U3387DD249F72E6B082B283425ADB9B0F324P1' + ], + 'bpm': ['0'], + 'encoded_by': ['iTunes 10.5'], + }, + 'samplerate': 44100, + 'duration': 314.97868480725623, + 'bitrate': 256.0, + 'channels': 2, + 'genre': 'Pop', + 'year': '2011', + 'title': 'Nothing', + 'album': 'Only Our Hearts To Lose', + 'track_total': 11, + 'track': 11, + 'artist': 'Marian', + 'filesize': 61432, + }), + ('samples/test2.m4a', { + 'extra': { + 'copyright': ['℗ 1992 Ace Records'], + 'itunnorm': [ + ' 00000371 00000481 00002E90 00002EA6 00000099 00000058' + ' 000073F3 0000768E 00000092 00000092' + ], + 'itunsmpb': [ + ' 00000000 00000840 00000110 000000000070DEB0 00000000' + ' 00000000 00000000 00000000 00000000 00000000 00000000' + ' 00000000' + ], + 'itunmovi': [ + '\n' + '\n' + '\n' + '\n' + '\tasset-info\n' + '\t\n' + '\t\tflavor\n' + '\t\t2:256\n' + '\t\n' + '\n' + '\n' + ], + 'tool': ['144255989988720642'], + }, + 'bitrate': 256.0, + 'track': 1, + 'albumartist': 'Millie Jackson - Get It Out \'cha System - 1978', + 'duration': 167.78739229024944, + 'filesize': 223365, + 'channels': 2, + 'year': '1978', + 'artist': 'Millie Jackson', + 'track_total': 9, + 'disc_total': 1, + 'genre': 'R&B/Soul', + 'album': "Get It Out 'cha System", + 'samplerate': 44100, + 'disc': 1, + 'title': 'Go Out and Get Some', + 'composer': 'Millie Jackson - Get It Out \'cha System - 1978', + 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', + }), + ('samples/iso8859_with_image.m4a', { + 'extra': {}, + 'artist': 'Major Lazer', + 'filesize': 57017, + 'title': 'Cold Water (feat. Justin Bieber & M\uFFFD)', + 'album': 'Cold Water (feat. Justin Bieber & M\uFFFD) - Single', + 'year': '2016', + 'samplerate': 44100, + 'duration': 188.545, + 'genre': 'Electronic;Music', + 'albumartist': 'Major Lazer', + 'channels': 2, + 'bitrate': 125.584, + 'comment': '? 2016 Mad Decent', + }), + ('samples/alac_file.m4a', { + 'extra': { + 'copyright': ['© Hyperion Records Ltd, London'], + 'lyrics': ['Album notes:'], + 'upc': ['0034571177380'] + }, + 'artist': 'Howard Shelley', + 'filesize': 20000, + 'composer': 'Clementi, Muzio (1752-1832)', + 'title': 'Clementi: Piano Sonata in D major, Op 25 No 6 - Movement 2: ' + 'Un poco andante', + 'album': 'Clementi: The Complete Piano Sonatas, Vol. 4', + 'year': '2009', + 'track': 14, + 'track_total': 27, + 'disc': 1, + 'disc_total': 1, + 'samplerate': 44100, + 'duration': 166.62639455782312, + 'genre': 'Classical', + 'albumartist': 'Howard Shelley', + 'channels': 2, + 'bitrate': 436.743, + 'bitdepth': 16, + }), ('samples/mpeg4_desc_cmt.m4a', { + 'extra': { + 'description': ['test description'], + 'encoded_by': ['Lavf59.27.100'] + }, 'filesize': 32006, 'bitrate': 101.038, 'channels': 2, 'comment': 'test comment', 'duration': 2.36, - 'extra': {'description': ['test description'], 'encoded_by': ['Lavf59.27.100']}, - 'samplerate': 44100}), + 'samplerate': 44100, + }), ('samples/mpeg4_xa9des.m4a', { + 'extra': { + 'description': ['test description'] + }, 'filesize': 2639, 'comment': 'test comment', 'duration': 727.1066666666667, - 'extra': {'description': ['test description']}}), - ('samples/test3.m4a', - {'extra': {'publisher': ['test7'], 'bpm': ['99999'], - 'encoded_by': ['Lavf60.3.100']}, - 'artist': 'test1', 'composer': 'test8', - 'filesize': 6260, 'samplerate': 8000, 'duration': 1.294, 'channels': 1, - 'bitrate': 27.887}), - - # AIFF - ('samples/test-tagged.aiff', - {'extra': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, 'artist': 'theartist', - 'bitrate': 1411.2, 'genre': 'Acid', 'samplerate': 44100, 'bitdepth': 16, 'track': 1, - 'title': 'thetitle', 'album': 'thealbum', 'comment': 'hello', - 'year': '2014'}), - ('samples/test.aiff', - {'extra': {'copyright': ['℗ 1992 Ace Records']}, 'channels': 2, 'duration': 0.0, - 'filesize': 164, 'bitrate': 1411.2, 'samplerate': 44100, 'bitdepth': 16, - 'title': 'Go Out and Get Some', - 'comment': 'Millie Jackson - Get It Out \'cha System - 1978'}), - ('samples/pluck-pcm8.aiff', - {'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, - 'artist': 'Serhiy Storchaka', 'title': 'Pluck', 'album': 'Python Test Suite', - 'bitrate': 176.4, 'samplerate': 11025, 'bitdepth': 8, - 'comment': 'Audacity Pluck + Wahwah', 'year': '2013'}), - ('samples/M1F1-mulawC-AFsp.afc', - {'extra': {'comment': ['user: kabal@CAPELLA', 'program: CopyAudio']}, - 'channels': 2, 'duration': 2.936625, 'filesize': 47148, - 'bitrate': 256.0, 'samplerate': 8000, 'bitdepth': 16, - 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC'}), - ('samples/invalid_sample_rate.aiff', - {'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16}), - ('samples/aiff_extra_tags.aiff', - {'extra': {'copyright': ['test'], 'isrc': ['CC-XXX-YY-NNNNN']}, 'channels': 1, - 'duration': 2.176, 'filesize': 18532, 'bitrate': 64.0, 'samplerate': 8000, 'bitdepth': 8, - 'title': 'song title', 'artist': 'artist 1;artist 2'}), - -]) + }), + ('samples/test3.m4a', { + 'extra': { + 'publisher': ['test7'], + 'bpm': ['99999'], + 'encoded_by': ['Lavf60.3.100'] + }, + 'artist': 'test1', + 'composer': 'test8', + 'filesize': 6260, + 'samplerate': 8000, + 'duration': 1.294, + 'channels': 1, + 'bitrate': 27.887, + }), + ('samples/test-tagged.aiff', { + 'extra': {}, + 'channels': 2, + 'duration': 1.0, + 'filesize': 177620, + 'artist': 'theartist', + 'bitrate': 1411.2, + 'genre': 'Acid', + 'samplerate': 44100, + 'bitdepth': 16, + 'track': 1, + 'title': 'thetitle', + 'album': 'thealbum', + 'comment': 'hello', + 'year': '2014', + }), + ('samples/test.aiff', { + 'extra': { + 'copyright': ['℗ 1992 Ace Records'] + }, + 'channels': 2, + 'duration': 0.0, + 'filesize': 164, + 'bitrate': 1411.2, + 'samplerate': 44100, + 'bitdepth': 16, + 'title': 'Go Out and Get Some', + 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', + }), + ('samples/pluck-pcm8.aiff', { + 'extra': {}, + 'channels': 2, + 'duration': 0.2999546485260771, + 'filesize': 6892, + 'artist': 'Serhiy Storchaka', + 'title': 'Pluck', + 'album': 'Python Test Suite', + 'bitrate': 176.4, + 'samplerate': 11025, + 'bitdepth': 8, + 'comment': 'Audacity Pluck + Wahwah', + 'year': '2013', + }), + ('samples/M1F1-mulawC-AFsp.afc', { + 'extra': { + 'comment': ['user: kabal@CAPELLA', 'program: CopyAudio'] + }, + 'channels': 2, + 'duration': 2.936625, + 'filesize': 47148, + 'bitrate': 256.0, + 'samplerate': 8000, + 'bitdepth': 16, + 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC', + }), + ('samples/invalid_sample_rate.aiff', { + 'extra': {}, + 'channels': 1, + 'filesize': 4096, + 'bitdepth': 16, + }), + ('samples/aiff_extra_tags.aiff', { + 'extra': { + 'copyright': ['test'], + 'isrc': ['CC-XXX-YY-NNNNN'] + }, + 'channels': 1, + 'duration': 2.176, + 'filesize': 18532, + 'bitrate': 64.0, + 'samplerate': 8000, + 'bitdepth': 8, + 'title': 'song title', + 'artist': 'artist 1;artist 2', + }), + ]) testfolder = os.path.join(os.path.dirname(__file__)) @@ -567,7 +1377,8 @@ def compare_values(path: str, expected_val: str | int | float) -> bool: # lets not copy *all* the lyrics inside the fixture if (path == 'extra.lyrics' - and isinstance(expected_val, list) and isinstance(result_val, list)): + and isinstance(expected_val, list) + and isinstance(result_val, list)): return result_val[0].startswith(expected_val[0]) if isinstance(expected_val, float): return result_val == pytest.approx(expected_val) @@ -588,12 +1399,15 @@ def error_fmt(value: str | int | float) -> str: compare_tag(result_val, expected_val, file, prev_path=key) else: fmt_string = 'field "%s": got %s expected %s in %s!' - fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), file) - assert compare_values(path, result_val, expected_val), fmt_string % fmt_values + fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), + file) + assert compare_values(path, result_val, expected_val), \ + fmt_string % fmt_values @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: +def test_file_reading_all(testfile: str, + expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) tag = TinyTag.get(filename, tags=True, duration=True) results = { @@ -607,9 +1421,12 @@ def test_file_reading_tags_duration(testfile: str, expected: dict[str, dict[str, @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: +def test_file_reading_tags(testfile: str, + expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) - excluded_attrs = {"bitdepth", "bitrate", "channels", "duration", "samplerate"} + excluded_attrs = { + "bitdepth", "bitrate", "channels", "duration", "samplerate" + } tag = TinyTag.get(filename, tags=True, duration=False) results = { key: val for key, val in tag.__dict__.items() @@ -625,9 +1442,12 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) - @pytest.mark.parametrize("testfile,expected", testfiles.items()) -def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: +def test_file_reading_duration(testfile: str, + expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) - allowed_attrs = {"bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} + allowed_attrs = { + "bitdepth", "bitrate", "channels", "duration", + "filesize", "samplerate"} tag = TinyTag.get(filename, tags=False, duration=True) results = { key: val for key, val in tag.__dict__.items() @@ -659,10 +1479,13 @@ def test_file_obj_compatibility() -> None: assert tag.filesize == tag_bytesio.filesize -@pytest.mark.skipif(sys.platform == "win32", reason='Windows does not support binary paths') +@pytest.mark.skipif(sys.platform == "win32", + reason='Windows does not support binary paths') def test_binary_path_compatibility() -> None: - binary_file_path = os.path.join(os.path.dirname(__file__).encode('utf-8'), b'\x01.mp3') - testfile = os.path.join(testfolder, next(iter(testfiles.keys()))).encode('utf-8') + binary_file_path = os.path.join( + os.path.dirname(__file__).encode('utf-8'), b'\x01.mp3') + testfile = os.path.join( + testfolder, next(iter(testfiles.keys()))).encode('utf-8') shutil.copy(testfile, binary_file_path) assert os.path.exists(binary_file_path) TinyTag.get(binary_file_path) @@ -684,24 +1507,28 @@ def test_override_encoding() -> None: def test_unsubclassed_tinytag_load() -> None: + # pylint: disable=protected-access tag = TinyTag() tag._load(tags=True, duration=True) assert not tag._tags_parsed def test_unsubclassed_tinytag_duration() -> None: + # pylint: disable=protected-access tag = TinyTag() with pytest.raises(NotImplementedError): tag._determine_duration(None) # type: ignore def test_unsubclassed_tinytag_parse_tag() -> None: + # pylint: disable=protected-access tag = TinyTag() with pytest.raises(NotImplementedError): tag._parse_tag(None) # type: ignore def test_mp3_length_estimation() -> None: + # pylint: disable=protected-access _ID3._MAX_ESTIMATION_SEC = 0.7 tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) assert tag.duration is not None @@ -754,7 +1581,8 @@ def test_image_loading(path: str, expected_size: int) -> None: def test_image_loading_extra() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + tag = TinyTag.get( + os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) image = tag.images.extra['bright_colored_fish'][0] assert image.data is not None assert tag.images.any is not None @@ -764,14 +1592,15 @@ def test_image_loading_extra() -> None: assert len(image.data) == 1220 assert str(image) == ( "{'name': 'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00" - "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" - "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " - "'description': None}" + "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" + "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " + "'mime_type': 'image/jpeg', 'description': None}" ) def test_mp3_utf_8_invalid_string() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) + tag = TinyTag.get( + os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) # the title used to be Gran dia, but I replaced the first byte with 0xFF, # which should be ignored here assert tag.title == '�ran día' @@ -790,6 +1619,7 @@ def test_mp3_utf_8_invalid_string() -> None: ('samples/detect_aiff.x', _Aiff), ]) def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None: + # pylint: disable=protected-access filename = os.path.join(testfolder, testfile) with open(filename, 'rb') as file_handle: parser = TinyTag._get_parser_class(filename, file_handle) @@ -797,10 +1627,11 @@ def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None: def test_show_hint_for_wrong_usage() -> None: - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError) as exc: TinyTag.get() - assert exc_info.type == ValueError - assert exc_info.value.args[0] == 'Either filename or file_obj argument is required' + assert exc.type == ValueError + assert exc.value.args[0] == ('Either filename or file_obj argument ' + 'is required') def test_deprecations() -> None: @@ -817,45 +1648,55 @@ def test_deprecations() -> None: def test_to_str() -> None: tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) assert ( - "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, 'bitrate': 160.0, " - "'bitdepth': None, 'samplerate': 44100, 'artist': 'Anais Mitchell', " - "'albumartist': None, 'composer': None, 'album': 'Hymns for the Exiled', 'disc': None, " - "'disc_total': None, 'title': 'cosmic american', 'track': 3, 'track_total': 11, " - "'genre': None, 'year': '2004', 'comment': 'Waterbug Records, www.anaismitchell.com', " - "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E 00000061 00009B67 " - "000044C3 00022478 00022182 00007FCC 00007E5C 0002245E 0002214E'], 'itunes_cddb_1': " - "['9D09130B+174405+11+150+14097+27391+43983+65786+84877+99399+113226+132452+146426+" - "163829'], 'itunes_cddb_tracknumber': ['3']}, 'images': {'front_cover': [], " - "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], 'extra': {}}" + "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, " + "'bitrate': 160.0, 'bitdepth': None, 'samplerate': 44100, " + "'artist': 'Anais Mitchell', 'albumartist': None, 'composer': None, " + "'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " + "'title': 'cosmic american', 'track': 3, 'track_total': 11, " + "'genre': None, 'year': '2004', " + "'comment': 'Waterbug Records, www.anaismitchell.com', " + "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E " + "00000061 00009B67 000044C3 00022478 00022182 00007FCC 00007E5C " + "0002245E 0002214E'], 'itunes_cddb_1': ['9D09130B+174405+11+150+14097" + "+27391+43983+65786+84877+99399+113226+132452+146426+163829'], " + "'itunes_cddb_tracknumber': ['3']}, 'images': {'front_cover': [], " + "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], " + "'extra': {}}" ) in str(tag) def test_to_str_flat_dict() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/flac_multiple_fields.flac')) + tag = TinyTag.get( + os.path.join(testfolder, 'samples/flac_multiple_fields.flac')) assert ( - "'filesize': 266, 'duration': 0.1, 'channels': 1, 'bitrate': 21.28, 'bitdepth': 16, " - "'samplerate': 44100, 'artist': ['artist 1', 'artist 2', 'artist 3'], " - "'album': ['album 1', 'album 2'], 'genre': ['genre 1', 'genre 2'], " - "'url': ['https://example.com'], 'images': {}" + "'filesize': 266, 'duration': 0.1, 'channels': 1, 'bitrate': 21.28, " + "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " + "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], " + "'genre': ['genre 1', 'genre 2'], 'url': ['https://example.com'], " + "'images': {}" ) in str(tag.as_dict()) def test_to_str_images() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + tag = TinyTag.get( + os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) assert str(tag.images) == ( - "{'front_cover': [], 'back_cover': [], 'leaflet': [], 'media': [], 'other': [], " - "'extra': {'bright_colored_fish': [{'name': 'bright_colored_fish', " - "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" - "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" + "{'front_cover': [], 'back_cover': [], 'leaflet': [], 'media': [], " + "'other': [], 'extra': {'bright_colored_fish': [{'name': " + "'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" + "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" + "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" ) def test_to_str_images_flat_dict() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + tag = TinyTag.get( + os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) assert str(tag.images.as_dict()) == ( "{'bright_colored_fish': [{'name': 'bright_colored_fish', " - "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" - "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" - "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}" + "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" + "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01" + "\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " + "'description': None}]}" ) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 285811a..3b0c00f 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -11,19 +11,22 @@ project_folder = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) sample_folder = os.path.join(project_folder, 'tinytag', 'tests', 'samples') -mp3_with_image = os.path.join(sample_folder, 'id3image_without_description.mp3') +mp3_with_img = os.path.join(sample_folder, 'id3image_without_description.mp3') bogus_file = os.path.join(sample_folder, 'there_is_no_such_ext.bogus') -assert os.path.exists(mp3_with_image) +assert os.path.exists(mp3_with_img) -tinytag_attributes = {'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', - 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'extra', - 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', - 'track_total', 'year'} +tinytag_attributes = { + 'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', + 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', + 'extra', 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', + 'track_total', 'year' +} def run_cli(args: str) -> str: debug_env = str(os.environ.pop("TINYTAG_DEBUG", None)) - output = check_output(f'{sys.executable} -m tinytag ' + args, cwd=project_folder, shell=True) + output = check_output( + f'{sys.executable} -m tinytag ' + args, cwd=project_folder, shell=True) if debug_env: os.environ["TINYTAG_DEBUG"] = debug_env return output.decode('utf-8') @@ -46,7 +49,7 @@ def test_print_help() -> None: def test_save_image_long_opt() -> None: with NamedTemporaryFile() as temp_file: assert file_size(temp_file.name) == 0 - run_cli(f'--save-image {temp_file.name} {mp3_with_image}') + run_cli(f'--save-image {temp_file.name} {mp3_with_img}') assert file_size(temp_file.name) > 0 with open(temp_file.name, 'rb') as file_handle: image_data = file_handle.read(20) @@ -57,37 +60,39 @@ def test_save_image_long_opt() -> None: def test_save_image_short_opt() -> None: with NamedTemporaryFile() as temp_file: assert file_size(temp_file.name) == 0 - run_cli(f'-i {temp_file.name} {mp3_with_image}') + run_cli(f'-i {temp_file.name} {mp3_with_img}') assert file_size(temp_file.name) > 0 def test_save_image_bulk() -> None: + temp_name = None with NamedTemporaryFile(suffix='.jpg') as temp_file: - temp_file_no_ext = temp_file.name[:-4] - assert file_size(temp_file.name) == 0 - run_cli(f'-i {temp_file.name} {mp3_with_image} {mp3_with_image} {mp3_with_image}') - assert not os.path.isfile(temp_file.name) - assert file_size(temp_file_no_ext + '00000.jpg') > 0 - assert file_size(temp_file_no_ext + '00001.jpg') > 0 - assert file_size(temp_file_no_ext + '00002.jpg') > 0 + temp_name = temp_file.name + temp_name_no_ext = temp_name[:-4] + assert file_size(temp_name) == 0 + run_cli(f'-i {temp_name} {mp3_with_img} {mp3_with_img} {mp3_with_img}') + assert not os.path.isfile(temp_name) + assert file_size(temp_name_no_ext + '00000.jpg') > 0 + assert file_size(temp_name_no_ext + '00001.jpg') > 0 + assert file_size(temp_name_no_ext + '00002.jpg') > 0 def test_meta_data_output_default_json() -> None: - output = run_cli(mp3_with_image) + output = run_cli(mp3_with_img) data = json.loads(output) assert data assert set(data.keys()).issubset(tinytag_attributes) def test_meta_data_output_format_json() -> None: - output = run_cli('-f json ' + mp3_with_image) + output = run_cli('-f json ' + mp3_with_img) data = json.loads(output) assert data assert set(data.keys()).issubset(tinytag_attributes) def test_meta_data_output_format_csv() -> None: - output = run_cli('-f csv ' + mp3_with_image) + output = run_cli('-f csv ' + mp3_with_img) lines = [line for line in output.split(os.linesep) if line] assert all(',' in line for line in lines) attributes = set(line.split(',')[0] for line in lines) @@ -95,7 +100,7 @@ def test_meta_data_output_format_csv() -> None: def test_meta_data_output_format_tsv() -> None: - output = run_cli('-f tsv ' + mp3_with_image) + output = run_cli('-f tsv ' + mp3_with_img) lines = [line for line in output.split(os.linesep) if line] assert all('\t' in line for line in lines) attributes = set(line.split('\t')[0] for line in lines) @@ -103,13 +108,13 @@ def test_meta_data_output_format_tsv() -> None: def test_meta_data_output_format_tabularcsv() -> None: - output = run_cli('-f tabularcsv ' + mp3_with_image) + output = run_cli('-f tabularcsv ' + mp3_with_img) header, _line, _rest = output.split(os.linesep) assert set(header.split(',')).issubset(tinytag_attributes) def test_meta_data_output_format_invalid() -> None: - output = run_cli('-f invalid ' + mp3_with_image) + output = run_cli('-f invalid ' + mp3_with_img) assert not output diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b63c266..f5f40d4 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,7 +1,7 @@ # tinytag - an audio file metadata reader # Copyright (c) 2014-2023 Tom Wallroth # Copyright (c) 2021-2024 Mat (mathiascode) -# + # Sources on GitHub: # http://github.com/tinytag/tinytag @@ -9,23 +9,23 @@ # Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode) -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files (the "Software"), +# to deal in the Software without restriction, including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. """Audio file metadata reader""" @@ -47,8 +47,8 @@ else: _Extra = _ImagesExtra = dict - -DEBUG = bool(environ.get('TINYTAG_DEBUG')) # some of the parsers can print debug info +# some of the parsers can print debug info +DEBUG = bool(environ.get('TINYTAG_DEBUG')) class TinyTagException(Exception): @@ -99,7 +99,7 @@ def __init__(self) -> None: self.extra = Extra() self.images = Images() self._filehandler: BinaryIO | None = None - self._default_encoding: str | None = None # allow override for some file formats + self._default_encoding: str | None = None # override for some formats self._parse_duration = True self._parse_tags = True self._load_image = False @@ -107,7 +107,10 @@ def __init__(self) -> None: self.__dict__: dict[str, str | int | float | Extra | Images] def __repr__(self) -> str: - return str({key: value for key, value in self.__dict__.items() if not key.startswith('_')}) + return str({ + key: value for key, value in self.__dict__.items() + if not key.startswith('_') + }) @classmethod def get(cls, @@ -121,14 +124,18 @@ def get(cls, """Return a tag object for an audio file.""" should_close_file = file_obj is None if filename and should_close_file: - file_obj = open(filename, 'rb') # pylint: disable=consider-using-with + # pylint: disable=consider-using-with + file_obj = open(filename, 'rb') if file_obj is None: - raise ValueError('Either filename or file_obj argument is required') + raise ValueError( + 'Either filename or file_obj argument is required') if 'ignore_errors' in kwargs: - from warnings import warn # pylint: disable=import-outside-toplevel - warn('ignore_errors argument is obsolete, and will be removed in a future ' - '2.x release', DeprecationWarning, stacklevel=2) + # pylint: disable=import-outside-toplevel + from warnings import warn + warn('ignore_errors argument is obsolete, and will be removed in ' + 'a future 2.x release', DeprecationWarning, stacklevel=2) try: + # pylint: disable=protected-access file_obj.seek(0, SEEK_END) filesize = file_obj.tell() file_obj.seek(0) @@ -150,12 +157,16 @@ def get(cls, @classmethod def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: - """Check if a specific file is supported based on its file extension.""" + """Check if a specific file is supported based on its file + extension.""" return cls._get_parser_for_filename(filename) is not None - def as_dict(self) -> dict[str, str | int | float | list[str] | dict[str, list[Image]]]: - """Return a flat dictionary representation of available metadata.""" - fields: dict[str, str | int | float | list[str] | dict[str, list[Image]]] = {} + def as_dict(self) -> dict[ + str, str | int | float | list[str] | dict[str, list[Image]]]: + """Return a flat dictionary representation of available + metadata.""" + fields: dict[ + str, str | int | float | list[str] | dict[str, list[Image]]] = {} for key, value in self.__dict__.items(): if key.startswith('_'): continue @@ -179,7 +190,8 @@ def as_dict(self) -> dict[str, str | int | float | list[str] | dict[str, list[Im @classmethod def _get_parser_for_filename( - cls, filename: bytes | str | PathLike[Any]) -> type[TinyTag] | None: + cls, filename: bytes | str | PathLike[Any] + ) -> type[TinyTag] | None: if cls._file_extension_mapping is None: cls._file_extension_mapping = { ('.mp1', '.mp2', '.mp3'): _ID3, @@ -187,7 +199,8 @@ def _get_parser_for_filename( ('.wav',): _Wave, ('.flac',): _Flac, ('.wma',): _Wma, - ('.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc'): _MP4, + ('.m4b', '.m4a', '.m4r', '.m4v', '.mp4', + '.aax', '.aaxc'): _MP4, ('.aiff', '.aifc', '.aif', '.afc'): _Aiff, } filename = fsdecode(filename).lower() @@ -197,15 +210,19 @@ def _get_parser_for_filename( return None @classmethod - def _get_parser_for_file_handle(cls, fh: BinaryIO) -> type[TinyTag] | None: + def _get_parser_for_file_handle( + cls, + filehandle: BinaryIO + ) -> type[TinyTag] | None: # https://en.wikipedia.org/wiki/List_of_file_signatures - header = fh.read(35) - fh.seek(0) + header = filehandle.read(35) + filehandle.seek(0) if header[:3] == b'ID3' or header[:2] == b'\xff\xfb': return _ID3 if header[:4] == b'fLaC': return _Flac - if ((header[4:8] == b'ftyp' and header[8:11] in {b'M4A', b'M4B', b'aax'}) + if ((header[4:8] == b'ftyp' + and header[8:11] in {b'M4A', b'M4B', b'aax'}) or b'\xff\xf1' in header): return _MP4 if (header[:4] == b'OggS' @@ -214,17 +231,21 @@ def _get_parser_for_file_handle(cls, fh: BinaryIO) -> type[TinyTag] | None: return _Ogg if header[:4] == b'RIFF' and header[8:12] == b'WAVE': return _Wave - if header[:16] == b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA\x00\x62\xCE\x6C': + if header[:16] == (b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA' + b'\x00\x62\xCE\x6C'): return _Wma if header[:4] == b'FORM' and header[8:12] in {b'AIFF', b'AIFC'}: return _Aiff return None @classmethod - def _get_parser_class(cls, filename: bytes | str | PathLike[Any] | None = None, - filehandle: BinaryIO | None = None) -> type[TinyTag]: - if cls != TinyTag: # if `get` is invoked on TinyTag, find parser by ext - return cls # otherwise use the class on which `get` was invoked + def _get_parser_class( + cls, + filename: bytes | str | PathLike[Any] | None = None, + filehandle: BinaryIO | None = None + ) -> type[TinyTag]: + if cls != TinyTag: + return cls if filename: parser_class = cls._get_parser_for_filename(filename) if parser_class is not None: @@ -234,7 +255,8 @@ def _get_parser_class(cls, filename: bytes | str | PathLike[Any] | None = None, 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') + raise UnsupportedFormatError( + 'No tag reader found to support file type') def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._parse_tags = tags @@ -260,7 +282,8 @@ def _set_field(self, fieldname: str, value: str | int | float, return extra_values.append(value) if DEBUG: - print(f'Setting extra field "{fieldname}" to "{extra_values!r}"') + print( + f'Setting extra field "{fieldname}" to "{extra_values!r}"') self.extra[fieldname] = extra_values return old_value = self.__dict__.get(fieldname) @@ -270,7 +293,9 @@ def _set_field(self, fieldname: str, value: str | int | float, values = new_value.split('\x00') for index, i_value in enumerate(values): if index or old_value and i_value != old_value: - self._set_field(self._EXTRA_PREFIX + fieldname, i_value, check_conflict=False) + self._set_field( + self._EXTRA_PREFIX + fieldname, i_value, + check_conflict=False) continue new_value = i_value if old_value: @@ -297,22 +322,24 @@ def _update(self, other: TinyTag) -> None: for extra_key, extra_values in other.extra.items(): for extra_value in extra_values: self._set_field( - self._EXTRA_PREFIX + extra_key, extra_value, check_conflict=False) + self._EXTRA_PREFIX + extra_key, extra_value, + check_conflict=False) elif isinstance(value, Images): - self.images._update(value) + self.images._update(value) # pylint: disable=protected-access elif value is not None: self._set_field(key, value) @staticmethod def _unpad(s: str) -> str: - # strings in mp3 and asf *may* be terminated with a zero byte at the end - return s.strip('\x00') + # certain strings *may* be terminated with a zero byte at the end + return s.strip('b\x00') def get_image(self) -> bytes | None: """Deprecated, use images.any instead.""" from warnings import warn # pylint: disable=import-outside-toplevel - warn('get_image() is deprecated, and will be removed in a future 2.x release. ' - 'Use images.any instead.', DeprecationWarning, stacklevel=2) + warn('get_image() is deprecated, and will be removed in a future 2.x ' + 'release. Use images.any instead.', + DeprecationWarning, stacklevel=2) image = self.images.any return image.data if image is not None else None @@ -321,7 +348,8 @@ def audio_offset(self) -> None: """Obsolete.""" from warnings import warn # pylint: disable=import-outside-toplevel warn('audio_offset attribute is obsolete, and will be ' - 'removed in a future 2.x release', DeprecationWarning, stacklevel=2) + 'removed in a future 2.x release', + DeprecationWarning, stacklevel=2) class Extra(_Extra): @@ -342,7 +370,10 @@ def __init__(self) -> None: self.__dict__: dict[str, list[Image] | ImagesExtra] def __repr__(self) -> str: - return str({key: value for key, value in self.__dict__.items() if not key.startswith('_')}) + return str({ + key: value for key, value in self.__dict__.items() + if not key.startswith('_') + }) @property def any(self) -> Image | None: @@ -395,19 +426,24 @@ def _update(self, other: Images) -> None: if isinstance(value, ImagesExtra): for extra_key, extra_values in value.items(): for image_extra in extra_values: - self._set_field(self._EXTRA_PREFIX + extra_key, image_extra) + self._set_field( + self._EXTRA_PREFIX + extra_key, image_extra) continue for image in value: self._set_field(key, image) class ImagesExtra(_ImagesExtra): - """A dictionary containing additional images embedded in an audio file.""" + """A dictionary containing additional images embedded in an audio + file.""" class Image: """A class representing an image embedded in an audio file.""" - def __init__(self, name: str, data: bytes, mime_type: str | None = None) -> None: + def __init__(self, + name: str, + data: bytes, + mime_type: str | None = None) -> None: self.name = name self.data = data self.mime_type = mime_type @@ -422,8 +458,12 @@ def __repr__(self) -> str: class _MP4(TinyTag): - # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html - # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html + """MP4 Audio Parser + + https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html + https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html + """ + # pylint: disable=protected-access class _Parser: atom_decoder_by_type: dict[ @@ -479,7 +519,9 @@ def _unpack_png_image(cls, data: bytes) -> Image: @classmethod def _unpack_integer(cls, value: bytes, signed: bool = True) -> str: - fmts = cls._UNPACK_SIGNED_FORMATS if signed else cls._UNPACK_UNSIGNED_FORMATS + fmts = cls._UNPACK_UNSIGNED_FORMATS + if signed: + fmts = cls._UNPACK_SIGNED_FORMATS value_len = len(value) if value_len in fmts: return str(unpack(fmts[value_len], value)[0]) @@ -490,43 +532,48 @@ def _unpack_integer_unsigned(cls, value: bytes) -> str: return cls._unpack_integer(value, signed=False) @classmethod - def _make_data_atom_parser( - cls, fieldname: str) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: - def _parse_data_atom(data_atom: bytes) -> dict[str, int | str | bytes | Image]: + def _make_data_parser( + cls, fieldname: str + ) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: + def _parse_data_atom( + data_atom: bytes + ) -> dict[str, int | str | bytes | Image]: data_type = unpack('>I', data_atom[:4])[0] if cls.atom_decoder_by_type is None: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 cls.atom_decoder_by_type = { # 0: 'reserved' - 1: cls._unpack_utf_8_string, # UTF-8 - 2: cls._unpack_utf_16_string, # UTF-16 - 3: cls._unpack_shift_jis_string, # S/JIS + 1: cls._unpack_utf_8_string, # UTF-8 + 2: cls._unpack_utf_16_string, # UTF-16 + 3: cls._unpack_shift_jis_string, # S/JIS # 16: duration in millis - 13: cls._unpack_jpeg_image, # JPEG - 14: cls._unpack_png_image, # PNG - 21: cls._unpack_integer, # BE Signed int - 22: cls._unpack_integer_unsigned, # BE Unsigned int - 65: cls._unpack_integer, # 8-bit Signed int - 66: cls._unpack_integer, # BE 16-bit Signed int - 67: cls._unpack_integer, # BE 32-bit Signed int - 74: cls._unpack_integer, # BE 64-bit Signed int - 75: cls._unpack_integer_unsigned, # 8-bit Unsigned int - 76: cls._unpack_integer_unsigned, # BE 16-bit Unsigned int - 77: cls._unpack_integer_unsigned, # BE 32-bit Unsigned int - 78: cls._unpack_integer_unsigned, # BE 64-bit Unsigned int + 13: cls._unpack_jpeg_image, # JPEG + 14: cls._unpack_png_image, # PNG + 21: cls._unpack_integer, # BE Signed + 22: cls._unpack_integer_unsigned, # BE Unsigned + 65: cls._unpack_integer, # 8-bit Signed + 66: cls._unpack_integer, # BE 16-bit Signed + 67: cls._unpack_integer, # BE 32-bit Signed + 74: cls._unpack_integer, # BE 64-bit Signed + 75: cls._unpack_integer_unsigned, # 8-bit Unsigned + 76: cls._unpack_integer_unsigned, # BE 16-bit Unsigned + 77: cls._unpack_integer_unsigned, # BE 32-bit Unsigned + 78: cls._unpack_integer_unsigned, # BE 64-bit Unsigned } conversion = cls.atom_decoder_by_type.get(data_type) if conversion is None: if DEBUG: - print(f'Cannot convert data type: {data_type}', file=stderr) + print(f'Cannot convert data type: {data_type}', + file=stderr) return {} # don't know how to convert data atom # skip header & null-bytes, convert rest return {fieldname: conversion(data_atom[8:])} return _parse_data_atom @classmethod - def _make_number_parser( - cls, fieldname1: str, fieldname2: str) -> Callable[[bytes], dict[str, int]]: + def _make_num_parser( + cls, fieldname1: str, fieldname2: str + ) -> Callable[[bytes], dict[str, int]]: def _(data_atom: bytes) -> dict[str, int]: number_data = data_atom[8:14] numbers = unpack('>3H', number_data) @@ -536,9 +583,10 @@ def _(data_atom: bytes) -> dict[str, int]: @classmethod def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: - # dunno why the genre is offset by -1 but that's how mutagen does it + # dunno why genre is offset by -1 but that's how mutagen does it idx = unpack('>H', data_atom[8:])[0] - 1 result = {} + # pylint: disable=protected-access if idx < len(_ID3._ID3V1_GENRES): result['genre'] = _ID3._ID3V1_GENRES[idx] return result @@ -550,7 +598,9 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: break @classmethod - def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | Image]: + def _parse_custom_field( + cls, data: bytes + ) -> dict[str, int | str | bytes | Image]: fh = BytesIO(data) header_size = 8 field_name = None @@ -562,6 +612,7 @@ def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | Image if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() field_name = atom_value.decode('utf-8', 'replace') + # pylint: disable=protected-access field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get( field_name, TinyTag._EXTRA_PREFIX + field_name) elif atom_type == b'data': @@ -571,7 +622,7 @@ def _parse_custom_field(cls, data: bytes) -> dict[str, int | str | bytes | Image atom_header = fh.read(header_size) # read next atom if len(data_atom) < 8 or field_name is None: return {} - parser = cls._make_data_atom_parser(field_name) + parser = cls._make_data_parser(field_name) return parser(data_atom) @classmethod @@ -608,7 +659,8 @@ def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: channels = data[49] avg_br, sr = unpack('>II', data[56:64]) avg_br /= 1000 # kbit/s - return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, 'bitdepth': bitdepth} + return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, + 'bitdepth': bitdepth} @classmethod def _parse_mvhd(cls, data: bytes) -> dict[str, float]: @@ -627,36 +679,36 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # Leaves of the parser tree are callables which receive the atom data. # callables return {fieldname: value} which is updates the TinyTag. _META_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { - # see: http://atomicparsley.sourceforge.net/mpeg-4files.html - # and: https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 - b'\xa9ART': {b'data': _Parser._make_data_atom_parser('artist')}, - b'\xa9alb': {b'data': _Parser._make_data_atom_parser('album')}, - b'\xa9cmt': {b'data': _Parser._make_data_atom_parser('comment')}, - b'\xa9con': {b'data': _Parser._make_data_atom_parser('extra.conductor')}, + # http://atomicparsley.sourceforge.net/mpeg-4files.html + # https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 + b'\xa9ART': {b'data': _Parser._make_data_parser('artist')}, + b'\xa9alb': {b'data': _Parser._make_data_parser('album')}, + b'\xa9cmt': {b'data': _Parser._make_data_parser('comment')}, + b'\xa9con': {b'data': _Parser._make_data_parser('extra.conductor')}, # need test-data for this - # b'cpil': {b'data': _Parser._make_data_atom_parser('extra.compilation')}, - b'\xa9day': {b'data': _Parser._make_data_atom_parser('year')}, - b'\xa9des': {b'data': _Parser._make_data_atom_parser('extra.description')}, - b'\xa9dir': {b'data': _Parser._make_data_atom_parser('extra.director')}, - b'\xa9gen': {b'data': _Parser._make_data_atom_parser('genre')}, - b'\xa9lyr': {b'data': _Parser._make_data_atom_parser('extra.lyrics')}, - b'\xa9mvn': {b'data': _Parser._make_data_atom_parser('movement')}, - b'\xa9nam': {b'data': _Parser._make_data_atom_parser('title')}, - b'\xa9pub': {b'data': _Parser._make_data_atom_parser('extra.publisher')}, - b'\xa9too': {b'data': _Parser._make_data_atom_parser('extra.encoded_by')}, - b'\xa9wrt': {b'data': _Parser._make_data_atom_parser('composer')}, - b'aART': {b'data': _Parser._make_data_atom_parser('albumartist')}, - b'cprt': {b'data': _Parser._make_data_atom_parser('extra.copyright')}, - b'desc': {b'data': _Parser._make_data_atom_parser('extra.description')}, - b'disk': {b'data': _Parser._make_number_parser('disc', 'disc_total')}, + # b'cpil': {b'data': _Parser._make_data_parser('extra.compilation')}, + b'\xa9day': {b'data': _Parser._make_data_parser('year')}, + b'\xa9des': {b'data': _Parser._make_data_parser('extra.description')}, + b'\xa9dir': {b'data': _Parser._make_data_parser('extra.director')}, + b'\xa9gen': {b'data': _Parser._make_data_parser('genre')}, + b'\xa9lyr': {b'data': _Parser._make_data_parser('extra.lyrics')}, + b'\xa9mvn': {b'data': _Parser._make_data_parser('movement')}, + b'\xa9nam': {b'data': _Parser._make_data_parser('title')}, + b'\xa9pub': {b'data': _Parser._make_data_parser('extra.publisher')}, + b'\xa9too': {b'data': _Parser._make_data_parser('extra.encoded_by')}, + b'\xa9wrt': {b'data': _Parser._make_data_parser('composer')}, + b'aART': {b'data': _Parser._make_data_parser('albumartist')}, + b'cprt': {b'data': _Parser._make_data_parser('extra.copyright')}, + b'desc': {b'data': _Parser._make_data_parser('extra.description')}, + b'disk': {b'data': _Parser._make_num_parser('disc', 'disc_total')}, b'gnre': {b'data': _Parser._parse_id3v1_genre}, - b'trkn': {b'data': _Parser._make_number_parser('track', 'track_total')}, - b'tmpo': {b'data': _Parser._make_data_atom_parser('extra.bpm')}, - b'covr': {b'data': _Parser._make_data_atom_parser('images.front_cover')}, + b'trkn': {b'data': _Parser._make_num_parser('track', 'track_total')}, + b'tmpo': {b'data': _Parser._make_data_parser('extra.bpm')}, + b'covr': {b'data': _Parser._make_data_parser('images.front_cover')}, b'----': _Parser._parse_custom_field, }}}}} - # see: https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html + # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html _AUDIO_DATA_TREE = { b'moov': { b'mvhd': _Parser._parse_mvhd, @@ -676,7 +728,9 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _parse_tag(self, fh: BinaryIO) -> None: self._traverse_atoms(fh, path=self._META_DATA_TREE) - def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], + def _traverse_atoms(self, + fh: BinaryIO, + path: dict[bytes, Any], stop_pos: int | None = None, curr_path: list[bytes] | None = None) -> None: header_size = 8 @@ -690,7 +744,8 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], atom_header = fh.read(header_size) continue if DEBUG: - print(f'{" " * 4 * len(curr_path)} pos: {fh.tell() - header_size} ' + print(f'{" " * 4 * len(curr_path)} ' + f'pos: {fh.tell() - header_size} ' f'atom: {atom_type!r} len: {atom_size + header_size}') if atom_type in self._VERSIONED_ATOMS: # jump atom version for now fh.seek(4, SEEK_CUR) @@ -709,7 +764,8 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) if fieldname.startswith('images.'): if self._load_image: - self.images._set_field(fieldname[len('images.'):], value) + self.images._set_field( + fieldname[len('images.'):], value) elif fieldname: self._set_field(fieldname, value) # if no action was specified using dict or callable, jump over atom @@ -722,6 +778,8 @@ def _traverse_atoms(self, fh: BinaryIO, path: dict[bytes, Any], class _ID3(TinyTag): + """MP3 Parser""" + _ID3_MAPPING = { # Mapping from Frame ID to a field of the TinyTag # https://exiftool.org/TagNames/ID3.html @@ -789,11 +847,11 @@ class _ID3(TinyTag): 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock', 'Progressive Rock', 'Psychedelic Rock', 'Symphonic Rock', 'Slow Rock', 'Big Band', 'Chorus', 'Easy listening', 'Acoustic', 'Humour', 'Speech', - 'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', 'Booty Bass', - 'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', 'Tango', 'Samba', - 'Folklore', 'Ballad', 'Power Ballad', 'Rhythmic Soul', 'Freestyle', - 'Duet', 'Punk Rock', 'Drum Solo', 'A capella', 'Euro-House', - 'Dance Hall', 'Goa', 'Drum & Bass', + 'Chanson', 'Opera', 'Chamber Music', 'Sonata', 'Symphony', + 'Booty Bass', 'Primus', 'Porn Groove', 'Satire', 'Slow Jam', 'Club', + 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power Ballad', + 'Rhythmic Soul', 'Freestyle', 'Duet', 'Punk Rock', 'Drum Solo', + 'A capella', 'Euro-House', 'Dance Hall', 'Goa', 'Drum & Bass', # according to https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres: 'Club-House', 'Hardcore Techno', 'Terror', 'Indie', 'BritPop', @@ -810,7 +868,8 @@ class _ID3(TinyTag): 'Math Rock', 'New Romantic', 'Nu-Breakz', 'Post-Punk', 'Post-Rock', 'Psytrance', 'Shoegaze', 'Space Rock', 'Trop Rock', 'World Music', 'Neoclassical', 'Audiobook', 'Audio Theatre', 'Neue Deutsche Welle', - 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', 'Psybient', + 'Podcast', 'Indie Rock', 'G-Funk', 'Dubstep', 'Garage Rock', + 'Psybient', ) _ID3V2_2_IMAGE_FORMATS = { 'bmp': 'image/bmp', @@ -850,18 +909,23 @@ class _ID3(TinyTag): (22050, 24000, 16000), # MPEG 2 (44100, 48000, 32000), # MPEG 1 ) - _V1L1 = (0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448, 0) - _V1L2 = (0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 0) - _V1L3 = (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0) - _V2L1 = (0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256, 0) + _V1L1 = (0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, + 448, 0) + _V1L2 = (0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, + 384, 0) + _V1L3 = (0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, + 320, 0) + _V2L1 = (0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, + 256, 0) _V2L2 = (0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0) _V2L3 = _V2L2 _NONE = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) - _BITRATE_BY_VERSION_BY_LAYER = ( - (_NONE, _V2L3, _V2L2, _V2L1), # MPEG Version 2.5 # note that the layers go - (_NONE, _NONE, _NONE, _NONE), # reserved # from 3 to 1 by design. - (_NONE, _V2L3, _V2L2, _V2L1), # MPEG Version 2 # the first layer id is - (_NONE, _V1L3, _V1L2, _V1L1), # MPEG Version 1 # reserved + _BITRATE_VERSION_LAYERS = ( + # note that layers go from 3 to 1 by design, first layer id is reserved + (_NONE, _V2L3, _V2L2, _V2L1), # MPEG Version 2.5 + (_NONE, _NONE, _NONE, _NONE), # reserved + (_NONE, _V2L3, _V2L2, _V2L1), # MPEG Version 2 + (_NONE, _V1L3, _V1L2, _V1L1), # MPEG Version 1 ) _SAMPLES_PER_FRAME = 1152 # the default frame size for mp3 _CHANNELS_PER_CHANNEL_MODE = ( @@ -897,7 +961,8 @@ def _determine_duration(self, fh: BinaryIO) -> None: if self._bytepos_after_id3v2 == -1: self._parse_id3v2_header(fh) - max_estimation_frames = (_ID3._MAX_ESTIMATION_SEC * 44100) // _ID3._SAMPLES_PER_FRAME + max_estimation_frames = ( + (self._MAX_ESTIMATION_SEC * 44100) // self._SAMPLES_PER_FRAME) frame_size_accu = 0 audio_offset = 0 frames = 0 # count frames for determining mp3 duration @@ -927,18 +992,21 @@ def _determine_duration(self, fh: BinaryIO) -> None: # check for eleven 1s, validate bitrate and sample rate if (not header[:2] > b'\xFF\xE0' or (first_mpeg_id is not None and first_mpeg_id != mpeg_id) - or br_id > 14 or br_id == 0 or sr_id == 3 or layer_id == 0 or mpeg_id == 1): - idx = header.find(b'\xFF', 1) # invalid frame, find next sync header + or br_id > 14 or br_id == 0 or sr_id == 3 or layer_id == 0 + or mpeg_id == 1): + # invalid frame, find next sync header + idx = header.find(b'\xFF', 1) if idx == -1: - idx = header_len # not found: jump over the current peek buffer + # not found: jump over the current peek buffer + idx = header_len walker.seek(max(idx, 1), SEEK_CUR) continue if first_mpeg_id is None: first_mpeg_id = mpeg_id self.channels = self._CHANNELS_PER_CHANNEL_MODE[channel_mode] - frame_bitrate = self._BITRATE_BY_VERSION_BY_LAYER[mpeg_id][layer_id][br_id] + frame_br = self._BITRATE_VERSION_LAYERS[mpeg_id][layer_id][br_id] self.samplerate = samplerate = self._SAMPLE_RATES[mpeg_id][sr_id] - frame_length = (144000 * frame_bitrate) // samplerate + padding + frame_length = (144000 * frame_br) // samplerate + padding # There might be a xing header in the first frame that contains # all the info we need, otherwise parse multiple frames to find the # accurate average bitrate @@ -951,27 +1019,30 @@ def _determine_duration(self, fh: BinaryIO) -> None: xframes, byte_count = self._parse_xing_header(walker) if xframes > 0 and byte_count > 0: # MPEG-2 Audio Layer III uses 576 samples per frame - samples_per_frame = 576 if mpeg_id <= 2 else self._SAMPLES_PER_FRAME - self.duration = duration = xframes * samples_per_frame / samplerate - self.bitrate = byte_count * 8 / duration / 1000 + samples_pf = self._SAMPLES_PER_FRAME + if mpeg_id <= 2: + samples_pf = 576 + self.duration = dur = xframes * samples_pf / samplerate + self.bitrate = byte_count * 8 / dur / 1000 return walker.seek(walker_offset) frames += 1 # it's most probably an mp3 frame - bitrate_accu += frame_bitrate + bitrate_accu += frame_br if frames == 1: audio_offset = file_offset + walker.tell() if frames <= self._CBR_DETECTION_FRAME_COUNT: - last_bitrates.add(frame_bitrate) + last_bitrates.add(frame_br) frame_size_accu += frame_length # if bitrate does not change over time its probably CBR - is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT and len(last_bitrates) == 1) + is_cbr = (frames == self._CBR_DETECTION_FRAME_COUNT + and len(last_bitrates) == 1) if frames == max_estimation_frames or is_cbr: # try to estimate duration fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) - audio_stream_size = fh.tell() - audio_offset - est_frame_count = audio_stream_size / (frame_size_accu / frames) + stream_size = fh.tell() - audio_offset + est_frame_count = stream_size / (frame_size_accu / frames) samples = est_frame_count * self._SAMPLES_PER_FRAME self.duration = samples / samplerate self.bitrate = bitrate_accu / frames @@ -1026,7 +1097,8 @@ def _parse_id3v1(self, fh: BinaryIO) -> None: return def asciidecode(x: bytes) -> str: - return self._unpad(x.decode(self._default_encoding or 'latin1', 'replace')) + return self._unpad( + x.decode(self._default_encoding or 'latin1', 'replace')) # Only set fields that were not set by ID3v2 tags, as ID3v1 # tags are more likely to be outdated or have encoding issues fields = fh.read(30 + 30 + 30 + 4 + 30 + 1) @@ -1066,13 +1138,17 @@ def __parse_custom_field(self, content: str) -> bool: value = value.lstrip('\ufeff') if custom_field_name_lower and separator and value: field_name = self._ID3_MAPPING_CUSTOM.get( - custom_field_name_lower, self._EXTRA_PREFIX + custom_field_name_lower) + custom_field_name_lower, + self._EXTRA_PREFIX + custom_field_name_lower) self._set_field(field_name, value) return True return False @classmethod - def _create_tag_image(cls, data: bytes, pic_type: int, mime_type: str | None = None, + def _create_tag_image(cls, + data: bytes, + pic_type: int, + mime_type: str | None = None, description: str | None = None) -> tuple[str, Image]: field_name = cls._UNKNOWN_IMAGE_TYPE if 0 <= pic_type <= len(cls._IMAGE_TYPES): @@ -1096,23 +1172,23 @@ def _index_utf16(s: bytes, search: bytes) -> int: def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 - frame_header_size = 6 if id3version == 2 else 10 + header_size = 6 if id3version == 2 else 10 frame_size_bytes = 3 if id3version == 2 else 4 is_synchsafe_int = id3version == 4 - frame_header_data = fh.read(frame_header_size) - if len(frame_header_data) != frame_header_size: + header = fh.read(header_size) + if len(header) != header_size: return 0 - frame_id = self._decode_string(frame_header_data[:frame_size_bytes]) + frame_id = self._decode_string(header[:frame_size_bytes]) frame_size: int if frame_size_bytes == 3: - frame_size = unpack('>I', b'\x00' + frame_header_data[3:6])[0] + frame_size = unpack('>I', b'\x00' + header[3:6])[0] elif is_synchsafe_int: - frame_size = self._unsynchsafe(unpack('4B', frame_header_data[4:8])) + frame_size = self._unsynchsafe(unpack('4B', header[4:8])) else: - frame_size = unpack('>I', frame_header_data[4:8])[0] + frame_size = unpack('>I', header[4:8])[0] if DEBUG: - print(f'Found id3 Frame {frame_id} at {fh.tell()}-{fh.tell() + frame_size} ' - f'of {self.filesize}') + print(f'Found id3 Frame {frame_id} at ' + f'{fh.tell()}-{fh.tell() + frame_size} of {self.filesize}') if frame_size > 0: # flags = frame[1+frame_size_bytes:] # dont care about flags. content = fh.read(frame_size) @@ -1141,14 +1217,14 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: # funky: id3v1 genre hidden in a id3v2 field if value.isdecimal(): genre_id = int(value) - # funkier: the TCO may contain genres in parens, e.g. '(13)' + # funkier: the TCO may contain genres in parens, e.g '(13)' elif value[:1] == '(': end_pos = value.find(')') parens_text = value[1:end_pos] if end_pos > 0 and parens_text.isdecimal(): genre_id = int(parens_text) - if 0 <= genre_id < len(_ID3._ID3V1_GENRES): - value = _ID3._ID3V1_GENRES[genre_id] + if 0 <= genre_id < len(self._ID3V1_GENRES): + value = self._ID3V1_GENRES[genre_id] if should_set_field: self._set_field(fieldname, value) elif frame_id in self._CUSTOM_FRAME_IDS: @@ -1164,76 +1240,90 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: if frame_id == 'PIC': # ID3 v2.2: imgformat = self._decode_string(content[1:4]).lower() mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat) - desc_start_pos = 1 + 3 + 1 # skip encoding (1), imgformat (3), pictype(1) + # skip encoding (1), imgformat (3), pictype(1) + desc_start_pos = 5 else: # ID3 v2.3+ - mime_type_end_pos = content.index(b'\x00', 1) - mime_type = self._decode_string(content[1:mime_type_end_pos]).lower() - if mime_type in self._ID3V2_2_IMAGE_FORMATS: # ID3 v2.2 format in v2.3... + mime_end_pos = content.index(b'\x00', 1) + mime_type = self._decode_string( + content[1:mime_end_pos]).lower() + # ID3 v2.2 format in v2.3... + if mime_type in self._ID3V2_2_IMAGE_FORMATS: mime_type = self._ID3V2_2_IMAGE_FORMATS[mime_type] - desc_start_pos = mime_type_end_pos + 1 + 1 # skip mtype, pictype(1) + # skip mtype, pictype(1) + desc_start_pos = mime_end_pos + 2 pic_type = content[desc_start_pos - 1] # latin1 and utf-8 are 1 byte - termination = b'\x00' if encoding in {b'\x00', b'\x03'} else b'\x00\x00' - desc_length = self._index_utf16(content[desc_start_pos:], termination) - desc_end_pos = desc_start_pos + desc_length + len(termination) - description = self._decode_string(content[desc_start_pos:desc_end_pos]) + if encoding in {b'\x00', b'\x03'}: + termination = b'\x00' + else: + termination = b'\x00\x00' + desc_len = self._index_utf16( + content[desc_start_pos:], termination) + desc_end_pos = desc_start_pos + desc_len + len(termination) + desc = self._decode_string( + content[desc_start_pos:desc_end_pos]) field_name, image = self._create_tag_image( - content[desc_end_pos:], pic_type, mime_type, description) + content[desc_end_pos:], pic_type, mime_type, desc) + # pylint: disable=protected-access self.images._set_field(field_name, image) elif frame_id not in self._DISALLOWED_FRAME_IDS: # unknown, try to add to extra dict if self._parse_tags: value = self._decode_string(content) if value: - self._set_field(self._EXTRA_PREFIX + frame_id.lower(), value) + self._set_field( + self._EXTRA_PREFIX + frame_id.lower(), value) return frame_size return 0 - def _decode_string(self, bytestr: bytes, language: bool = False) -> str: + def _decode_string(self, value: bytes, language: bool = False) -> str: default_encoding = 'ISO-8859-1' if self._default_encoding: default_encoding = self._default_encoding # it's not my fault, this is the spec. - first_byte = bytestr[:1] + first_byte = value[:1] if first_byte == b'\x00': # ISO-8859-1 - bytestr = bytestr[1:] + value = value[1:] encoding = default_encoding elif first_byte == b'\x01': # UTF-16 with BOM - bytestr = bytestr[1:] + value = value[1:] # remove language (but leave BOM) if language: - if bytestr[3:5] in {b'\xfe\xff', b'\xff\xfe'}: - bytestr = bytestr[3:] - if bytestr[:3].isalpha(): - bytestr = bytestr[3:] # remove language - bytestr = bytestr.lstrip(b'\x00') # strip optional additional null bytes + if value[3:5] in {b'\xfe\xff', b'\xff\xfe'}: + value = value[3:] + if value[:3].isalpha(): + value = value[3:] # remove language + # strip optional additional null bytes + value = value.lstrip(b'\x00') # read byte order mark to determine endianness - encoding = 'UTF-16be' if bytestr[:2] == b'\xfe\xff' else 'UTF-16le' + encoding = 'UTF-16be' if value[:2] == b'\xfe\xff' else 'UTF-16le' # strip the bom if it exists - if bytestr[:2] in {b'\xfe\xff', b'\xff\xfe'}: - bytestr = bytestr[2:] if len(bytestr) % 2 == 0 else bytestr[2:-1] + if value[:2] in {b'\xfe\xff', b'\xff\xfe'}: + value = value[2:] if len(value) % 2 == 0 else value[2:-1] # remove ADDITIONAL EXTRA BOM :facepalm: - if bytestr[:4] == b'\x00\x00\xff\xfe': - bytestr = bytestr[4:] + if value[:4] == b'\x00\x00\xff\xfe': + value = value[4:] elif first_byte == b'\x02': # UTF-16LE # strip optional null byte, if byte count uneven - bytestr = bytestr[1:-1] if len(bytestr) % 2 == 0 else bytestr[1:] + value = value[1:-1] if len(value) % 2 == 0 else value[1:] encoding = 'UTF-16le' elif first_byte == b'\x03': # UTF-8 - bytestr = bytestr[1:] + value = value[1:] encoding = 'UTF-8' else: encoding = default_encoding # wild guess - if language and bytestr[:3].isalpha(): - bytestr = bytestr[3:] # remove language - return self._unpad(bytestr.decode(encoding, 'replace')) + if language and value[:3].isalpha(): + value = value[3:] # remove language + return self._unpad(value.decode(encoding, 'replace')) @staticmethod - def _unsynchsafe(intarr: tuple[int, ...]) -> int: - return (intarr[0] << 21) + (intarr[1] << 14) + (intarr[2] << 7) + intarr[3] + def _unsynchsafe(ints: tuple[int, ...]) -> int: + return (ints[0] << 21) + (ints[1] << 14) + (ints[2] << 7) + ints[3] class _Ogg(TinyTag): + """OGG Parser""" + _VORBIS_MAPPING = { 'album': 'album', 'albumartist': 'albumartist', @@ -1311,7 +1401,8 @@ def _parse_tag(self, fh: BinaryIO) -> None: for packet in self._parse_pages(fh): if packet[:7] == b"\x01vorbis": if self._parse_duration: - self.channels, self.samplerate = unpack(" None: version, ch = unpack("BB", packet[8:10]) if (version & 0xF0) == 0: # only major version 0 supported self.channels = ch - self.samplerate = 48000 # internally opus always uses 48khz + self.samplerate = 48000 # opus always uses 48khz elif packet[:8] == b'OpusTags': if self._parse_tags: # parse opus metadata: walker = BytesIO(packet) @@ -1334,12 +1425,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif packet[:5] == b'\x7fFLAC': # https://xiph.org/flac/ogg_mapping.html walker = BytesIO(packet) - walker.seek(9, SEEK_CUR) # jump over header name, version and number of headers + # jump over header name, version and number of headers + walker.seek(9, SEEK_CUR) + # pylint: disable=protected-access flactag = _Flac() flactag._filehandler = walker flactag.filesize = self.filesize - flactag._load(tags=self._parse_tags, duration=self._parse_duration, - image=self._load_image) + flactag._load( + tags=self._parse_tags, duration=self._parse_duration, + image=self._load_image) self._update(flactag) check_flac_second_packet = True elif check_flac_second_packet: @@ -1348,7 +1442,8 @@ def _parse_tag(self, fh: BinaryIO) -> None: walker = BytesIO(packet) meta_header = walker.read(4) block_type = meta_header[0] & 0x7f - if block_type == _Flac.METADATA_VORBIS_COMMENT: + # pylint: disable=protected-access + if block_type == _Flac._VORBIS_COMMENT: self._parse_vorbis_comment(walker) check_flac_second_packet = False elif packet[:8] == b'Speex ': @@ -1360,22 +1455,27 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif check_speex_second_packet: if self._parse_tags: walker = BytesIO(packet) - length = unpack('I', walker.read(4))[0] # starts with a comment string + # starts with a comment string + length = unpack('I', walker.read(4))[0] comment = walker.read(length).decode('utf-8', 'replace') self._set_field('comment', comment) - self._parse_vorbis_comment(walker, contains_vendor=False) # other tags + # other tags + self._parse_vorbis_comment(walker, has_vendor=False) check_speex_second_packet = False else: if DEBUG: - print('Unsupported Ogg page type: ', packet[:16], file=stderr) + print('Unsupported Ogg page type: ', + packet[:16], file=stderr) break self._tags_parsed = True - def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> None: + def _parse_vorbis_comment(self, + fh: BinaryIO, + has_vendor: bool = True) -> None: # for the spec, see: http://xiph.org/vorbis/doc/v-comment.html # discnumber tag based on: https://en.wikipedia.org/wiki/Vorbis_comment # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/Vorbis.html - if contains_vendor: + if has_vendor: vendor_length = unpack('I', fh.read(4))[0] fh.seek(vendor_length, SEEK_CUR) # jump over vendor elements = unpack('I', fh.read(4))[0] @@ -1384,23 +1484,27 @@ def _parse_vorbis_comment(self, fh: BinaryIO, contains_vendor: bool = True) -> N keyvalpair = fh.read(length).decode('utf-8', 'replace') if '=' in keyvalpair: key, value = keyvalpair.split('=', 1) - key_lowercase = key.lower() - - if key_lowercase == "metadata_block_picture" and self._load_image: + key_lower = key.lower() + if key_lower == "metadata_block_picture" and self._load_image: if DEBUG: print('Found Vorbis Image', key, value[:64]) - fieldname, fieldvalue = _Flac._parse_image(BytesIO(a2b_base64(value))) + # pylint: disable=protected-access + fieldname, fieldvalue = _Flac._parse_image( + BytesIO(a2b_base64(value))) self.images._set_field(fieldname, fieldvalue) else: if DEBUG: print('Found Vorbis Comment', key, value[:64]) fieldname = self._VORBIS_MAPPING.get( - key_lowercase, self._EXTRA_PREFIX + key_lowercase) # custom field - if fieldname in {'track', 'disc', 'track_total', 'disc_total'}: + key_lower, self._EXTRA_PREFIX + key_lower) + if fieldname in { + 'track', 'disc', 'track_total', 'disc_total' + }: if fieldname in {'track', 'disc'} and '/' in value: value, total = value.split('/')[:2] if total.isdecimal(): - self._set_field(f'{fieldname}_total', int(total)) + self._set_field( + f'{fieldname}_total', int(total)) if value.isdecimal(): self._set_field(fieldname, int(value)) elif value: @@ -1436,7 +1540,11 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: class _Wave(TinyTag): - # https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html + """WAVE Parser + + https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html + """ + _RIFF_MAPPING = { b'INAM': 'title', b'TITL': 'title', @@ -1466,8 +1574,8 @@ def _determine_duration(self, fh: BinaryIO) -> None: self._parse_tag(fh) def _parse_tag(self, fh: BinaryIO) -> None: - # see: http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html - # and: https://en.wikipedia.org/wiki/WAV + # http://www-mmsp.ece.mcgill.ca/Documents/AudioFormats/WAVE/WAVE.html + # https://en.wikipedia.org/wiki/WAV header = fh.read(12) if header[:4] != b'RIFF' or header[8:12] != b'WAVE': raise ParseError('Invalid WAV header') @@ -1477,22 +1585,25 @@ def _parse_tag(self, fh: BinaryIO) -> None: while len(chunk_header) == 8: subchunkid = chunk_header[:4] subchunksize = unpack('I', chunk_header[4:8])[0] - subchunksize += subchunksize % 2 # IFF chunks are padded to an even number of bytes + # IFF chunks are padded to an even number of bytes + subchunksize += subchunksize % 2 if subchunkid == b'fmt ' and self._parse_duration: chunk = fh.read(subchunksize) _format_tag, channels, samplerate = unpack(' None: field = walker.read(4) while len(field) == 4: data_length = unpack('I', walker.read(4))[0] - data_length += data_length % 2 # IFF chunks are padded to an even size - data = walker.read(data_length).split(b'\x00', 1)[0] # strip zero-byte + # IFF chunks are padded to an even size + data_length += data_length % 2 + # strip zero-byte + data = walker.read(data_length).split(b'\x00', 1)[0] fieldname = self._RIFF_MAPPING.get(field) if fieldname: value = data.decode('utf-8', 'replace') @@ -1515,6 +1628,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._set_field(fieldname, value) field = walker.read(4) elif subchunkid in {b'id3 ', b'ID3 '} and self._parse_tags: + # pylint: disable=protected-access id3 = _ID3() id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) @@ -1526,13 +1640,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: class _Flac(TinyTag): - METADATA_STREAMINFO = 0 - METADATA_PADDING = 1 - METADATA_APPLICATION = 2 - METADATA_SEEKTABLE = 3 - METADATA_VORBIS_COMMENT = 4 - METADATA_CUESHEET = 5 - METADATA_PICTURE = 6 + """FLAC Parser""" + + _STREAMINFO = 0 + _PADDING = 1 + _APPLICATION = 2 + _SEEKTABLE = 3 + _VORBIS_COMMENT = 4 + _CUESHEET = 5 + _PICTURE = 6 def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: @@ -1543,6 +1659,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: header = fh.read(4) if header[:3] == b'ID3': # parse ID3 header if it exists fh.seek(-4, SEEK_CUR) + # pylint: disable=protected-access id3 = _ID3() id3._filehandler = fh id3._parse_tags = self._parse_tags @@ -1558,9 +1675,9 @@ def _parse_tag(self, fh: BinaryIO) -> None: is_last_block = header_data[0] & 0x80 size = unpack('>I', b'\x00' + header_data[1:4])[0] # http://xiph.org/flac/format.html#metadata_block_streaminfo - if block_type == self.METADATA_STREAMINFO and self._parse_duration: - info_header = fh.read(size) - if len(info_header) < 34: # invalid streaminfo + if block_type == self._STREAMINFO and self._parse_duration: + head = fh.read(size) + if len(head) < 34: # invalid streaminfo break # From the xiph documentation: # py | @@ -1578,22 +1695,25 @@ def _parse_tag(self, fh: BinaryIO) -> None: # |----- samplerate -----| |-||----| |---------~ ~----| # 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 # #---4---# #---5---# #---6---# #---7---# #--8-~ ~-12-# - self.samplerate = samplerate = unpack('>I', b'\x00' + info_header[10:13])[0] >> 4 - self.channels = ((info_header[12] >> 1) & 0x07) + 1 + sr = unpack('>I', b'\x00' + head[10:13])[0] >> 4 + self.channels = ((head[12] >> 1) & 0x07) + 1 self.bitdepth = ( - ((info_header[12] & 1) << 4) + ((info_header[13] & 0xF0) >> 4) + 1) - total_sample_bytes = bytes([info_header[13] & 0x0F]) + info_header[14:18] - total_samples = unpack('>Q', b'\x00\x00\x00' + total_sample_bytes)[0] - self.duration = duration = total_samples / samplerate + ((head[12] & 1) << 4) + ((head[13] & 0xF0) >> 4) + 1) + tot_samples_b = bytes([head[13] & 0x0F]) + head[14:18] + tot_samples = unpack('>Q', b'\x00\x00\x00' + tot_samples_b)[0] + self.duration = duration = tot_samples / sr + self.samplerate = sr if duration > 0: self.bitrate = self.filesize / duration * 8 / 1000 - elif block_type == self.METADATA_VORBIS_COMMENT and self._parse_tags: + elif block_type == self._VORBIS_COMMENT and self._parse_tags: + # pylint: disable=protected-access oggtag = _Ogg() oggtag._filehandler = fh oggtag._parse_vorbis_comment(fh) self._update(oggtag) - elif block_type == self.METADATA_PICTURE and self._load_image: + elif block_type == self._PICTURE and self._load_image: fieldname, value = self._parse_image(fh) + # pylint: disable=protected-access self.images._set_field(fieldname, value) elif block_type >= 127: break # invalid block type @@ -1601,7 +1721,6 @@ def _parse_tag(self, fh: BinaryIO) -> None: if DEBUG: print('Unknown FLAC block type', block_type) fh.seek(size, SEEK_CUR) # seek over this block - if is_last_block: break header_data = fh.read(4) @@ -1618,14 +1737,18 @@ def _parse_image(cls, fh: BinaryIO) -> tuple[str, Image]: description = fh.read(description_len).decode('utf-8', 'replace') fh.seek(16, SEEK_CUR) # jump over width, height, depth, colors pic_len = unpack('>I', fh.read(4))[0] - return _ID3._create_tag_image(fh.read(pic_len), pic_type, mime_type, description) + # pylint: disable=protected-access + return _ID3._create_tag_image( + fh.read(pic_len), pic_type, mime_type, description) class _Wma(TinyTag): - # see: - # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx - # and (japanese, but none the less helpful) - # http://uguisu.skr.jp/Windows/format_asf.html + """WMA Parser + + http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx + http://uguisu.skr.jp/Windows/format_asf.html + """ + _ASF_MAPPING = { 'WM/ARTISTS': 'artist', 'WM/TrackNumber': 'track', @@ -1654,18 +1777,20 @@ class _Wma(TinyTag): 'WM/Barcode': 'extra.barcode', 'WM/CatalogNo': 'extra.catalog_number', } - _ASF_UNPACK_FORMATS = { + _UNPACK_FORMATS = { 1: ' None: @@ -1673,10 +1798,10 @@ def _determine_duration(self, fh: BinaryIO) -> None: self._parse_tag(fh) def _parse_tag(self, fh: BinaryIO) -> None: - header = fh.read(30) # http://www.garykessler.net/library/file_sigs.html # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc521913958 - if (header[:16] != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' # 128 bit GUID + header = fh.read(30) + if (header[:16] != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' or header[-1:] != b'\x02'): raise ParseError('Invalid WMA header') while True: @@ -1687,9 +1812,10 @@ def _parse_tag(self, fh: BinaryIO) -> None: object_size = unpack(' self.filesize: break # invalid object, stop parsing. - if object_id == self._ASF_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: + if object_id == self._ASF_CONTENT_DESC and self._parse_tags: walker = BytesIO(fh.read(object_size - 24)) - (title_length, author_length, copyright_length, description_length, + (title_length, author_length, + copyright_length, description_length, rating_length) = unpack('<5H', walker.read(10)) data_blocks = { 'title': title_length, @@ -1699,40 +1825,42 @@ def _parse_tag(self, fh: BinaryIO) -> None: '_rating': rating_length, } for i_field_name, length in data_blocks.items(): - value = self._unpad(walker.read(length).decode('utf-16', 'replace')) + value = self._unpad( + walker.read(length).decode('utf-16', 'replace')) if not i_field_name.startswith('_') and value: self._set_field(i_field_name, value) - elif object_id == self._ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT and self._parse_tags: + elif object_id == self._ASF_EXT_CONTENT_DESC and self._parse_tags: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 walker = BytesIO(fh.read(object_size - 24)) descriptor_count = unpack(' None: if codec_id_format_tag == 355: # lossless self.bitdepth = unpack(' str: - """ decode _ASF_EXTENDED_CONTENT_DESCRIPTION_OBJECT values""" + """ decode _ASF_EXT_CONTENT_DESC values""" if value_type == 0: # Unicode string return cls._unpad(value.decode('utf-16', 'replace')) if 1 < value_type < 6: # DWORD / QWORD / WORD value_len = len(value) - if value_len in cls._ASF_UNPACK_FORMATS: - return str(unpack(cls._ASF_UNPACK_FORMATS[value_len], value)[0]) + if value_len in cls._UNPACK_FORMATS: + return str(unpack(cls._UNPACK_FORMATS[value_len], value)[0]) return "" class _Aiff(TinyTag): - # - # AIFF is part of the IFF family of file formats. - # - # https://en.wikipedia.org/wiki/Audio_Interchange_File_Format#Data_format - # https://web.archive.org/web/20171118222232/http://www-mmsp.ece.mcgill.ca/documents/audioformats/aiff/aiff.html - # https://web.archive.org/web/20071219035740/http://www.cnpbagwell.com/aiff-c.txt - # - # A few things about the spec: - # - # * IFF strings are not supposed to be null terminated. They sometimes are. - # * Some tools might throw more metadata into the ANNO chunk but it is - # wildly unreliable to count on it. In fact, the official spec recommends against - # using it. That said... this code throws the ANNO field into comment and hopes - # for the best. - # - # The key thing here is that AIFF metadata is usually in a handful of fields - # and the rest is an ID3 or XMP field. XMP is too complicated and only Adobe-related - # products support it. The vast majority use ID3. As such, this code inherits from - # ID3 rather than TinyTag since it does everything that needs to be done here. - # + """"AIFF Parser + + https://en.wikipedia.org/wiki/Audio_Interchange_File_Format#Data_format + https://web.archive.org/web/20171118222232/http://www-mmsp.ece.mcgill.ca/documents/audioformats/aiff/aiff.html + https://web.archive.org/web/20071219035740/http://www.cnpbagwell.com/aiff-c.txt + + A few things about the spec: + + * IFF strings are not supposed to be null terminated, but sometimes + are. + * Some tools might throw more metadata into the ANNO chunk but it is + wildly unreliable to count on it. In fact, the official spec + recommends against using it. That said... this code throws the + ANNO field into comment and hopes for the best. + + The key thing here is that AIFF metadata is usually in a handful of + fields and the rest is an ID3 or XMP field. XMP is too complicated + and only Adobe-related products support it. The vast majority use + ID3. + """ _AIFF_MAPPING = { - # - # "Name Chunk text contains the name of the sampled sound." - # - # "Author Chunk text contains one or more author names. An author in - # this case is the creator of a sampled sound." - # - # "Annotation Chunk text contains a comment. Use of this chunk is - # discouraged within FORM AIFC." Some tools: "hold my beer" - # - # "The Copyright Chunk contains a copyright notice for the sound. text - # contains a date followed by the copyright owner. The chunk ID '[c] ' - # serves as the copyright character. " Some tools: "hold my beer" - # b'NAME': 'title', b'AUTH': 'artist', b'ANNO': 'comment', @@ -1807,23 +1922,28 @@ def _parse_tag(self, fh: BinaryIO) -> None: while len(chunk_header) == 8: sub_chunk_id = chunk_header[:4] sub_chunk_size = unpack('>I', chunk_header[4:8])[0] - sub_chunk_size += sub_chunk_size % 2 # IFF chunks are padded to an even number of bytes + # IFF chunks are padded to an even number of bytes + sub_chunk_size += sub_chunk_size % 2 if sub_chunk_id in self._AIFF_MAPPING and self._parse_tags: - value = self._unpad(fh.read(sub_chunk_size).decode('utf-8', 'replace')) + value = self._unpad( + fh.read(sub_chunk_size).decode('utf-8', 'replace')) self._set_field(self._AIFF_MAPPING[sub_chunk_id], value) elif sub_chunk_id == b'COMM' and self._parse_duration: chunk = fh.read(sub_chunk_size) channels, num_frames, bitdepth = unpack('>hLh', chunk[:8]) self.channels, self.bitdepth = channels, bitdepth try: - exponent, mantissa = unpack('>HQ', chunk[8:18]) # Extended precision - samplerate = int(mantissa * (2 ** (exponent - 0x3FFF - 63))) - duration = num_frames / samplerate - bitrate = samplerate * channels * bitdepth / 1000 - self.samplerate, self.duration, self.bitrate = samplerate, duration, bitrate + # Extended precision + exp, mantissa = unpack('>HQ', chunk[8:18]) + sr = int(mantissa * (2 ** (exp - 0x3FFF - 63))) + duration = num_frames / sr + bitrate = sr * channels * bitdepth / 1000 + self.samplerate, self.duration, self.bitrate = ( + sr, duration, bitrate) except OverflowError: pass elif sub_chunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: + # pylint: disable=protected-access id3 = _ID3() id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) From a87a60ce84c85af7fd537d4c2bb93f88a9e46711 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 20:59:24 +0300 Subject: [PATCH 230/305] MP4: remove nested Parser class (#226) It's redundant, and we don't use this pattern for other formats. --- tinytag/tinytag.py | 514 +++++++++++++++++++++++---------------------- 1 file changed, 258 insertions(+), 256 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index f5f40d4..b050cc2 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -463,270 +463,93 @@ class _MP4(TinyTag): https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html """ - # pylint: disable=protected-access - - class _Parser: - atom_decoder_by_type: dict[ - int, Callable[[bytes], int | str | bytes | Image]] | None = None - _CUSTOM_FIELD_NAME_MAPPING = { - 'artists': 'artist', - 'conductor': 'extra.conductor', - 'discsubtitle': 'extra.set_subtitle', - 'initialkey': 'extra.initial_key', - 'isrc': 'extra.isrc', - 'language': 'extra.language', - 'lyricist': 'extra.lyricist', - 'media': 'extra.media', - 'website': 'extra.url', - 'originaldate': 'extra.original_date', - 'originalyear': 'extra.original_year', - 'license': 'extra.license', - 'barcode': 'extra.barcode', - 'catalognumber': 'extra.catalog_number', - } - _UNPACK_SIGNED_FORMATS = { - 1: '>b', - 2: '>h', - 4: '>i', - 8: '>q' - } - _UNPACK_UNSIGNED_FORMATS = { - 1: '>B', - 2: '>H', - 4: '>I', - 8: '>Q' - } - - @classmethod - def _unpack_utf_8_string(cls, value: bytes) -> str: - return value.decode('utf-8', 'replace') - - @classmethod - def _unpack_utf_16_string(cls, value: bytes) -> str: - return value.decode('utf-16', 'replace') - - @classmethod - def _unpack_shift_jis_string(cls, value: bytes) -> str: - return value.decode('s/jis', 'replace') - - @classmethod - def _unpack_jpeg_image(cls, data: bytes) -> Image: - return Image('front_cover', data, 'image/jpeg') - - @classmethod - def _unpack_png_image(cls, data: bytes) -> Image: - return Image('front_cover', data, 'image/png') - - @classmethod - def _unpack_integer(cls, value: bytes, signed: bool = True) -> str: - fmts = cls._UNPACK_UNSIGNED_FORMATS - if signed: - fmts = cls._UNPACK_SIGNED_FORMATS - value_len = len(value) - if value_len in fmts: - return str(unpack(fmts[value_len], value)[0]) - return "" - - @classmethod - def _unpack_integer_unsigned(cls, value: bytes) -> str: - return cls._unpack_integer(value, signed=False) - - @classmethod - def _make_data_parser( - cls, fieldname: str - ) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: - def _parse_data_atom( - data_atom: bytes - ) -> dict[str, int | str | bytes | Image]: - data_type = unpack('>I', data_atom[:4])[0] - if cls.atom_decoder_by_type is None: - # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 - cls.atom_decoder_by_type = { - # 0: 'reserved' - 1: cls._unpack_utf_8_string, # UTF-8 - 2: cls._unpack_utf_16_string, # UTF-16 - 3: cls._unpack_shift_jis_string, # S/JIS - # 16: duration in millis - 13: cls._unpack_jpeg_image, # JPEG - 14: cls._unpack_png_image, # PNG - 21: cls._unpack_integer, # BE Signed - 22: cls._unpack_integer_unsigned, # BE Unsigned - 65: cls._unpack_integer, # 8-bit Signed - 66: cls._unpack_integer, # BE 16-bit Signed - 67: cls._unpack_integer, # BE 32-bit Signed - 74: cls._unpack_integer, # BE 64-bit Signed - 75: cls._unpack_integer_unsigned, # 8-bit Unsigned - 76: cls._unpack_integer_unsigned, # BE 16-bit Unsigned - 77: cls._unpack_integer_unsigned, # BE 32-bit Unsigned - 78: cls._unpack_integer_unsigned, # BE 64-bit Unsigned - } - conversion = cls.atom_decoder_by_type.get(data_type) - if conversion is None: - if DEBUG: - print(f'Cannot convert data type: {data_type}', - file=stderr) - return {} # don't know how to convert data atom - # skip header & null-bytes, convert rest - return {fieldname: conversion(data_atom[8:])} - return _parse_data_atom - - @classmethod - def _make_num_parser( - cls, fieldname1: str, fieldname2: str - ) -> Callable[[bytes], dict[str, int]]: - def _(data_atom: bytes) -> dict[str, int]: - number_data = data_atom[8:14] - numbers = unpack('>3H', number_data) - # for some reason the first number is always irrelevant. - return {fieldname1: numbers[1], fieldname2: numbers[2]} - return _ - - @classmethod - def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: - # dunno why genre is offset by -1 but that's how mutagen does it - idx = unpack('>H', data_atom[8:])[0] - 1 - result = {} - # pylint: disable=protected-access - if idx < len(_ID3._ID3V1_GENRES): - result['genre'] = _ID3._ID3V1_GENRES[idx] - return result - - @classmethod - def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: - for _i in range(4): - if esds_atom.read(1) != b'\x80': - break - @classmethod - def _parse_custom_field( - cls, data: bytes - ) -> dict[str, int | str | bytes | Image]: - fh = BytesIO(data) - header_size = 8 - field_name = None - data_atom = b'' - atom_header = fh.read(header_size) - while len(atom_header) == header_size: - atom_size = unpack('>I', atom_header[:4])[0] - header_size - atom_type = atom_header[4:] - if atom_type == b'name': - atom_value = fh.read(atom_size)[4:].lower() - field_name = atom_value.decode('utf-8', 'replace') - # pylint: disable=protected-access - field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get( - field_name, TinyTag._EXTRA_PREFIX + field_name) - elif atom_type == b'data': - data_atom = fh.read(atom_size) - else: - fh.seek(atom_size, SEEK_CUR) - atom_header = fh.read(header_size) # read next atom - if len(data_atom) < 8 or field_name is None: - return {} - parser = cls._make_data_parser(field_name) - return parser(data_atom) - - @classmethod - def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: - # this atom also contains the esds atom: - # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html - # http://xhelmboyx.tripod.com/formats/mp4-layout.txt - # http://sasperger.tistory.com/103 - - # jump over version and flags - channels = unpack('>H', data[16:18])[0] - # jump over bit_depth, QT compr id & pkt size - sr = unpack('>I', data[22:26])[0] - - # ES Description Atom - esds_atom_size = unpack('>I', data[28:32])[0] - esds_atom = BytesIO(data[36:36 + esds_atom_size]) - esds_atom.seek(5, SEEK_CUR) # jump over version, flags and tag - - # ES Descriptor - cls._read_extended_descriptor(esds_atom) - esds_atom.seek(4, SEEK_CUR) # jump over ES id, flags and tag - - # Decoder Config Descriptor - cls._read_extended_descriptor(esds_atom) - esds_atom.seek(9, SEEK_CUR) - avg_br = unpack('>I', esds_atom.read(4))[0] / 1000 # kbit/s - return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} - - @classmethod - def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: - # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt - bitdepth = data[45] - channels = data[49] - avg_br, sr = unpack('>II', data[56:64]) - avg_br /= 1000 # kbit/s - return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, - 'bitdepth': bitdepth} - - @classmethod - def _parse_mvhd(cls, data: bytes) -> dict[str, float]: - # http://stackoverflow.com/a/3639993/1191373 - version = data[0] - # jump over flags - if version == 0: # uses 32 bit integers for timestamps - # jump over create & mod times - time_scale, duration = unpack('>II', data[12:20]) - else: # version == 1: # uses 64 bit integers for timestamps - # jump over create & mod times - time_scale, duration = unpack('>Iq', data[20:28]) - return {'duration': duration / time_scale} - - # The parser tree: Each key is an atom name which is traversed if existing. - # Leaves of the parser tree are callables which receive the atom data. - # callables return {fieldname: value} which is updates the TinyTag. - _META_DATA_TREE = {b'moov': {b'udta': {b'meta': {b'ilst': { - # http://atomicparsley.sourceforge.net/mpeg-4files.html - # https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 - b'\xa9ART': {b'data': _Parser._make_data_parser('artist')}, - b'\xa9alb': {b'data': _Parser._make_data_parser('album')}, - b'\xa9cmt': {b'data': _Parser._make_data_parser('comment')}, - b'\xa9con': {b'data': _Parser._make_data_parser('extra.conductor')}, - # need test-data for this - # b'cpil': {b'data': _Parser._make_data_parser('extra.compilation')}, - b'\xa9day': {b'data': _Parser._make_data_parser('year')}, - b'\xa9des': {b'data': _Parser._make_data_parser('extra.description')}, - b'\xa9dir': {b'data': _Parser._make_data_parser('extra.director')}, - b'\xa9gen': {b'data': _Parser._make_data_parser('genre')}, - b'\xa9lyr': {b'data': _Parser._make_data_parser('extra.lyrics')}, - b'\xa9mvn': {b'data': _Parser._make_data_parser('movement')}, - b'\xa9nam': {b'data': _Parser._make_data_parser('title')}, - b'\xa9pub': {b'data': _Parser._make_data_parser('extra.publisher')}, - b'\xa9too': {b'data': _Parser._make_data_parser('extra.encoded_by')}, - b'\xa9wrt': {b'data': _Parser._make_data_parser('composer')}, - b'aART': {b'data': _Parser._make_data_parser('albumartist')}, - b'cprt': {b'data': _Parser._make_data_parser('extra.copyright')}, - b'desc': {b'data': _Parser._make_data_parser('extra.description')}, - b'disk': {b'data': _Parser._make_num_parser('disc', 'disc_total')}, - b'gnre': {b'data': _Parser._parse_id3v1_genre}, - b'trkn': {b'data': _Parser._make_num_parser('track', 'track_total')}, - b'tmpo': {b'data': _Parser._make_data_parser('extra.bpm')}, - b'covr': {b'data': _Parser._make_data_parser('images.front_cover')}, - b'----': _Parser._parse_custom_field, - }}}}} - - # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html - _AUDIO_DATA_TREE = { - b'moov': { - b'mvhd': _Parser._parse_mvhd, - b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": { - b'mp4a': _Parser._parse_audio_sample_entry_mp4a, - b'alac': _Parser._parse_audio_sample_entry_alac - }}}}} - } + _CUSTOM_FIELD_NAME_MAPPING = { + 'artists': 'artist', + 'conductor': 'extra.conductor', + 'discsubtitle': 'extra.set_subtitle', + 'initialkey': 'extra.initial_key', + 'isrc': 'extra.isrc', + 'language': 'extra.language', + 'lyricist': 'extra.lyricist', + 'media': 'extra.media', + 'website': 'extra.url', + 'originaldate': 'extra.original_date', + 'originalyear': 'extra.original_year', + 'license': 'extra.license', + 'barcode': 'extra.barcode', + 'catalognumber': 'extra.catalog_number', + } + _UNPACK_SIGNED_FORMATS = { + 1: '>b', + 2: '>h', + 4: '>i', + 8: '>q' + } + _UNPACK_UNSIGNED_FORMATS = { + 1: '>B', + 2: '>H', + 4: '>I', + 8: '>Q' } - _VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header _FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header + _atom_decoder_by_type: dict[ + int, Callable[[bytes], int | str | bytes | Image]] | None = None + _audio_data_tree: dict[bytes, Any] | None = None + _meta_data_tree: dict[bytes, Any] | None = None + def _determine_duration(self, fh: BinaryIO) -> None: - self._traverse_atoms(fh, path=self._AUDIO_DATA_TREE) + # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html + if _MP4._audio_data_tree is None: + _MP4._audio_data_tree = { + b'moov': { + b'mvhd': _MP4._parse_mvhd, + b'trak': {b'mdia': {b"minf": {b"stbl": {b"stsd": { + b'mp4a': _MP4._parse_audio_sample_entry_mp4a, + b'alac': _MP4._parse_audio_sample_entry_alac + }}}}} + } + } + self._traverse_atoms(fh, path=_MP4._audio_data_tree) def _parse_tag(self, fh: BinaryIO) -> None: - self._traverse_atoms(fh, path=self._META_DATA_TREE) + # The parser tree: Each key is an atom name which is traversed if + # existing. Leaves of the parser tree are callables which receive + # the atom data. Callables return {fieldname: value} which is updates + # the TinyTag. + if _MP4._meta_data_tree is None: + _MP4._meta_data_tree = {b'moov': {b'udta': {b'meta': {b'ilst': { + # http://atomicparsley.sourceforge.net/mpeg-4files.html + # https://metacpan.org/dist/Image-ExifTool/source/lib/Image/ExifTool/QuickTime.pm#L3093 + b'\xa9ART': {b'data': _MP4._data_parser('artist')}, + b'\xa9alb': {b'data': _MP4._data_parser('album')}, + b'\xa9cmt': {b'data': _MP4._data_parser('comment')}, + b'\xa9con': {b'data': _MP4._data_parser('extra.conductor')}, + # need test-data for this + # b'cpil': {b'data': _MP4._data_parser('extra.compilation')}, + b'\xa9day': {b'data': _MP4._data_parser('year')}, + b'\xa9des': {b'data': _MP4._data_parser('extra.description')}, + b'\xa9dir': {b'data': _MP4._data_parser('extra.director')}, + b'\xa9gen': {b'data': _MP4._data_parser('genre')}, + b'\xa9lyr': {b'data': _MP4._data_parser('extra.lyrics')}, + b'\xa9mvn': {b'data': _MP4._data_parser('movement')}, + b'\xa9nam': {b'data': _MP4._data_parser('title')}, + b'\xa9pub': {b'data': _MP4._data_parser('extra.publisher')}, + b'\xa9too': {b'data': _MP4._data_parser('extra.encoded_by')}, + b'\xa9wrt': {b'data': _MP4._data_parser('composer')}, + b'aART': {b'data': _MP4._data_parser('albumartist')}, + b'cprt': {b'data': _MP4._data_parser('extra.copyright')}, + b'desc': {b'data': _MP4._data_parser('extra.description')}, + b'disk': {b'data': _MP4._num_parser('disc', 'disc_total')}, + b'gnre': {b'data': _MP4._parse_id3v1_genre}, + b'trkn': {b'data': _MP4._num_parser('track', 'track_total')}, + b'tmpo': {b'data': _MP4._data_parser('extra.bpm')}, + b'covr': {b'data': _MP4._data_parser('images.front_cover')}, + b'----': _MP4._parse_custom_field, + }}}}} + self._traverse_atoms(fh, path=_MP4._meta_data_tree) def _traverse_atoms(self, fh: BinaryIO, @@ -764,6 +587,7 @@ def _traverse_atoms(self, print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) if fieldname.startswith('images.'): if self._load_image: + # pylint: disable=protected-access self.images._set_field( fieldname[len('images.'):], value) elif fieldname: @@ -776,6 +600,184 @@ def _traverse_atoms(self, return # return to parent (next parent node in tree) atom_header = fh.read(header_size) # read next atom + @classmethod + def _unpack_utf_8_string(cls, value: bytes) -> str: + return value.decode('utf-8', 'replace') + + @classmethod + def _unpack_utf_16_string(cls, value: bytes) -> str: + return value.decode('utf-16', 'replace') + + @classmethod + def _unpack_shift_jis_string(cls, value: bytes) -> str: + return value.decode('s/jis', 'replace') + + @classmethod + def _unpack_jpeg_image(cls, data: bytes) -> Image: + return Image('front_cover', data, 'image/jpeg') + + @classmethod + def _unpack_png_image(cls, data: bytes) -> Image: + return Image('front_cover', data, 'image/png') + + @classmethod + def _unpack_integer(cls, value: bytes, signed: bool = True) -> str: + fmts = cls._UNPACK_UNSIGNED_FORMATS + if signed: + fmts = cls._UNPACK_SIGNED_FORMATS + value_len = len(value) + if value_len in fmts: + return str(unpack(fmts[value_len], value)[0]) + return "" + + @classmethod + def _unpack_integer_unsigned(cls, value: bytes) -> str: + return cls._unpack_integer(value, signed=False) + + @classmethod + def _data_parser( + cls, fieldname: str + ) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: + def _parse_data_atom( + data_atom: bytes + ) -> dict[str, int | str | bytes | Image]: + data_type = unpack('>I', data_atom[:4])[0] + if cls._atom_decoder_by_type is None: + # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 + cls._atom_decoder_by_type = { + # 0: 'reserved' + 1: cls._unpack_utf_8_string, # UTF-8 + 2: cls._unpack_utf_16_string, # UTF-16 + 3: cls._unpack_shift_jis_string, # S/JIS + # 16: duration in millis + 13: cls._unpack_jpeg_image, # JPEG + 14: cls._unpack_png_image, # PNG + 21: cls._unpack_integer, # BE Signed + 22: cls._unpack_integer_unsigned, # BE Unsigned + 65: cls._unpack_integer, # 8-bit Signed + 66: cls._unpack_integer, # BE 16-bit Signed + 67: cls._unpack_integer, # BE 32-bit Signed + 74: cls._unpack_integer, # BE 64-bit Signed + 75: cls._unpack_integer_unsigned, # 8-bit Unsigned + 76: cls._unpack_integer_unsigned, # BE 16-bit Unsigned + 77: cls._unpack_integer_unsigned, # BE 32-bit Unsigned + 78: cls._unpack_integer_unsigned, # BE 64-bit Unsigned + } + conversion = cls._atom_decoder_by_type.get(data_type) + if conversion is None: + if DEBUG: + print(f'Cannot convert data type: {data_type}', + file=stderr) + return {} # don't know how to convert data atom + # skip header & null-bytes, convert rest + return {fieldname: conversion(data_atom[8:])} + return _parse_data_atom + + @classmethod + def _num_parser( + cls, fieldname1: str, fieldname2: str + ) -> Callable[[bytes], dict[str, int]]: + def _(data_atom: bytes) -> dict[str, int]: + number_data = data_atom[8:14] + numbers = unpack('>3H', number_data) + # for some reason the first number is always irrelevant. + return {fieldname1: numbers[1], fieldname2: numbers[2]} + return _ + + @classmethod + def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: + # dunno why genre is offset by -1 but that's how mutagen does it + idx = unpack('>H', data_atom[8:])[0] - 1 + result = {} + # pylint: disable=protected-access + if idx < len(_ID3._ID3V1_GENRES): + result['genre'] = _ID3._ID3V1_GENRES[idx] + return result + + @classmethod + def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: + for _i in range(4): + if esds_atom.read(1) != b'\x80': + break + + @classmethod + def _parse_custom_field( + cls, data: bytes + ) -> dict[str, int | str | bytes | Image]: + fh = BytesIO(data) + header_size = 8 + field_name = None + data_atom = b'' + atom_header = fh.read(header_size) + while len(atom_header) == header_size: + atom_size = unpack('>I', atom_header[:4])[0] - header_size + atom_type = atom_header[4:] + if atom_type == b'name': + atom_value = fh.read(atom_size)[4:].lower() + field_name = atom_value.decode('utf-8', 'replace') + # pylint: disable=protected-access + field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get( + field_name, TinyTag._EXTRA_PREFIX + field_name) + elif atom_type == b'data': + data_atom = fh.read(atom_size) + else: + fh.seek(atom_size, SEEK_CUR) + atom_header = fh.read(header_size) # read next atom + if len(data_atom) < 8 or field_name is None: + return {} + parser = cls._data_parser(field_name) + return parser(data_atom) + + @classmethod + def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: + # this atom also contains the esds atom: + # https://ffmpeg.org/doxygen/0.6/mov_8c-source.html + # http://xhelmboyx.tripod.com/formats/mp4-layout.txt + # http://sasperger.tistory.com/103 + + # jump over version and flags + channels = unpack('>H', data[16:18])[0] + # jump over bit_depth, QT compr id & pkt size + sr = unpack('>I', data[22:26])[0] + + # ES Description Atom + esds_atom_size = unpack('>I', data[28:32])[0] + esds_atom = BytesIO(data[36:36 + esds_atom_size]) + esds_atom.seek(5, SEEK_CUR) # jump over version, flags and tag + + # ES Descriptor + cls._read_extended_descriptor(esds_atom) + esds_atom.seek(4, SEEK_CUR) # jump over ES id, flags and tag + + # Decoder Config Descriptor + cls._read_extended_descriptor(esds_atom) + esds_atom.seek(9, SEEK_CUR) + avg_br = unpack('>I', esds_atom.read(4))[0] / 1000 # kbit/s + return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br} + + @classmethod + def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: + # https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt + bitdepth = data[45] + channels = data[49] + avg_br, sr = unpack('>II', data[56:64]) + avg_br /= 1000 # kbit/s + return {'channels': channels, 'samplerate': sr, 'bitrate': avg_br, + 'bitdepth': bitdepth} + + @classmethod + def _parse_mvhd(cls, data: bytes) -> dict[str, float]: + # http://stackoverflow.com/a/3639993/1191373 + version = data[0] + # jump over flags + if version == 0: # uses 32 bit integers for timestamps + # jump over create & mod times + time_scale, duration = unpack('>II', data[12:20]) + else: # version == 1: # uses 64 bit integers for timestamps + # jump over create & mod times + time_scale, duration = unpack('>Iq', data[20:28]) + return {'duration': duration / time_scale} + class _ID3(TinyTag): """MP3 Parser""" From dc6bd94d79b91de064af8eaeae9ecd6812c4cfda Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 18 Oct 2024 21:08:12 +0300 Subject: [PATCH 231/305] Stricter handling of deprecated keyword arguments We still need to warn the user if they provide unknown keyword arguments. --- tinytag/tests/test_all.py | 2 ++ tinytag/tinytag.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index de1d43f..a1ea99b 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1638,6 +1638,8 @@ def test_deprecations() -> None: file_path = os.path.join(testfolder, 'samples/id3v24-long-title.mp3') with pytest.warns(DeprecationWarning): tag = TinyTag.get(filename=file_path, image=True, ignore_errors=True) + with pytest.warns(DeprecationWarning): + tag = TinyTag.get(filename=file_path, image=True, ignore_errors=False) with pytest.warns(DeprecationWarning): assert tag.audio_offset is None with pytest.warns(DeprecationWarning): diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b050cc2..fe3d232 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -115,12 +115,12 @@ def __repr__(self) -> str: @classmethod def get(cls, filename: bytes | str | PathLike[Any] | None = None, + file_obj: BinaryIO | None = None, tags: bool = True, duration: bool = True, image: bool = False, encoding: str | None = None, - file_obj: BinaryIO | None = None, - **kwargs: Any) -> TinyTag: + ignore_errors: bool | None = None) -> TinyTag: """Return a tag object for an audio file.""" should_close_file = file_obj is None if filename and should_close_file: @@ -129,7 +129,7 @@ def get(cls, if file_obj is None: raise ValueError( 'Either filename or file_obj argument is required') - if 'ignore_errors' in kwargs: + if ignore_errors is not None: # pylint: disable=import-outside-toplevel from warnings import warn warn('ignore_errors argument is obsolete, and will be removed in ' From 402faacec3119a8a4c4adf32204679d45dd825e9 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 03:00:09 +0300 Subject: [PATCH 232/305] Ogg: don't write image data to wrong field --- tinytag/tinytag.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index fe3d232..4e2873b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1487,13 +1487,14 @@ def _parse_vorbis_comment(self, if '=' in keyvalpair: key, value = keyvalpair.split('=', 1) key_lower = key.lower() - if key_lower == "metadata_block_picture" and self._load_image: - if DEBUG: - print('Found Vorbis Image', key, value[:64]) - # pylint: disable=protected-access - fieldname, fieldvalue = _Flac._parse_image( - BytesIO(a2b_base64(value))) - self.images._set_field(fieldname, fieldvalue) + if key_lower == "metadata_block_picture": + if self._load_image: + if DEBUG: + print('Found Vorbis Image', key, value[:64]) + # pylint: disable=protected-access + fieldname, fieldvalue = _Flac._parse_image( + BytesIO(a2b_base64(value))) + self.images._set_field(fieldname, fieldvalue) else: if DEBUG: print('Found Vorbis Comment', key, value[:64]) From 0c1843b2f45ef917c86f77475118fe95330a364d Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 03:01:39 +0300 Subject: [PATCH 233/305] ID3: read image description properly We need to reuse the encoding from the mime type string for the description. --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4e2873b..a242b38 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1263,7 +1263,7 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: content[desc_start_pos:], termination) desc_end_pos = desc_start_pos + desc_len + len(termination) desc = self._decode_string( - content[desc_start_pos:desc_end_pos]) + encoding + content[desc_start_pos:desc_end_pos]) field_name, image = self._create_tag_image( content[desc_end_pos:], pic_type, mime_type, desc) # pylint: disable=protected-access From 226b1af3c359e2a5795cd7074e6d0da16f6c0d32 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 12:37:50 +0300 Subject: [PATCH 234/305] Make project REUSE-compliant (#227) Ensure each file has copyright and licensing information. Remove some sample files containing cover art. --- .github/ISSUE_TEMPLATE/bug_report.md | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 5 + .github/workflows/tests.yml | 14 +- .gitignore | 3 + LICENSE | 2 +- LICENSES/CC0-1.0.txt | 121 +++++++++ LICENSES/MIT.txt | 1 + README.md | 5 + pyproject.toml | 3 + tinytag/__init__.py | 3 + tinytag/__main__.py | 3 + tinytag/icons/icon.svg | 4 + tinytag/icons/icon_bg.png | Bin 19042 -> 0 bytes tinytag/icons/icon_bg.svg | 4 + tinytag/icons/icon_bg_round.png | Bin 13016 -> 0 bytes tinytag/icons/icon_bg_round.svg | 4 + tinytag/tests/samples/12oz.mp3 | Bin 43062 -> 0 bytes tinytag/tests/samples/REUSE.toml | 6 + tinytag/tests/samples/aiff_with_image.aiff | Bin 38636 -> 21044 bytes tinytag/tests/samples/cover_img.mp3 | Bin 150000 -> 0 bytes tinytag/tests/samples/flac_with_image.flac | Bin 80000 -> 4692 bytes tinytag/tests/samples/id3_image_jfif.mp3 | Bin 132000 -> 0 bytes .../samples/id3image_without_description.mp3 | Bin 28821 -> 0 bytes tinytag/tests/samples/id3v22_image.mp3 | Bin 35924 -> 0 bytes tinytag/tests/samples/id3v22_with_image.mp3 | Bin 0 -> 2311 bytes tinytag/tests/samples/iso8859_with_image.m4a | Bin 57017 -> 0 bytes .../{test3.m4a => mpeg4_with_image.m4a} | Bin 6260 -> 7371 bytes tinytag/tests/samples/ogg_with_image.ogg | Bin 5838 -> 5759 bytes tinytag/tests/samples/test2.m4a | Bin 223365 -> 6260 bytes tinytag/tests/samples/{mp3/vbr => }/vbr11.mp3 | Bin .../samples/{mp3/vbr => }/vbr11stereo.mp3 | Bin tinytag/tests/samples/{mp3/vbr => }/vbr16.mp3 | Bin .../samples/{mp3/vbr => }/vbr16stereo.mp3 | Bin tinytag/tests/samples/{mp3/vbr => }/vbr22.mp3 | Bin .../samples/{mp3/vbr => }/vbr22stereo.mp3 | Bin tinytag/tests/samples/{mp3/vbr => }/vbr32.mp3 | Bin .../samples/{mp3/vbr => }/vbr32stereo.mp3 | Bin tinytag/tests/samples/{mp3/vbr => }/vbr44.mp3 | Bin .../samples/{mp3/vbr => }/vbr44stereo.mp3 | Bin tinytag/tests/samples/{mp3/vbr => }/vbr48.mp3 | Bin .../samples/{mp3/vbr => }/vbr48stereo.mp3 | Bin tinytag/tests/samples/{mp3/vbr => }/vbr8.mp3 | Bin .../samples/{mp3/vbr => }/vbr8stereo.mp3 | Bin tinytag/tests/samples/wav_with_image.wav | Bin 22908 -> 22902 bytes tinytag/tests/samples/with_id3_header.flac | Bin 64837 -> 0 bytes tinytag/tests/test_all.py | 229 +++++++----------- tinytag/tests/test_cli.py | 5 +- tinytag/tinytag.py | 13 +- 48 files changed, 274 insertions(+), 156 deletions(-) create mode 100644 LICENSES/CC0-1.0.txt create mode 120000 LICENSES/MIT.txt delete mode 100644 tinytag/icons/icon_bg.png delete mode 100644 tinytag/icons/icon_bg_round.png delete mode 100644 tinytag/tests/samples/12oz.mp3 create mode 100644 tinytag/tests/samples/REUSE.toml delete mode 100644 tinytag/tests/samples/cover_img.mp3 delete mode 100644 tinytag/tests/samples/id3_image_jfif.mp3 delete mode 100644 tinytag/tests/samples/id3image_without_description.mp3 delete mode 100644 tinytag/tests/samples/id3v22_image.mp3 create mode 100644 tinytag/tests/samples/id3v22_with_image.mp3 delete mode 100644 tinytag/tests/samples/iso8859_with_image.m4a rename tinytag/tests/samples/{test3.m4a => mpeg4_with_image.m4a} (77%) rename tinytag/tests/samples/{mp3/vbr => }/vbr11.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr11stereo.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr16.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr16stereo.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr22.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr22stereo.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr32.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr32stereo.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr44.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr44stereo.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr48.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr48stereo.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr8.mp3 (100%) rename tinytag/tests/samples/{mp3/vbr => }/vbr8stereo.mp3 (100%) delete mode 100644 tinytag/tests/samples/with_id3_header.flac diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 5a64758..81a04a0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,8 @@ + + --- name: Bug report about: Something is broken or doesn't work as expected diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 9390310..9504c50 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,3 +1,8 @@ + + --- name: Feature request about: Suggest an idea for this project diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 33cd154..b1f6fbe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2022-2024 tinytag Contributors +# SPDX-License-Identifier: MIT + name: Tests on: [push, pull_request] @@ -40,7 +43,7 @@ jobs: cache: 'pip' - name: Install dependencies - run: python -m pip install build coverage flit .[tests] + run: python -m pip install build coverage flit reuse .[tests] - name: PEP 8 style checks run: python -m pycodestyle . @@ -55,9 +58,7 @@ jobs: python -m mypy -p tinytag - name: Unit tests - run: | - coverage run -m pytest - coverage lcov -o coverage/lcov.info + run: coverage run -m pytest env: TINYTAG_DEBUG: true @@ -67,10 +68,15 @@ jobs: - name: Build package without isolation run: python -m build --no-isolation + - name: REUSE compliance + if: matrix.python != '3.7' && matrix.python != 'pypy-3.7' + run: python -m reuse lint + - name: Coveralls uses: coverallsapp/github-action@v2 with: flag-name: run-${{ join(matrix.*, '-') }} + file: .coverage parallel: true finish: diff --git a/.gitignore b/.gitignore index 0db6c42..e2ddd48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2020-2024 tinytag Contributors +# SPDX-License-Identifier: MIT + *.py[cod] # Packages diff --git a/LICENSE b/LICENSE index d6c723c..443b21a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode) +Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode), et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/LICENSES/CC0-1.0.txt b/LICENSES/CC0-1.0.txt new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/LICENSES/CC0-1.0.txt @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/README.md b/README.md index 235a7d7..6c52800 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + + # tinytag tinytag is a Python library for reading audio file metadata diff --git a/pyproject.toml b/pyproject.toml index e525742..ac90011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2024 tinytag Contributors +# SPDX-License-Identifier: MIT + [build-system] requires = ["flit_core>=3.2"] build-backend = "flit_core.buildapi" diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 10b3713..5994e41 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2014-2024 tinytag Contributors +# SPDX-License-Identifier: MIT + """Audio file metadata reader""" from .tinytag import ( diff --git a/tinytag/__main__.py b/tinytag/__main__.py index a186c3f..f9285e8 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2015-2024 tinytag Contributors +# SPDX-License-Identifier: MIT + # pylint: disable=missing-module-docstring,protected-access from __future__ import annotations diff --git a/tinytag/icons/icon.svg b/tinytag/icons/icon.svg index 09f2ed9..601cb01 100644 --- a/tinytag/icons/icon.svg +++ b/tinytag/icons/icon.svg @@ -1 +1,5 @@ + diff --git a/tinytag/icons/icon_bg.png b/tinytag/icons/icon_bg.png deleted file mode 100644 index 77e3a31fd1601d3896fe76140e164e51379746e4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19042 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 X-Am5 diff --git a/tinytag/icons/icon_bg_round.png b/tinytag/icons/icon_bg_round.png deleted file mode 100644 index 0fc58e66ab5f557a24e654cffcbcf46292c3dca7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13016 zcmXXt2{@GN_wNw8h^ACjmIhHNiYVEtu}pSKp_oXb!F3}cX3DK5Sw|tV#6+TO8AX<& zRMuplQ2LdIhJ+zw`Jd1IKRwTw`QGYVT%*P&gCP#CJjw-1z**- zSqf`}{!)mXty^W|u;JwDuU1lu((YPsR=k{FV|8d!Ld^M?^xR^7#TN_kwkGrl7}pqxn}%^bm&&ODB- ztjJhWOud^NVvqc-yT_ z!ckhHs7-|>zsu`5Dc1Sx6)Ws#E06S`Jt=h zXYDb@lemBf%+V8vkam;+DtO5|MBz3lEq=q{F9KiAUz{=ZLckkzM6)&qkqO{EprG>(r_qEFS ziPHLD+7Vl%@eif-Lx&OdAyZK+P}V@hUdOzG*q)?(>ith-vPuXkzT`PDB$5J>bGjy< z^W+u+3YmrGb6HGvllqLc!?+N@qpf5?fz%p5b;#0kqBK}C@W;84_g7nw%vlgWCGL~d z*dc|)*Rdtz1CGvoea9$ydQ;=OZc0V{FU^$gfkMf;>um7~4RC{E)*lDY1wc4NG4jzq+VLpY!?_@}|J zyw5Q!Pv**uN&Tt3I`Te{w0pnl_+m8ktMbkxfUswyQk83a%_QBeNlFNP>+M>@l%?vxG+d=Q%8U2 zk|;^VJjAR|3!VLJ%~(9#E^~4(ZPk55mhCs4sJ3f2G!G#T?L%kBgBVtfslVh%QDwI1 z7h+{><7knn@w#HP>OXgwHPQ6K9Eqzh58`M_?|4sAQubW78c{>rCp}0i#QIfv5eijm zfr#l5x`{2HFr~SBRxo>PHL0d3>=E}`UY)|uv*^uj?jd{Y;m~rX(9N&t;b4Z=^x%@? zPf2NODcbHce3_JrTRl(mN|D*vM$4UImvsVHcid8@?RE<_WoN`stpbS2B{!Ij6zV%3 zFRw1s0Nx^37A;7S43nl_bp#`EW+z{8u{v~{hRJ4gq}ak6V;B&;T00z(cyz{*5KH2` zM&RnBH)LpdkI*Mvd!+?4@kMm@`vjxfk1x(neIOCxts$s!l`^i*sbk0`0NwY4vYGCR zL!0sb#Z|`+?j{P_lq;uf$7NW_ zt|+aOqiyNv|3baa6np(yQX$%y(NexZVo%!Nc-gdDP~!|`=>gOFre!!1Zm?v;#L(rL z5?49y!ep}^33h||T)Ol;PCSvT9M;XNr09U+O#w+OD1JLQ35@haT`@#?C8@P{)8Fc(~O6EEnq#v=-s6cUke6t|Azx=omhvcK$RQQJDD)&Mp z_m+}Rx~?Re`XB&o3()oKX#tv~;58aoq}RZ@z0}SVM~ghQ9K0J7s?c1mH#wox?wsiVB}XVkH2ABp9b(YiW+6~4&1N&NKFF0A`?%_<6Cdi zmy&efs-BiKlJ6a0^VYfj5}}3nz2hzQ4QK|&*&f3ESj%2&;5}fltkQ&#N!wuxwSk8N zV4|W%@;wF}yyMC{EwGiI%WPNH!cRCKCzNT*a_#E>rK1wK9!U#MVkQ=!7aG<+fnC;0d-YY<@^0CQ$efj;IhHIquyb$j*oG9Yz2fCkYIFnRP8;Krh1hy-{I>}03m(6;8n4#rD9PJ!OQUKlq6U9S4P134U^-$oujy4DqUw#`dj>Lv;GA z_lQEozXC+6-kFfW|p`#-$^d23;6Kh$evf`SxncST|)MH?Y8WM76_~xu~Gwk^;#7bou z?(iB01wduJuX)BS6+01<7e$Q5Rc-vO$Vg8InqI=FS6%HpWEU-djX>5Cu zyt)$|d_f-m6q?WV9xRo=q6KkQXua(QFli6~qd=!@LIij!fR`<0qK(XS)L9!e#CX zkV8)g{Tkd4LajBc*hS#oq5Io22L^!W))Nws66sjF40BugKB&?-q zP@eoyjK-wK1sIL2MzrG68fH0D;=`@`rv!?sa=GK?-Z>Z>%pcQx)Co_H$ez)@UIG$p z;&r-p8zP0rql#{^r(bCOt(t@?TD{MLM3^jl$Hb+0-Fk|6zc`JnSXn+N1mYvsRC2!|Mqgj*vh)O~^UyV5BFWd3W0 zgf&Q&w(8OoZc>iK?o)@0SCB)^+1;cNGw`N}QlCK>^2c(OI7^vG8Cr>&^|FwD`FQXT z=N-0asBJ|(2DLn$y(=7XD2_?Lbeed1S71!E%#VYeJUzDIou}>?oTl0#`XvhS(;eNn z*TtvS7&#`6ug8QvtH%Uwx0H2r7rrQ5%X48-6}%p1uvQ>xXAh#@UP5(-?%O%>Gq|EO zcJ+~VIZUi=5gDfSYZbEqRG)C*U!TVz&?4#ZO41y=V@sFk=d*}1evUuP_|63O8p*50 z(oZQ4eeXEfrS&&R)p`SVY(P`#LOSf$wt+_(K70n|*?yGM_o)(u&qi%Z79dylR(+=~ zTkXSIb;Y5-B1*RW9hZm(2oOqYe}oh;Ye=$gWml4MMOD#US5iubu9${Ji{a9I`oIz+ z=>;=)X1uK1MZaq4hT;5a>v56mZSC|+O$vHy=M>JO_a*Z+n2AMIU-NKGzo+}J76Qqw zB@88Fcsx8ze9^~qWwz{IDfC%CA`ZlpqD86E38XUx3%Q4t^ff);g1Y8X1y|5s*vtV{ z%xb#4L#xK`-efZR`-VIa)kmj%6XUr7&H(0d-<^u z+(Bn6tewu|iWV~I4;Li=LbVE|3qh>%hm-fp)`N~LS=n(vZU(RLthup7?|#eLE(b*M zgE;VT^fEk6pzpii;z;Wg1vT))8!iZhG!Ci`?dmKy8jgg`0m^mG-3r6HKJzcAGK4ot5eRgm- zEhznB&uJ0af8K5hS)9x$44(Y{Am)a*&lb)d#=!A!r0xBJ;qNE_K@tU~lvT^Q4hF`Ng6bz29bO)WtvB z8}|e%9d!>SnK}eteJ@JmUgwq{4ZMt`JoCF;6s=4|`g@I9-?*Da52EpRx>lcDnvGq0 z_|e)hP5BDm{+mUJGH7KwgZFp{HcTHFe=!*_bW`O~zb^)0|0kY%{=UDJ!9|;g&xz1h zRP)LTIgkCqb|{krh>nSOPFi-)n2S>kU#K;1=oy>2kDHQNN{h*9RIfRyl71=6%HYo# z568!nVa9u1Kq2wP&oV@~{K2Rawb+D?pELeA^l@gokb259Ezn^^`bX@3568v88p*zH z(2zsz=lQ`FsP^Z*(6Q$yWi@mM+GO4NIfX)*A2}~<+PfC?wn7}S+W$?eKV*z3!U$x@boPoR~coC1rL=H2-%B`m1+>g;cMuWi82GjZkexM#QqMi)pmd#}> z{mJOq-Ih)78RYpc=(-F(5m0SpWGvoj8wefm|Hv<_7i4x?l5Mz`O3zH4Un#k=cM|!7g85SfuvN( zJ=RspF>?jT2a={rTn-~Na^anF5;KN2Q1$9!c9P)KQ3MIxONLxm<8FkeD}KM^pG?pd z4hbU880eMm85L*?Q+@fVw^|;>wM6R-B zwo3HSluPwI3eL?Vbm?yT3WSa}@H8oYyAx)b9-@F;>7E5iR7>6ltc)6KIgl+7=h2JT zY!n1`!?)9#AqxSn=R&WhE+M3mT%jYBk~}t=dkW*crc(3Veb|?2H_7Jm>~r};UK?oi z&+n+}FjFz^qP!6?C^nEcY118oh_* zdH_h`qiiYsCJZ@Ny~ZIFm^_wT^$v_FhMPMj_ZMLPnE!XCrHjO~cLHs`>g~F-P;FwWlN!LWs7-t+E_|n3Jd<%878wKltKYuJNYO zW)m^E8}w4`3*jt56}SLw>aVT&@KT?^8~2j~oNri0e@TTRoL5*AR9(H@LbKgxg+X<$ zG1A*+mLc_rAu;W#g;ncmR=K4aOcUj0bbeo~TwtyWyAUCyK;Y$X#l&zp(Zjm`^NjXX zG`)v>9*VXR>I7bI+f0`;qIj}q3U;l5O)11MIZourN&|1@KJFh?EM{6Yi$J z41nSXvB{TM?>lV_TsU@%-cT53T3hCcF5cEEuQr3%g(sDL%D-=rv%$3Dt9LYZNHI@( zi6*cbKYDU2fV@7~=RV(O{vY_Ym{9!PLl=ZHD-ZlV+_x5oXzz{d-F+HH1`GEK7NtFd zb7;1EUE8(usTW*v=gQd!ly5Er{eFV#idi6Rs9kxB9w8|Ez-gsa#=(sa6yaw7IrXQmaIg%DpUVA~~62wb|Y=Y+w>Vbz z59kKMD>fMyE2{$6;|nk?HX*p#uE)c57I`J2`(HNy8?E7(7>7N6?D`2IiwNbugPzN7IU-w}{*rUAI0X2O5F&4^9zpk%ZO zP4$F&&_MLn!*{Ef)$%@v3!0?h|Gw?V1UB*UtBCL%sd0Vd zb8g3$IGe(yMf;kX<9U;lixZ1W)+2`0kx9kJ7IrFuD}l6piT?oQV6TKH1CnI%GEEY< z1K=4w*2Q%1jrDMxkv7-k67S*2(hFRT@kgyO(UY}|0iNBcNm2ta z8ZHiJ7|ZZpsI@BXG*1JXJ4Oa9+8O_WUcA@4e5MIS{auM#Hg>#8iCSfCdM5LyZl5VhFXHLZNY^*gL8?&g`YQsBp%PV1DY+ z&iJX+=`i;yq0G?oO!;H~;Tu^tj-K^h=ON19A2Ci!Y+`rRyaewzn74?+#H&8(`IS_u$=dG5t7Jsna@XInW%G08z`qTQO)2 zXuse$Hon-~UOlB{4UKTs18BQm8_@96YaIZ@3yK$JL59gZBEx4xii`Tl522(kFh}Z4|FM{(FGpW_G_@I_4 zOU+$~A%)LKYsS{D2tWcibfGkOus{?tkPT+ut>3EEe(NngRGJBalG!wNq16ArJC+1D1poBmj33hq z`jGq0Mlwx13|)7TY`=i}F?TK_Mi4!G`|6rlU_IWH37Ccz$cu2y zm*r^Y)EDcHLysn)F!8S^60cE?HgR{DS#))F6*VHNB?R~y#t5MwHE?gXQxExoT`poN zR01J!=Ie=ILkR*Y+T`m3+)$<&S*{JlX)Hl5HlP+>*Ef9AFwr1i2={Q{joclNKVQOS z^Iby`I0Mq0OHGUj`Vp&QFmY4&M`i?eW2x5My?f$TxNL!+Q08bvn1LM16cJmm)@Hcy z(Qw<End8rPpOjO`s)M&E)LT^Vy5*B}i|5!ryam;+BaT4Pdy6_GZg+9&gTXVRv0eD+;An{+kh{h;Xez@03E8z z`YM35hdxJ0cIDMhrf-EB%oS`FAUFMy-Z;NC$P7wZn)&;Dm4(2mEuLuqc5L$|RP==2 zw!2|-_F8Mb_?OhDm-E5jIlsQLgYUcspUWO7eDqZiD2Mg2^?yd3;lT?u761{sagvqpl1&rMI7HRXk%q%mA)P?p9h6u zjVJ4OoqVYC;>#-CA-dwTcdhhDx;-JQU}W~rE~$0K6Ym`I1Ko&vCGRIu?X#ZClg-mZ z8){CN$_7f!@Z^2U!5*xS2VXq`4)Lkend|P61fcrFo*Xt>dDS60LwtSlInvu!jEY-!z zk%XfzU{&c3`O^lZrIXXo5jk1P`2Oi){ z>Bznf2p#_Ulzzgl;imUC#JD~DhkE4EH%TE>ySm~)>*Nt%ex*U#I+}0xAEQ&lf{6U~ zo=dm&l3lcrQA(eff|pm?@Crm;`RUH^ADO`PqAsvsDkH$@zo+x2X@(2tdp96Xbrrfb zYM>6@d?YqFy}R1Bng=@@DH(P%!BAEsvaDn1p(6%yE zMUqQNVj^Pw8K~J1RdhQRxmE=*J}UKEMQK5+^A8T0{p>Tzp2$0@V(owkS&E#0 zm&<*{Yb)GOuXzV;36QN1X~P3Z7mfNU)Ew2wM!U0SPDm*a>LJEG)f2s(fPq9^ z;%@;B&z5_elsiLa6yb45>!;@zsHxa<1yXSO(k&(%>pWz-m46Qo_a0fzkjfJ*(B8lQ z4~XHPol1}p(CO~K^$c|aCA?^m02TE6x^D3*#QGZ#YwrZ7k8i?T$r3llfs2f!+u0`x zNz@xkBQ*DxvFK|fOvT>8&<$f}SYn0e@RBfJTdAbnmg1Z%jsho|c^A3R3}Jl7YU5D6 zZJfNEM1Qt7FmCUv4sn{2aeXtx3Ts2q!iyHHBeZAD1@{RmkXVyr*K75$21ure;ZNUr z^NWpxt7M+ZU?{M-$IZR5hC{7dnUDMddPI#@CdeYC`Mb4JTDmTTt&=<})H6B`YI}V4 zL`vHT&|=cT8d|k)i1q$;8nfny?cj0iWb2U%Rp@*m?avp2)2Ukm>70#`7=nht^QEKh z5zui(=-uOFNIB(@yP%4ubRH=NQSDT5RWVu!8-Y_YZ>8<)!c* zBMPN^JAn5TzmHW~e>X0}`|@NgB0M}p*G&ET#lOg9AZvn4DoBN_N31WdGnh43v{~kb znBvgadZN&5A#jxtFX3?S<^D&PA6xa=!UdR4E=cXUCrUSj)QYvOi)n>AjYIzCPtdf zxLi)|yk$3ZicZ+iEF#zV02|N^+Wm`O>BAzXL>1^SZF&dR15IGioRX?AZ1syf|2sIn zJ%llfv-Q+Ntv9Y1!V2l_F$ImP#L&=fU8Sum)dSb;j@?9Deuj z*i`vj7&h{K-ephdbS|bQ;_38fc^}UG+=Rbn@0nNkRvlNc9$(HjIMIFz6OnLBKg9PU zf0_9QySF2T7KCYYlIamAtaDE$KULDFy15Mv=S6gQ6)c;tza#O5-KSmBNFy+Huv^M_ zq8ZwX+82SX;2B)oD=|Wpg4`f^v5+NPEACzKOq>0|yUbu9?9ysPZt5|pQH{)2bW`*E zw}FMs7~HIWU+S@TO$^hHkrKUD$tZep|61*?Wn~%gEmu!^>Wnef>15LIpK$!{uH)CD zzsb?|8rS!OyRad-U*JH^yqG`y21I^zxqP0#H zIuZL0ecHJinH=0fh}j-F#D;~9qO~DX#{MMYi=%eSdR7of82SeGx<4@PAQ9I?(`!|a zqIa8Z$Ixg>_E`!9u2{ig(BdYvPFnYb+hZ+VnPRSvc4~7hG_&8TqSQ8u_MjiX8PoV2 zt~@v^5xX1G0a7$CP>u0BQ|j1)Hiwy|Y6ZPB>8ORrG|#L~?<&joZP-fJXh)VRG~(;A zM!ayvxe8+gKWr=e!=G?lkECE7 z<*1v-@Do`;H;|cZi{>&rz4a~IlazLv0mDr`RWV{zBeAI*Y(OCca2Z>NcTITe0Rq;@ zN;C_BcrXZq9a2TNs;q%>NgqS!_95)d75%<^j`-q2ow!lP;}gYheDGlePBAEOEljbb z54T3sQ(-PaS&AmwGk~d-OFIr*jN?1NVk})5o5@H-+gIa*z6FQJajmf+Q@5R9kUsq9 zrTAiJCOWD_*m^7L(lus+(tiKVNP25xfL->7OUH;7ITD(WP6#{%9xU!)d;ZtE74+Rh za7h6B1h~4QMwXB>eN+x}Y0tutHR@O91RL942JhfsyT-^8g1>`fqV-MsAa-iRR)X*L z#^2k-7pG4PXPyOH^)0Va;E*tMX|v(!!=@(n@<5A96Y5YS__j{Y=P|4-fqizj=%tf- zhP0P8;V>@K;al!{mVX#3Q2=wVvlssvxa1Eo;<8Riq4?dqcm1Vmm2EI?RSMNP%92&P z7{*U~PZ>`s8jc*A5BJP%BoPdsb$sviVf9;dLm09c>k-s|G|d{Ewi<`acn`B#nF5Ic zu;GTrtfVlE9a(@T)bsWah2xM(1N1e4_pWBLD`v8hc;=lQ#|@<_(EdZ{m|`(Tpl41h0#0HVTC$EKB@xE|51fP6ebcV$kj3~dW` zJDJQ<<;4f&^9Sm%JsweFjA+Cw zjooG;K9KC_{{JLhH$|IzQtEXze&Qmj(S8`G(KdEx>ibyWg7~5$bUb<>kUh2d<_% z#iZ4>g8K;u-M;06y5Lz;?Qa~^S4Hj*JH2Jed<#+~D4@b~dE@%OK;_l{fAkL$*z#Lh zJ%{ZjH$VuML(`yNX1XlZ$S~>ohP9f=y@hueY~~%qWoKTzt|aaKN;HsA;)DGhe%>SXb~iL`7)-SWU019)AzvO<$4&re0#AJeC8`2>63%B z^!6Z=`cQ~oiZI_R5%H3UtKHf=09j1U2Ob+{I@3gIh5Oz4f`Fc@q8rS=N>ikeMJiVa z;?F869QkvB+^lspy%HN1%|zrkU8(|p2~Ukj^lGKuKQ|P>farPF2FS9$`qYrx6Z0mK z`~R%*{wC?eo+c}FCSMiUpf*kR1>lA)V_*x63kB0`J@^+%@(}NHHGgFvhr!lpE~WF) zvDW62pIinE4M=JndXg02U@IjbML(8TN7k}IHa8ve%QBedJq8wf7X*?Y`z3h<&(+u0 zCn>&y4NqZ19+>ACw4NpyE{a|*fZgikDEh6+nb8_xO4Kfx$~_%A3T`2U-9QTY8`hB% zg&zS#*AU$Nf#@(P)bak}g@69}{fmAnjp<3RiKy*lwCsu;x<@||uPgpWWNFfP&yM3y zKJ6#C$lwd(pB@|0Gm0Kd47kEA-MV#acp~h99>qK0E%4r^Jzt5dvgqQb<=7si9QIT- zvemf$cgGctfm!*T!tZ~7Ym|4CdTs6_zr%|UEO9Zee+&Z?=lO~kzBfHL2$Uo56-B}f zEZH<{D>MK4v#||vOny5!~4QW$rlW zDw1|$l~c-`ZuEqoCh9piiD$~ml8&3rQEa6$zNdtV?+`^MSGmTI9Y)*e`#$kXl_Bo^ zrRd>BJZYEsZ9{7F_oPd!C9&am#fFR7{l4SQiQl&1{gasuIW7O*56EWvK~M7=n6){< zZBL*=W(FJ>49NE>6C_F&3h0+oo@JrY@+O`K-rvhH(KSuev&GL&MBovq?1|!jN;%tl zSL5$q{Dc}Qlpg)>*GGz+23AeFM=xaIOSFvNyR87=dGNw)uF4&c=dMIOuTwT8 zBqYS%r0Xj`Uc7Xb`Nu0EI@*H~zLTR%g5qjtaJp-AcEFXp_Yn&FXS{00;6*QG%$x6@ zlA?U+9<$+A=$fXk?Iayyy*kuit?UiIxMH5{UDfoJ0b4{9?TuNt!$B`gH8KrGfkJcD z8ZVYqWiw@s_n+9#c|(6zt=2fIICLGpbb6mM1aIbi_m#+_!#K32r0Vtm{`WscDEeOX za5QM=DH4X_8FITgTNtdd@H3={mXq5!F=GvGBx=zDpFSIw=ur;+!c>syxS|`WFS&LW zD&2xD7Z1000e#M%j;uh(L}!!wEcywUnuAXzFI!?$X_Zm?X3~YdfpyssXxvVe8Wk)q zrZ|{6J%2N9P6{$lxefmh;2Q&q>-wyu^WHZW7|@pKsm|w*Rsy-%y_A71oLGyGf)U&$ Z!AEDOr2RjOK)#I-$=Hhc diff --git a/tinytag/tests/samples/12oz.mp3 b/tinytag/tests/samples/12oz.mp3 deleted file mode 100644 index 5d591c3bd1b2029c8a9674f3477aee823384309a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43062 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 diff --git a/tinytag/tests/samples/REUSE.toml b/tinytag/tests/samples/REUSE.toml new file mode 100644 index 0000000..6726561 --- /dev/null +++ b/tinytag/tests/samples/REUSE.toml @@ -0,0 +1,6 @@ +version = 1 + +[[annotations]] +path = ["*"] +SPDX-FileCopyrightText = "NONE" +SPDX-License-Identifier = "CC0-1.0" diff --git a/tinytag/tests/samples/aiff_with_image.aiff b/tinytag/tests/samples/aiff_with_image.aiff index fec9460a4939f09f7e7e7ccf536a679f8d7b07ef..028e36fd72efc6ff901df8af97bdc233f3b8430c 100644 GIT binary patch literal 21044 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

>Y>yM|SVE8_kuCH(P`MQ?)%Q#|eEO`=a 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 literal 80000 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�$E=l0BytQbyq-y9*Nn58|53KYpHQ=c3Xb( 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ⅇd_(;+tz}HIxkF} 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%0DABCaP$ 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 P;DW#ffeQlvQv&}5CHkNl diff --git a/tinytag/tests/samples/id3_image_jfif.mp3 b/tinytag/tests/samples/id3_image_jfif.mp3 deleted file mode 100644 index f724445893a1c14052f72ede336e2a65cf9460b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 132000 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

; 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%0D=MEScQr`&|MUm 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\)lOn$XM7#ORJI~%M7zXMw9AZu1vAaj)*}9Q&5`E z68Ts|#AiniP{NhLoQmKS2(O}GV#Zxgt}K04n5Bg*I!t`TekLp`g8LL6rvy3BB#D2l zm&iZg8BuGs7qJ3HxuZ=TO%~s8NW&~M%_+z!Dbpejc9#>|nrHR9+914CT-aoe^S9I!!{`1mWQe%YA?AI3}`G#EIk5pdBa30Z13Tvs| zkwy=rTaqTLbq}^I!NWoQihyRxXh$C&1Me1yQJh3W1dIF&v4y>91vhliV=(+Dy2B8( zn%n2n04hYQIhGiv>pvZ(-gSxgAbF__djNKC~OjzpE}ACUHA z>g<6|^m>xft2lKlC5-Z{tdz747EU{eGj^};7gAkIt3J3?X0U+&N$HJc!KohJi$@-7;tU;7(Z|APYReu85} z6e0&=hPAb*1MhjZaM76=hD{ht`!evZD z9Yg@`q4uV=lk$$|VjJH9Id!7TWLvt`P(vh{T1Uz<

qzqS6C1>O^@;jy6;iS+xbH zgJ}@!Q(IZqq0(#!oOf-g%ht_n&Vg7$IW?A>n5chg_{Sr7YZ)gNk6BY0pME9PnxpL~#@ zRn(AAXH5$EX)#!t+7F_cVKJ*I=~x7oP7pOue20~ z+$Ks$h&l@IoH^SCik9`VbrOnb&t&r^3MaSXK8VVS>c^5t-~RZUD7_{@#WH*G-D=>m z{$^k^YL27J26>6|IFM*)R$27M;g?8SRJGZ0y_e+8u3AL|moo!XAnmYAj!&zkL5ndk zcBQk6{;k*$A1Q)Xua|Zh=*1FHv)&ABwcxAYkkZ^dMaoXqMDg3>y~VsIk8&mm2&jpF zUiE~6O0qW)VEQ@Hu45!+KNxGb!gf0fV4BrtG@Pu&xgTvppMz=L9TYflK>7o>5&r;~ zZ*zskvrB2O!{0k8*t7=PA+BzNI1ep8B-aN;->Gl^0hs@-=^Duc0RR9d008&zjTi79 z@ZSyq$ZD#q!w$x;1U401X3Tm^uLN?!WuE5k+eGuj2pd?cd8c*aL#Y2aKK208pui!f`yiw3!S~09*mSWha0H1I|MhabcR-F@T%V0LFs8;?B(3X`V!!FbPXr*+6acKuFp;fIdl$**1os4RGha0qGA^7HYCzp*0# zuO(K0`a@9B2EPq`e%xjz@Cc`3Mrcn93R>U0_S;?%romzHG4K_U)zSBDJ4^2ZyLQG$ z(F10pe6{cPB4Ms#k?C;Bv@IQS5v%n(x;QH&rMB$ZNnnJ=e$T?Y&!krMc_{G%m#${J zUKR?5=U|2?KL_mFAmt*ZSE)zIw!IQw__@v$HE9)XpMuY)0zUWC=yg zt07+#4r^HsjSmH~H>(mPdC~v0Jl_hq7=Z_#4n(ZS^xda9G<8!PbVM!`=qcv)KS8FB K8_Wlct^W^-3-Krb diff --git a/tinytag/tests/samples/id3image_without_description.mp3 b/tinytag/tests/samples/id3image_without_description.mp3 deleted file mode 100644 index a4a8cb15d4f8e1b24e86e4c53e858c078610f307..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 28821 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 A%>V!Z diff --git a/tinytag/tests/samples/id3v22_image.mp3 b/tinytag/tests/samples/id3v22_image.mp3 deleted file mode 100644 index 701219b92f2c279bf06d646cef849e24ad68314f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35924 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 B_JjZc diff --git a/tinytag/tests/samples/id3v22_with_image.mp3 b/tinytag/tests/samples/id3v22_with_image.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..2118a69b949362b95bf2db8c2371bc3da9b99067 GIT binary patch literal 2311 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 diff --git a/tinytag/tests/samples/test3.m4a b/tinytag/tests/samples/mpeg4_with_image.m4a similarity index 77% rename from tinytag/tests/samples/test3.m4a rename to tinytag/tests/samples/mpeg4_with_image.m4a index 8c1e15fdec55abd3ef3c09cf642310361ef47c2c..0cbb76967e80eca2bd3c46ff7f19164f8136d54f 100644 GIT binary patch delta 1189 zcmah}O=uHQ5dPlVv;0cwWd;g(L(XxgZ3wy8mSm!g;oVkHc4B^Cg~2jC zVMQH|Cka0yyjQ2jgcpQA;ovU<580R#grfJyLZn$C92{DM6Kk+KtZRV54-ly}sb#4F zA8>F|;2Q#KnG%V!O?-eal|6rs&Q@C6(TfPqAc`OoC}9K{6wyv+mOKT-DBe>gzsb&z zQkIdRv=OM3asoMMh}#7qrb{HU%Ipg1{rhYe^Ys8V?=9=wZoq8z-w;+y|s3 z;Ays!pPK@X{2>2$#n(CsY`zIRo;51Y#BJco7_fCQAJ^k{BZ+pNP6J;b(tQ~O8eah3 zi_YjMCgOPT2I!}0_00o!CxEsgDt@WTmwPCoJUQaDZ8Tnov^qlT9YQci16?kG2y`3P zGsqq^qgi4yxXdJ(S+fY(7gZ=Ko0r^*xW4PndJCV#!2Ob+Zqnqk9o;Fo{=d0h#Pv`G zR>R$bAQQn@SIuEajCHayV*>&&6RP&8?R0unC9uvGH|a5K@%HdZzpy{boVV4zBFz6k zVGiLReTlG0Wt)mxJ_i|SSpA0t>|V^AQ+7%DQo>wT6n5A=9%wT^D?4iWt+bv>&5Q=d F_;0dG-2(ss delta 91 zcmX?Y`Nd$vX+b8o)tk=>9%kZVepZ@NlE}cod~fnB5p6bx`I$MzC6f6bN*+HtCX>x*8GXSI)9321v diff --git a/tinytag/tests/samples/ogg_with_image.ogg b/tinytag/tests/samples/ogg_with_image.ogg index 79b16f9e6d623e46d468fe60c21e4f0ebe14dd04..6cc4c88188d3ecb1bba3a979bcb6af9159008f69 100644 GIT binary patch delta 1260 zcmZ`&(Tdwv5Vh&zkcS3J652vaO9P>0Q|`(ksa@#9x$?EG-L+R&maLVa1WW6cb}g?> zy@|Yj@h2ofU(i1&^f8c6=szUSrF|-S2sE90y$)+==V5fuoU1dVnbGgxy}$q5{`l3i zzu$fOer2^6KlK}~p9}Z+b@=l!|v0__mY8cq+!)d@<%g}4V7yr#(i3^{y8QJ`JMu|K7qD|-%z~=1ptw=|TE4ODhOPc0$ca=XfFpRo zFOJla-XW_VJ#cc3L;V$AP)*ZNF$47yW^tB=zM_sU`86JD-V3QInqM`+i!-Mu6_rYI z4wBHg8bA_kTp(OcFCumKR5^^omhnUvkZDDQM;f2$grlPN(U;4Sy4nVBLyX``E@1*e z!VR=Wh`@JX?qDGK6BUnh-h+rC$Hwf)S1rCYop7E-S>IKWmP*KTcu+c)(wU;wfdwwm zsv(j*)%wYj+Skkm)};2>{3GhkP5J?lJNJFWAa+XjzQZO zsotKfMfuYS9S>|zdAX5qY4(mOYNzDRXO3EvRr?7CQ_|WWw$-tE$kTYdS`9#dxfzaW zE$zKC;=4W-BUSC<7Y<-KkR1odOn_4Zq$qwV z-@3lXY7$NQX9(TCXR`rS+5in-preM#&zP#7j59U|JOTCM&CfYleL}mS{x;D>a=c=- z>=l-@1cUq^*4$wKLpfj{lvnKijl9evfg^5Kctfpp{syv>>=3R$4%yf=o)PCI_UQ@n=jO<#lW)v4oJF1}AaSc2j4a z_LAdXDgF~^_q5^<^cQgD&OhL^x9!fjq)>#oF^9=K^Yy;-%C~W^UC|bl9TJF zXY%LqGfzo|$+-Wd6-@TW{a+jgR||zgFinOB${O`>LuwaFCpOwxk7e|$9BZ(rS75k!WivQpldN( zQ^xZAu&s>)#v6>$9xn0L8Q%SfWrh?#^fXf;OV$`KWZb>rka7409tDP(a6y9$WIGPq zn|uMeeU9xMzc|B5Xm~u(M`8BB^4pu9OWc8+^Uwpe2Q7h(1<*C>fJCATfxrVW1Z)f3 z2ff1nBhcz#rznT*dAZ9C&II{K(Y=}HS!@QZ3_!tsx)Jr!W1##9Q~_a%3Fs>KpAiM! zwzn5?KFIk|b+P4kM-uNWMaNHXv2Y+tkQu;f!fsR)W6H z2UKc2?B<07!8EJnTqj!Ojk&hS9}qcMa5$!3Nu@{T$nWXeVK--sLiyz8#HY5^-zg4d zpOnK7Lmnr~6%hj7Exp%fhnl#&633z}a}tm_@u`neo_|`=GxE};tUm3CSR*;#5oq+{ zzqQk4zF~nIp#kc)w2>lRB>Qj0z6`?1;j7hV_S5RxZ9_^Uu<1GW=@V7Jro#s+!dykE zDnJwbNoM~Pua>6L9KeN4tX;cByw~<N7;Mxy7cbH{{b(Vgj@gs diff --git a/tinytag/tests/samples/test2.m4a b/tinytag/tests/samples/test2.m4a index 4b58dcd4a61b90c8bb6617bc824d5daadb57ef03..8c1e15fdec55abd3ef3c09cf642310361ef47c2c 100644 GIT binary patch literal 6260 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 z%0D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

Uxikz7zaY_}F|q;GYWU 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%0Dwz15*s<|}`(Jd#5& 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 b6_&n*\n' - '\n' - '\n' - '\n' - '\tasset-info\n' - '\t\n' - '\t\tflavor\n' - '\t\t2:256\n' - '\t\n' - '\n' - '\n' - ], - 'tool': ['144255989988720642'], + 'publisher': ['test7'], + 'bpm': ['1'], + 'encoded_by': ['Lavf60.3.100'] }, - 'bitrate': 256.0, - 'track': 1, - 'albumartist': 'Millie Jackson - Get It Out \'cha System - 1978', - 'duration': 167.78739229024944, - 'filesize': 223365, - 'channels': 2, - 'year': '1978', - 'artist': 'Millie Jackson', - 'track_total': 9, - 'disc_total': 1, - 'genre': 'R&B/Soul', - 'album': "Get It Out 'cha System", - 'samplerate': 44100, - 'disc': 1, - 'title': 'Go Out and Get Some', - 'composer': 'Millie Jackson - Get It Out \'cha System - 1978', - 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', - }), - ('samples/iso8859_with_image.m4a', { - 'extra': {}, - 'artist': 'Major Lazer', - 'filesize': 57017, - 'title': 'Cold Water (feat. Justin Bieber & M\uFFFD)', - 'album': 'Cold Water (feat. Justin Bieber & M\uFFFD) - Single', - 'year': '2016', - 'samplerate': 44100, - 'duration': 188.545, - 'genre': 'Electronic;Music', - 'albumartist': 'Major Lazer', - 'channels': 2, - 'bitrate': 125.584, - 'comment': '? 2016 Mad Decent', + 'artist': 'test1', + 'composer': 'test8', + 'filesize': 7371, + 'samplerate': 8000, + 'duration': 1.294, + 'channels': 1, + 'bitrate': 27.887, }), ('samples/alac_file.m4a', { 'extra': { @@ -1275,7 +1214,7 @@ 'comment': 'test comment', 'duration': 727.1066666666667, }), - ('samples/test3.m4a', { + ('samples/test2.m4a', { 'extra': { 'publisher': ['test7'], 'bpm': ['99999'], @@ -1364,7 +1303,17 @@ 'title': 'song title', 'artist': 'artist 1;artist 2', }), - ]) + ('samples/aiff_with_image.aiff', { + 'extra': {}, + 'channels': 1, + 'duration': 2.176, + 'filesize': 21044, + 'bitrate': 64.0, + 'samplerate': 8000, + 'bitdepth': 8, + 'title': 'image', + }), +]) testfolder = os.path.join(os.path.dirname(__file__)) @@ -1409,7 +1358,7 @@ def error_fmt(value: str | int | float) -> str: def test_file_reading_all(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(testfolder, testfile) - tag = TinyTag.get(filename, tags=True, duration=True) + tag = TinyTag.get(filename, tags=True, duration=True, image=True) results = { key: val for key, val in tag.__dict__.items() if not key.startswith('_') and val is not None @@ -1417,7 +1366,6 @@ def test_file_reading_all(testfile: str, for attr_name in ('filename', 'images'): del results[attr_name] compare_tag(results, expected, filename) - assert tag.images.any is None @pytest.mark.parametrize("testfile,expected", testfiles.items()) @@ -1547,18 +1495,15 @@ def test_invalid_file(path: str, cls: type[TinyTag]) -> None: cls.get(os.path.join(testfolder, path)) -@pytest.mark.parametrize('path,expected_size', [ - ('samples/cover_img.mp3', 146676), - ('samples/id3v22_image.mp3', 18092), - ('samples/id3image_without_description.mp3', 28680), - ('samples/image-text-encoding.mp3', 5708), - ('samples/12oz.mp3', 2210), - ('samples/iso8859_with_image.m4a', 21963), - ('samples/flac_with_image.flac', 73246), - ('samples/wav_with_image.wav', 4627), - ('samples/aiff_with_image.aiff', 21963), +@pytest.mark.parametrize('path,expected_size,desc', [ + ('samples/image-text-encoding.mp3', 5708, 'cover'), + ('samples/id3v22_with_image.mp3', 1220, 'some image ë'), + ('samples/mpeg4_with_image.m4a', 1220, None), + ('samples/flac_with_image.flac', 1220, 'some image ë'), + ('samples/wav_with_image.wav', 4627, 'some image ë'), + ('samples/aiff_with_image.aiff', 1220, 'some image ë'), ]) -def test_image_loading(path: str, expected_size: int) -> None: +def test_image_loading(path: str, expected_size: int, desc: str) -> None: tag = TinyTag.get(os.path.join(testfolder, path), image=True) image = tag.images.any manual_image = None @@ -1578,6 +1523,7 @@ def test_image_loading(path: str, expected_size: int) -> None: assert image.data.startswith(b'\xff\xd8\xff\xe0'), \ 'The image data must start with a jpeg header' assert image.mime_type == 'image/jpeg' + assert image.description == desc def test_image_loading_extra() -> None: @@ -1589,12 +1535,13 @@ def test_image_loading_extra() -> None: assert tag.images.any.data == image.data assert image.mime_type == 'image/jpeg' assert image.name == 'bright_colored_fish' + assert image.description == 'some image ë' assert len(image.data) == 1220 assert str(image) == ( "{'name': 'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00" "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "'mime_type': 'image/jpeg', 'description': None}" + "'mime_type': 'image/jpeg', 'description': 'some image ë'}" ) @@ -1688,7 +1635,7 @@ def test_to_str_images() -> None: "'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" - "lcm..', 'mime_type': 'image/jpeg', 'description': None}]}}" + "lcm..', 'mime_type': 'image/jpeg', 'description': 'some image ë'}]}}" ) @@ -1700,5 +1647,5 @@ def test_to_str_images_flat_dict() -> None: "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01" "\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " - "'description': None}]}" + "'description': 'some image ë'}]}" ) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 3b0c00f..cfd6cdc 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2020-2024 tinytag Contributors +# SPDX-License-Identifier: MIT + # pylint: disable=missing-function-docstring,missing-module-docstring import json @@ -11,7 +14,7 @@ project_folder = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) sample_folder = os.path.join(project_folder, 'tinytag', 'tests', 'samples') -mp3_with_img = os.path.join(sample_folder, 'id3image_without_description.mp3') +mp3_with_img = os.path.join(sample_folder, 'image-text-encoding.mp3') bogus_file = os.path.join(sample_folder, 'there_is_no_such_ext.bogus') assert os.path.exists(mp3_with_img) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index a242b38..dbfd4df 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,13 +1,12 @@ -# tinytag - an audio file metadata reader -# Copyright (c) 2014-2023 Tom Wallroth -# Copyright (c) 2021-2024 Mat (mathiascode) +# SPDX-FileCopyrightText: 2014-2024 tinytag Contributors +# SPDX-License-Identifier: MIT -# Sources on GitHub: +# tinytag - an audio file metadata reader # http://github.com/tinytag/tinytag # MIT License -# Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode) +# Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode), et al. # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), @@ -29,7 +28,6 @@ """Audio file metadata reader""" - from __future__ import annotations from binascii import a2b_base64 from io import BytesIO @@ -1248,9 +1246,6 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: mime_end_pos = content.index(b'\x00', 1) mime_type = self._decode_string( content[1:mime_end_pos]).lower() - # ID3 v2.2 format in v2.3... - if mime_type in self._ID3V2_2_IMAGE_FORMATS: - mime_type = self._ID3V2_2_IMAGE_FORMATS[mime_type] # skip mtype, pictype(1) desc_start_pos = mime_end_pos + 2 pic_type = content[desc_start_pos - 1] From 50feffdc88eb8eb57aafba7d701943639e247854 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 12:43:59 +0300 Subject: [PATCH 235/305] ID3: fix reading of UTF-16 strings without BOM We were missing a sample file for this. --- tinytag/tests/samples/utf16_no_bom.mp3 | Bin 0 -> 1069 bytes tinytag/tests/test_all.py | 6 ++++++ tinytag/tinytag.py | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tinytag/tests/samples/utf16_no_bom.mp3 diff --git a/tinytag/tests/samples/utf16_no_bom.mp3 b/tinytag/tests/samples/utf16_no_bom.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..d82bc19100502b11166521f0b3b1eff316292da3 GIT binary patch literal 1069 zcmeZtF=l1}0uJ>M&k!RZLz;nsi6M_6pFx2k2}tGw=@N!ihGHP8!0 str: # remove ADDITIONAL EXTRA BOM :facepalm: if value[:4] == b'\x00\x00\xff\xfe': value = value[4:] - elif first_byte == b'\x02': # UTF-16LE + elif first_byte == b'\x02': # UTF-16 without BOM # strip optional null byte, if byte count uneven value = value[1:-1] if len(value) % 2 == 0 else value[1:] - encoding = 'UTF-16le' + encoding = 'UTF-16be' elif first_byte == b'\x03': # UTF-8 value = value[1:] encoding = 'UTF-8' From 7df0a7527b2ba4e002ff965cb2162dfda3ba38c2 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 14:44:52 +0300 Subject: [PATCH 236/305] ID3: exclude CHAP/CTOC frames We need to parse these frames properly in the future. The output you get isn't useful at the moment. --- tinytag/tinytag.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ac78120..054a86b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -821,7 +821,9 @@ class _ID3(TinyTag): } _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} - _DISALLOWED_FRAME_IDS = {'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d'} + _DISALLOWED_FRAME_IDS = { + 'CHAP', 'CTOC', 'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d' + } _MAX_ESTIMATION_SEC = 30.0 _CBR_DETECTION_FRAME_COUNT = 5 _USE_XING_HEADER = True # much faster, but can be deactivated for testing From 3886ab4e78ee12f1526209cef809c6628e81ccaf Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 15:17:45 +0300 Subject: [PATCH 237/305] ID3: skip reading frames with invalid sizes Instead of writing garbage into our tags. --- tinytag/tinytag.py | 200 +++++++++++++++++++++++---------------------- 1 file changed, 102 insertions(+), 98 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 054a86b..9aeb362 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -822,7 +822,7 @@ class _ID3(TinyTag): _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} _DISALLOWED_FRAME_IDS = { - 'CHAP', 'CTOC', 'PRIV', 'RGAD', 'GEOB', 'GEO', 'ÿû°d' + 'CHAP', 'CTOC', 'PRIV', 'RGAD', 'GEOB', 'GEO' } _MAX_ESTIMATION_SEC = 30.0 _CBR_DETECTION_FRAME_COUNT = 5 @@ -1081,18 +1081,19 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: def _parse_id3v2(self, fh: BinaryIO) -> None: size, extended, major = self._parse_id3v2_header(fh) - if size: - end_pos = fh.tell() + size - parsed_size = 0 - if extended: # just read over the extended header. - extd_size = self._unsynchsafe(unpack('4B', fh.read(6)[:4])) - fh.seek(extd_size - 6, SEEK_CUR) # jump over extended_header - while parsed_size < size: - frame_size = self._parse_frame(fh, id3version=major) - if frame_size == 0: - break - parsed_size += frame_size - fh.seek(end_pos, SEEK_SET) + if size <= 0: + return + end_pos = fh.tell() + size + parsed_size = 0 + if extended: # just read over the extended header. + extd_size = self._unsynchsafe(unpack('4B', fh.read(6)[:4])) + fh.seek(extd_size - 6, SEEK_CUR) # jump over extended_header + while parsed_size < size: + frame_size = self._parse_frame(fh, size, id3version=major) + if frame_size == 0: + break + parsed_size += frame_size + fh.seek(end_pos, SEEK_SET) def _parse_id3v1(self, fh: BinaryIO) -> None: if fh.read(3) != b'TAG': # check if this is an ID3 v1 tag @@ -1172,7 +1173,10 @@ def _index_utf16(s: bytes, search: bytes) -> int: return i return -1 - def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: + def _parse_frame(self, + fh: BinaryIO, + total_size: int, + id3version: int | None = None) -> int: # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 header_size = 6 if id3version == 2 else 10 frame_size_bytes = 3 if id3version == 2 else 4 @@ -1190,90 +1194,90 @@ def _parse_frame(self, fh: BinaryIO, id3version: int | None = None) -> int: frame_size = unpack('>I', header[4:8])[0] if DEBUG: print(f'Found id3 Frame {frame_id} at ' - f'{fh.tell()}-{fh.tell() + frame_size} of {self.filesize}') - if frame_size > 0: - # flags = frame[1+frame_size_bytes:] # dont care about flags. - content = fh.read(frame_size) - fieldname = self._ID3_MAPPING.get(frame_id) - should_set_field = True - if fieldname: - if not self._parse_tags: - return frame_size - language = fieldname in {'comment', 'extra.lyrics'} - value = self._decode_string(content, language) - if not value: - return frame_size - if fieldname == "comment": - # check if comment is a key-value pair (used by iTunes) - should_set_field = not self.__parse_custom_field(value) - elif fieldname in {'track', 'disc'}: - if '/' in value: - value, total = value.split('/')[:2] - if total.isdecimal(): - self._set_field(f'{fieldname}_total', int(total)) - if value.isdecimal(): - self._set_field(fieldname, int(value)) - should_set_field = False - elif fieldname == 'genre': - genre_id = 255 - # funky: id3v1 genre hidden in a id3v2 field - if value.isdecimal(): - genre_id = int(value) - # funkier: the TCO may contain genres in parens, e.g '(13)' - elif value[:1] == '(': - end_pos = value.find(')') - parens_text = value[1:end_pos] - if end_pos > 0 and parens_text.isdecimal(): - genre_id = int(parens_text) - if 0 <= genre_id < len(self._ID3V1_GENRES): - value = self._ID3V1_GENRES[genre_id] - if should_set_field: - self._set_field(fieldname, value) - elif frame_id in self._CUSTOM_FRAME_IDS: - # custom fields - if self._parse_tags: - value = self._decode_string(content) - if value: - self.__parse_custom_field(value) - elif frame_id in self._IMAGE_FRAME_IDS: - if self._load_image: - # See section 4.14: http://id3.org/id3v2.4.0-frames - encoding = content[:1] - if frame_id == 'PIC': # ID3 v2.2: - imgformat = self._decode_string(content[1:4]).lower() - mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat) - # skip encoding (1), imgformat (3), pictype(1) - desc_start_pos = 5 - else: # ID3 v2.3+ - mime_end_pos = content.index(b'\x00', 1) - mime_type = self._decode_string( - content[1:mime_end_pos]).lower() - # skip mtype, pictype(1) - desc_start_pos = mime_end_pos + 2 - pic_type = content[desc_start_pos - 1] - # latin1 and utf-8 are 1 byte - if encoding in {b'\x00', b'\x03'}: - termination = b'\x00' - else: - termination = b'\x00\x00' - desc_len = self._index_utf16( - content[desc_start_pos:], termination) - desc_end_pos = desc_start_pos + desc_len + len(termination) - desc = self._decode_string( - encoding + content[desc_start_pos:desc_end_pos]) - field_name, image = self._create_tag_image( - content[desc_end_pos:], pic_type, mime_type, desc) - # pylint: disable=protected-access - self.images._set_field(field_name, image) - elif frame_id not in self._DISALLOWED_FRAME_IDS: - # unknown, try to add to extra dict - if self._parse_tags: - value = self._decode_string(content) - if value: - self._set_field( - self._EXTRA_PREFIX + frame_id.lower(), value) - return frame_size - return 0 + f'{fh.tell()}-{fh.tell() + frame_size} of {total_size}') + if frame_size > total_size: + # invalid frame size, stop here + return 0 + content = fh.read(frame_size) + fieldname = self._ID3_MAPPING.get(frame_id) + should_set_field = True + if fieldname: + if not self._parse_tags: + return frame_size + language = fieldname in {'comment', 'extra.lyrics'} + value = self._decode_string(content, language) + if not value: + return frame_size + if fieldname == "comment": + # check if comment is a key-value pair (used by iTunes) + should_set_field = not self.__parse_custom_field(value) + elif fieldname in {'track', 'disc'}: + if '/' in value: + value, total = value.split('/')[:2] + if total.isdecimal(): + self._set_field(f'{fieldname}_total', int(total)) + if value.isdecimal(): + self._set_field(fieldname, int(value)) + should_set_field = False + elif fieldname == 'genre': + genre_id = 255 + # funky: id3v1 genre hidden in a id3v2 field + if value.isdecimal(): + genre_id = int(value) + # funkier: the TCO may contain genres in parens, e.g '(13)' + elif value[:1] == '(': + end_pos = value.find(')') + parens_text = value[1:end_pos] + if end_pos > 0 and parens_text.isdecimal(): + genre_id = int(parens_text) + if 0 <= genre_id < len(self._ID3V1_GENRES): + value = self._ID3V1_GENRES[genre_id] + if should_set_field: + self._set_field(fieldname, value) + elif frame_id in self._CUSTOM_FRAME_IDS: + # custom fields + if self._parse_tags: + value = self._decode_string(content) + if value: + self.__parse_custom_field(value) + elif frame_id in self._IMAGE_FRAME_IDS: + if self._load_image: + # See section 4.14: http://id3.org/id3v2.4.0-frames + encoding = content[:1] + if frame_id == 'PIC': # ID3 v2.2: + imgformat = self._decode_string(content[1:4]).lower() + mime_type = self._ID3V2_2_IMAGE_FORMATS.get(imgformat) + # skip encoding (1), imgformat (3), pictype(1) + desc_start_pos = 5 + else: # ID3 v2.3+ + mime_end_pos = content.index(b'\x00', 1) + mime_type = self._decode_string( + content[1:mime_end_pos]).lower() + # skip mtype, pictype(1) + desc_start_pos = mime_end_pos + 2 + pic_type = content[desc_start_pos - 1] + # latin1 and utf-8 are 1 byte + if encoding in {b'\x00', b'\x03'}: + termination = b'\x00' + else: + termination = b'\x00\x00' + desc_len = self._index_utf16( + content[desc_start_pos:], termination) + desc_end_pos = desc_start_pos + desc_len + len(termination) + desc = self._decode_string( + encoding + content[desc_start_pos:desc_end_pos]) + field_name, image = self._create_tag_image( + content[desc_end_pos:], pic_type, mime_type, desc) + # pylint: disable=protected-access + self.images._set_field(field_name, image) + elif frame_id not in self._DISALLOWED_FRAME_IDS: + # unknown, try to add to extra dict + if self._parse_tags: + value = self._decode_string(content) + if value: + self._set_field( + self._EXTRA_PREFIX + frame_id.lower(), value) + return frame_size def _decode_string(self, value: bytes, language: bool = False) -> str: default_encoding = 'ISO-8859-1' From a895e5be2c897cb3b52a46da002e2885a97b97bf Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 20:39:36 +0300 Subject: [PATCH 238/305] Remove support for unpacking unused value types (#228) Until someone shows up with audio files that actually use these value types, remove support for them. I haven't seen them used in the wild, and we have no sample files for our test suite. --- tinytag/tinytag.py | 150 +++++++++++++++------------------------------ 1 file changed, 48 insertions(+), 102 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 9aeb362..ba16e32 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -478,23 +478,19 @@ class _MP4(TinyTag): 'barcode': 'extra.barcode', 'catalognumber': 'extra.catalog_number', } - _UNPACK_SIGNED_FORMATS = { + _IMAGE_MIME_TYPES = { + 13: 'image/jpeg', + 14: 'image/png' + } + _UNPACK_FORMATS = { 1: '>b', 2: '>h', 4: '>i', 8: '>q' } - _UNPACK_UNSIGNED_FORMATS = { - 1: '>B', - 2: '>H', - 4: '>I', - 8: '>Q' - } _VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header _FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header - _atom_decoder_by_type: dict[ - int, Callable[[bytes], int | str | bytes | Image]] | None = None _audio_data_tree: dict[bytes, Any] | None = None _meta_data_tree: dict[bytes, Any] | None = None @@ -540,11 +536,11 @@ def _parse_tag(self, fh: BinaryIO) -> None: b'aART': {b'data': _MP4._data_parser('albumartist')}, b'cprt': {b'data': _MP4._data_parser('extra.copyright')}, b'desc': {b'data': _MP4._data_parser('extra.description')}, - b'disk': {b'data': _MP4._num_parser('disc', 'disc_total')}, + b'disk': {b'data': _MP4._nums_parser('disc', 'disc_total')}, b'gnre': {b'data': _MP4._parse_id3v1_genre}, - b'trkn': {b'data': _MP4._num_parser('track', 'track_total')}, + b'trkn': {b'data': _MP4._nums_parser('track', 'track_total')}, b'tmpo': {b'data': _MP4._data_parser('extra.bpm')}, - b'covr': {b'data': _MP4._data_parser('images.front_cover')}, + b'covr': {b'data': _MP4._parse_cover_image}, b'----': _MP4._parse_custom_field, }}}}} self._traverse_atoms(fh, path=_MP4._meta_data_tree) @@ -598,89 +594,36 @@ def _traverse_atoms(self, return # return to parent (next parent node in tree) atom_header = fh.read(header_size) # read next atom - @classmethod - def _unpack_utf_8_string(cls, value: bytes) -> str: - return value.decode('utf-8', 'replace') - - @classmethod - def _unpack_utf_16_string(cls, value: bytes) -> str: - return value.decode('utf-16', 'replace') - - @classmethod - def _unpack_shift_jis_string(cls, value: bytes) -> str: - return value.decode('s/jis', 'replace') - - @classmethod - def _unpack_jpeg_image(cls, data: bytes) -> Image: - return Image('front_cover', data, 'image/jpeg') - - @classmethod - def _unpack_png_image(cls, data: bytes) -> Image: - return Image('front_cover', data, 'image/png') - - @classmethod - def _unpack_integer(cls, value: bytes, signed: bool = True) -> str: - fmts = cls._UNPACK_UNSIGNED_FORMATS - if signed: - fmts = cls._UNPACK_SIGNED_FORMATS - value_len = len(value) - if value_len in fmts: - return str(unpack(fmts[value_len], value)[0]) - return "" - - @classmethod - def _unpack_integer_unsigned(cls, value: bytes) -> str: - return cls._unpack_integer(value, signed=False) - @classmethod def _data_parser( cls, fieldname: str - ) -> Callable[[bytes], dict[str, int | str | bytes | Image]]: + ) -> Callable[[bytes], dict[str, int | str | bytes | None]]: def _parse_data_atom( data_atom: bytes - ) -> dict[str, int | str | bytes | Image]: + ) -> dict[str, int | str | bytes | None]: data_type = unpack('>I', data_atom[:4])[0] - if cls._atom_decoder_by_type is None: - # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html#//apple_ref/doc/uid/TP40000939-CH1-SW34 - cls._atom_decoder_by_type = { - # 0: 'reserved' - 1: cls._unpack_utf_8_string, # UTF-8 - 2: cls._unpack_utf_16_string, # UTF-16 - 3: cls._unpack_shift_jis_string, # S/JIS - # 16: duration in millis - 13: cls._unpack_jpeg_image, # JPEG - 14: cls._unpack_png_image, # PNG - 21: cls._unpack_integer, # BE Signed - 22: cls._unpack_integer_unsigned, # BE Unsigned - 65: cls._unpack_integer, # 8-bit Signed - 66: cls._unpack_integer, # BE 16-bit Signed - 67: cls._unpack_integer, # BE 32-bit Signed - 74: cls._unpack_integer, # BE 64-bit Signed - 75: cls._unpack_integer_unsigned, # 8-bit Unsigned - 76: cls._unpack_integer_unsigned, # BE 16-bit Unsigned - 77: cls._unpack_integer_unsigned, # BE 32-bit Unsigned - 78: cls._unpack_integer_unsigned, # BE 64-bit Unsigned - } - conversion = cls._atom_decoder_by_type.get(data_type) - if conversion is None: - if DEBUG: - print(f'Cannot convert data type: {data_type}', - file=stderr) - return {} # don't know how to convert data atom - # skip header & null-bytes, convert rest - return {fieldname: conversion(data_atom[8:])} + data = data_atom[8:] + value = None + if data_type == 1: # UTF-8 string + value = data.decode('utf-8', 'replace') + elif data_type == 21: # BE signed integer + fmts = cls._UNPACK_FORMATS + data_len = len(data) + if data_len in fmts: + value = str(unpack(fmts[data_len], data)[0]) + return {fieldname: value} return _parse_data_atom @classmethod - def _num_parser( + def _nums_parser( cls, fieldname1: str, fieldname2: str ) -> Callable[[bytes], dict[str, int]]: - def _(data_atom: bytes) -> dict[str, int]: + def _parse_nums(data_atom: bytes) -> dict[str, int]: number_data = data_atom[8:14] numbers = unpack('>3H', number_data) # for some reason the first number is always irrelevant. return {fieldname1: numbers[1], fieldname2: numbers[2]} - return _ + return _parse_nums @classmethod def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: @@ -692,6 +635,13 @@ def _parse_id3v1_genre(cls, data_atom: bytes) -> dict[str, str]: result['genre'] = _ID3._ID3V1_GENRES[idx] return result + @classmethod + def _parse_cover_image(cls, data_atom: bytes) -> dict[str, Image]: + data_type = unpack('>I', data_atom[:4])[0] + image = Image( + 'front_cover', data_atom[8:], cls._IMAGE_MIME_TYPES.get(data_type)) + return {'images.front_cover': image} + @classmethod def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: for _i in range(4): @@ -701,7 +651,7 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: @classmethod def _parse_custom_field( cls, data: bytes - ) -> dict[str, int | str | bytes | Image]: + ) -> dict[str, int | str | bytes | None]: fh = BytesIO(data) header_size = 8 field_name = None @@ -1841,9 +1791,18 @@ def _parse_tag(self, fh: BinaryIO) -> None: name_len = unpack(' None: if name.startswith('WM/'): name = name[3:] field_name = self._EXTRA_PREFIX + name.lower() - val = self._decode_ext_desc(val_type, walker.read(val_len)) - if val: - if field_name in {'track', 'disc'}: - if isinstance(val, int) or val.isdecimal(): - self._set_field(field_name, int(val)) - elif val: - self._set_field(field_name, val) + if field_name in {'track', 'disc'}: + if isinstance(value, int) or value.isdecimal(): + self._set_field(field_name, int(value)) + elif value: + self._set_field(field_name, value) elif object_id == self._ASF_FILE_PROP and self._parse_duration: data = fh.read(object_size - 24) play_duration = unpack(' None: fh.seek(object_size - 24, SEEK_CUR) # skip unknown object ids self._tags_parsed = True - @classmethod - def _decode_ext_desc(cls, value_type: int, value: bytes) -> str: - """ decode _ASF_EXT_CONTENT_DESC values""" - if value_type == 0: # Unicode string - return cls._unpad(value.decode('utf-16', 'replace')) - if 1 < value_type < 6: # DWORD / QWORD / WORD - value_len = len(value) - if value_len in cls._UNPACK_FORMATS: - return str(unpack(cls._UNPACK_FORMATS[value_len], value)[0]) - return "" - class _Aiff(TinyTag): """"AIFF Parser From f7f723e4715fb533c5af7d45816d317554f0e444 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 19 Oct 2024 22:39:26 +0300 Subject: [PATCH 239/305] Various cleanups --- tinytag/tinytag.py | 64 ++++++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 39 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ba16e32..36aba6e 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1116,13 +1116,6 @@ def _create_tag_image(cls, image.description = description return field_name, image - @staticmethod - def _index_utf16(s: bytes, search: bytes) -> int: - for i in range(0, len(s), len(search)): - if s[i:i + len(search)] == search: - return i - return -1 - def _parse_frame(self, fh: BinaryIO, total_size: int, @@ -1208,12 +1201,13 @@ def _parse_frame(self, pic_type = content[desc_start_pos - 1] # latin1 and utf-8 are 1 byte if encoding in {b'\x00', b'\x03'}: - termination = b'\x00' + desc_end_pos = content.find(b'\x00', desc_start_pos) + 1 else: - termination = b'\x00\x00' - desc_len = self._index_utf16( - content[desc_start_pos:], termination) - desc_end_pos = desc_start_pos + desc_len + len(termination) + desc_end_pos = 0 + for i in range(desc_start_pos, len(content), 2): + if content[i:i + 2] == b'\x00\x00': + desc_end_pos = i + 2 + break desc = self._decode_string( encoding + content[desc_start_pos:desc_end_pos]) field_name, image = self._create_tag_image( @@ -1332,21 +1326,16 @@ def _determine_duration(self, fh: BinaryIO) -> None: if self.duration is not None or not self.samplerate: return # either ogg flac or invalid file if self.filesize > max_page_size: - fh.seek(-max_page_size, 2) # go to last possible page position - while True: - file_offset = fh.tell() - b = fh.read() - if len(b) < 4: - return # EOF - if b[:4] == b'OggS': # look for an ogg header - fh.seek(file_offset) - for _ in self._parse_pages(fh): - pass # parse all remaining pages - self.duration = self._max_samplenum / self.samplerate - break - idx = b.find(b'OggS') # try to find header in peeked data - if idx != -1: - fh.seek(file_offset + idx) + # go to last possible page position + fh.seek(-max_page_size, SEEK_END) + data = fh.read() + idx = data.find(b'OggS') # try to find header in read data + if idx != -1: + walker = BytesIO(data) + walker.seek(idx) + for _packet in self._parse_pages(walker): + pass # parse all remaining pages + self.duration = self._max_samplenum / self.samplerate def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False @@ -1360,7 +1349,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif packet[:7] == b"\x03vorbis": if self._parse_tags: walker = BytesIO(packet) - walker.seek(7, SEEK_CUR) # jump over header name + walker.seek(7) # jump over header name self._parse_vorbis_comment(walker) elif packet[:8] == b'OpusHead': if self._parse_duration: # parse opus header @@ -1373,13 +1362,13 @@ def _parse_tag(self, fh: BinaryIO) -> None: elif packet[:8] == b'OpusTags': if self._parse_tags: # parse opus metadata: walker = BytesIO(packet) - walker.seek(8, SEEK_CUR) # jump over header name + walker.seek(8) # jump over header name self._parse_vorbis_comment(walker) elif packet[:5] == b'\x7fFLAC': # https://xiph.org/flac/ogg_mapping.html walker = BytesIO(packet) # jump over header name, version and number of headers - walker.seek(9, SEEK_CUR) + walker.seek(9) # pylint: disable=protected-access flactag = _Flac() flactag._filehandler = walker @@ -1558,13 +1547,12 @@ def _parse_tag(self, fh: BinaryIO) -> None: self.duration = ( subchunksize / self.channels / self.samplerate / (self.bitdepth / 8)) - fh.seek(subchunksize, 1) + fh.seek(subchunksize, SEEK_CUR) elif subchunkid == b'LIST' and self._parse_tags: - is_info = fh.read(4) # check INFO header - if is_info != b'INFO': # jump over non-INFO sections - fh.seek(subchunksize - 4, SEEK_CUR) - else: - walker = BytesIO(fh.read(subchunksize - 4)) + chunk = fh.read(subchunksize) + if chunk[:4] == b'INFO': + walker = BytesIO(chunk) + walker.seek(4) # skip header field = walker.read(4) while len(field) == 4: data_length = unpack('I', walker.read(4))[0] @@ -1588,7 +1576,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) else: # some other chunk, just skip the data - fh.seek(subchunksize, 1) + fh.seek(subchunksize, SEEK_CUR) chunk_header = fh.read(8) self._tags_parsed = True @@ -1669,8 +1657,6 @@ def _parse_tag(self, fh: BinaryIO) -> None: fieldname, value = self._parse_image(fh) # pylint: disable=protected-access self.images._set_field(fieldname, value) - elif block_type >= 127: - break # invalid block type else: if DEBUG: print('Unknown FLAC block type', block_type) From a7794148b5b0cac4f3c526782726c46ef1638db3 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 00:00:55 +0300 Subject: [PATCH 240/305] Improve test cases for string representation (#229) --- tinytag/tests/samples/aiff_with_image.aiff | Bin 21044 -> 21044 bytes .../tests/samples/flac_multiple_fields.flac | Bin 266 -> 0 bytes tinytag/tests/samples/flac_with_image.flac | Bin 4692 -> 2824 bytes tinytag/tests/test_all.py | 120 +++++++++--------- 4 files changed, 62 insertions(+), 58 deletions(-) delete mode 100644 tinytag/tests/samples/flac_multiple_fields.flac diff --git a/tinytag/tests/samples/aiff_with_image.aiff b/tinytag/tests/samples/aiff_with_image.aiff index 028e36fd72efc6ff901df8af97bdc233f3b8430c..bbf89fb6d73cd4551f2bad3898525bea8bf64762 100644 GIT binary patch delta 16 Ycmdn8gmKFf#toG&j0~HrTz2sS06NtMSpWb4 delta 16 Ycmdn8gmKFf#toG&jLe&>Tz2sS06OOeTmS$7 diff --git a/tinytag/tests/samples/flac_multiple_fields.flac b/tinytag/tests/samples/flac_multiple_fields.flac deleted file mode 100644 index bcbdd23ba8cdf8b0329ebf8ce30f3942168012dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 266 zcmYfENpxmlU{DfZ5CBr#3=F(nM;tydFbG<`Dvwz|KlVF!&IOe#l8Q3RB|vfT5bSxzOn%TS?oxn diff --git a/tinytag/tests/samples/flac_with_image.flac b/tinytag/tests/samples/flac_with_image.flac index 60741a560ef3f170f6c25cc81d33a377ff443c0c..2e3352d68e2202666568eb3a116e39e7efdb86f2 100644 GIT binary patch delta 267 zcmcbj(jlgo=9B2mz`&p+z#ssmxEUCDxsEt|U|_b1u;qjZ_pTnMCJ=a2qY#j#2CX16BAy{sYpWbe4cyehGrWtcxUcu zuJ?SN-}m?DzWrU3yG%S40kFIgjdYq1=)TeLIMlZ;96#IrXXclYBNuP#?I(QC3|GT* zk0czvTp^XU*1le{vPAWj!GdKtCF9~JRA`wzf^??vjM`)NPtdnSV^JuIg6HW2`<8kp z7K!Xk^lpvD;yu&=sK@eVq2g%=3dNyv@8+=4*T3EH{)}39;KL#`rOZmHJCTUX$TNN4 zxds{u_^yq|nn^K7t$x6&n1EuCW|LVe4^jRa<(`e1P+}IWOe*(Au2f zE3C7MFKwf9D;>?)j2K=(0tV99gBks+QYN-U+nZxP_H!g%E6=_&tVV+ zq@4cX-+(j})81Axp$?~{)P;enA8q@$m4Np{;Qm3|zIWWV@1Fp)AAk$R>HdSqfQ^4p z{or)1^(fFV0-PJ2uB8tFpB(^#H%h5;%9%%_Z#9zvCQi|g>j#>y(ms4ye4{pQ$I0tJ zlDvvv1`ZzqR?+rs*fGm>*HDKk-{#az`e)xng!k_w1rPE_18`^iYxryEHfzxoKUBY> z`4xK<3!Hn1eV7U#l>>Zd{X6XMQKuxN8{ZQW8IqdYeGgxdq=3fvm?a*)Y6fRCF4EMI{%9M;G3X|8?FPc1Fi$E1Fi$E z1Fi$E1Fi%AV+ZWJ{Xy)O3)J6ZQu0CvY$nwbI&U#)X`v@{CeN_YViK&Jkh$)_YnrU8A=*u{h?-il_*O+SQv7MvwBvaj1p*y#j>gn|aN9qvM z{BEJXdZvb`(3Llt^d&;s+f4puq4C2^jSGan)tH+2^$NwSZ!iVCLSJlPniQff%(QTx zv<1eP7SSsij(1Np1y=~|*va%5Kl)Q#TFtb$O=#i_(~{Lf);CPeT|)atm>%CKwD4D^ wmUTjFcQY;R6#Be{$#`GruPD>94}@|drseyE5+B=l_XbyZzT%f76n#B^19J&smH+?% diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index c9c0d3a..98ba501 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1059,33 +1059,23 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/flac_multiple_fields.flac', { + ('samples/flac_with_image.flac', { 'extra': { 'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'], 'album': ['album 2'], 'url': ['https://example.com'], }, - 'filesize': 266, + 'filesize': 2824, 'album': 'album 1', 'artist': 'artist 1', - 'bitrate': 21.28, + 'bitrate': 225.92, 'channels': 1, 'duration': 0.1, 'genre': 'genre 1', 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/flac_with_image.flac', { - 'extra': {}, - 'channels': 2, - 'duration': 3.684716553287982, - 'filesize': 4692, - 'bitrate': 10.186943678613627, - 'samplerate': 44100, - 'bitdepth': 16, - 'title': 'image', - }), ('samples/test2.wma', { 'extra': { '_track': ['0'], @@ -1519,10 +1509,11 @@ def test_image_loading(path: str, expected_size: int, desc: str) -> None: manual_image = tag.images.other[0] assert image is not None assert manual_image is not None - image.data = manual_image.data assert image.name in {'front_cover', 'other'} assert image.data is not None assert image.data == manual_image.data + with pytest.warns(DeprecationWarning): + assert image.data == tag.get_image() image_size = len(image.data) assert image_size == expected_size, \ f'Image is {image_size} bytes but should be {expected_size} bytes' @@ -1539,6 +1530,8 @@ def test_image_loading_extra() -> None: assert image.data is not None assert tag.images.any is not None assert tag.images.any.data == image.data + with pytest.warns(DeprecationWarning): + assert image.data == tag.get_image() assert image.mime_type == 'image/jpeg' assert image.name == 'bright_colored_fish' assert image.description == 'some image ë' @@ -1601,57 +1594,68 @@ def test_deprecations() -> None: def test_to_str() -> None: - tag = TinyTag.get(os.path.join(testfolder, 'samples/id3v22-test.mp3')) - assert ( - "'filesize': 5120, 'duration': 0.13836297152858082, 'channels': 2, " - "'bitrate': 160.0, 'bitdepth': None, 'samplerate': 44100, " - "'artist': 'Anais Mitchell', 'albumartist': None, 'composer': None, " - "'album': 'Hymns for the Exiled', 'disc': None, 'disc_total': None, " - "'title': 'cosmic american', 'track': 3, 'track_total': 11, " - "'genre': None, 'year': '2004', " - "'comment': 'Waterbug Records, www.anaismitchell.com', " - "'extra': {'encoded_by': ['iTunes v4.6'], 'itunnorm': [' 0000044E " - "00000061 00009B67 000044C3 00022478 00022182 00007FCC 00007E5C " - "0002245E 0002214E'], 'itunes_cddb_1': ['9D09130B+174405+11+150+14097" - "+27391+43983+65786+84877+99399+113226+132452+146426+163829'], " - "'itunes_cddb_tracknumber': ['3']}, 'images': {'front_cover': [], " - "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], " - "'extra': {}}" - ) in str(tag) - - -def test_to_str_flat_dict() -> None: - tag = TinyTag.get( - os.path.join(testfolder, 'samples/flac_multiple_fields.flac')) - assert ( - "'filesize': 266, 'duration': 0.1, 'channels': 1, 'bitrate': 21.28, " - "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " - "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], " - "'genre': ['genre 1', 'genre 2'], 'url': ['https://example.com'], " - "'images': {}" - ) in str(tag.as_dict()) - - -def test_to_str_images() -> None: tag = TinyTag.get( - os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) + assert str(tag).endswith( + "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " + "'bitdepth': 16, 'samplerate': 44100, 'artist': 'artist 1', " + "'albumartist': None, 'composer': None, 'album': 'album 1', " + "'disc': None, 'disc_total': None, 'title': None, 'track': None, " + "'track_total': None, 'genre': 'genre 1', 'year': None, " + "'comment': None, 'extra': {'artist': ['artist 2', 'artist 3'], " + "'album': ['album 2'], 'genre': ['genre 2'], " + "'url': ['https://example.com']}, 'images': {'front_cover': " + "[{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" + "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" + "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': " + "'image/jpeg', 'description': 'some image ë'}], 'back_cover': [], " + "'leaflet': [], 'media': [], 'other': [], 'extra': " + "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " + "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" + "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" + "\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', 'description': " + "'some image ë'}]}}}" + ) assert str(tag.images) == ( - "{'front_cover': [], 'back_cover': [], 'leaflet': [], 'media': [], " - "'other': [], 'extra': {'bright_colored_fish': [{'name': " - "'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" - "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" - "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0" - "lcm..', 'mime_type': 'image/jpeg', 'description': 'some image ë'}]}}" + "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" + "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" + "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " + "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " + "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], 'extra': " + "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " + "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" + "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" + "\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', 'description': " + "'some image ë'}]}}" ) -def test_to_str_images_flat_dict() -> None: +def test_to_str_flat_dict() -> None: tag = TinyTag.get( - os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) + assert str(tag.as_dict()).endswith( + "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " + "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " + "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], 'genre': " + "['genre 1', 'genre 2'], 'url': ['https://example.com'], 'images': " + "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" + "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" + "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " + "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " + "'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " + "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" + "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" + "\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', 'description': " + "'some image ë'}]}}" + ) assert str(tag.images.as_dict()) == ( - "{'bright_colored_fish': [{'name': 'bright_colored_fish', " + "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" + "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" + "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " + "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " + "'bright_colored_fish': [{'name': 'bright_colored_fish', " "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" - "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01" - "\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " + "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01" + "\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " "'description': 'some image ë'}]}" ) From 7da270c2f7aefd6f5e2a4a0d5b2e3706911bf9a9 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 01:47:12 +0300 Subject: [PATCH 241/305] Reduce number of read calls on file handles --- tinytag/tinytag.py | 166 +++++++++++++++++++++------------------------ 1 file changed, 77 insertions(+), 89 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 36aba6e..b6dbe98 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -550,20 +550,20 @@ def _traverse_atoms(self, path: dict[bytes, Any], stop_pos: int | None = None, curr_path: list[bytes] | None = None) -> None: - header_size = 8 - atom_header = fh.read(header_size) - while len(atom_header) == header_size: - atom_size = unpack('>I', atom_header[:4])[0] - header_size + header_len = 8 + atom_header = fh.read(header_len) + while len(atom_header) == header_len: + atom_size = unpack('>I', atom_header[:4])[0] - header_len atom_type = atom_header[4:] if curr_path is None: # keep track how we traversed in the tree curr_path = [atom_type] if atom_size <= 0: # empty atom, jump to next one - atom_header = fh.read(header_size) + atom_header = fh.read(header_len) continue if DEBUG: print(f'{" " * 4 * len(curr_path)} ' - f'pos: {fh.tell() - header_size} ' - f'atom: {atom_type!r} len: {atom_size + header_size}') + f'pos: {fh.tell() - header_len} ' + f'atom: {atom_type!r} len: {atom_size + header_len}') if atom_type in self._VERSIONED_ATOMS: # jump atom version for now fh.seek(4, SEEK_CUR) if atom_type in self._FLAGGED_ATOMS: # jump atom flags for now @@ -592,7 +592,7 @@ def _traverse_atoms(self, # check if we have reached the end of this branch: if stop_pos and fh.tell() >= stop_pos: return # return to parent (next parent node in tree) - atom_header = fh.read(header_size) # read next atom + atom_header = fh.read(header_len) # read next atom @classmethod def _data_parser( @@ -653,12 +653,12 @@ def _parse_custom_field( cls, data: bytes ) -> dict[str, int | str | bytes | None]: fh = BytesIO(data) - header_size = 8 + header_len = 8 field_name = None data_atom = b'' - atom_header = fh.read(header_size) - while len(atom_header) == header_size: - atom_size = unpack('>I', atom_header[:4])[0] - header_size + atom_header = fh.read(header_len) + while len(atom_header) == header_len: + atom_size = unpack('>I', atom_header[:4])[0] - header_len atom_type = atom_header[4:] if atom_type == b'name': atom_value = fh.read(atom_size)[4:].lower() @@ -670,7 +670,7 @@ def _parse_custom_field( data_atom = fh.read(atom_size) else: fh.seek(atom_size, SEEK_CUR) - atom_header = fh.read(header_size) # read next atom + atom_header = fh.read(header_len) # read next atom if len(data_atom) < 8 or field_name is None: return {} parser = cls._data_parser(field_name) @@ -1121,11 +1121,11 @@ def _parse_frame(self, total_size: int, id3version: int | None = None) -> int: # ID3v2.2 especially ugly. see: http://id3.org/id3v2-00 - header_size = 6 if id3version == 2 else 10 + header_len = 6 if id3version == 2 else 10 frame_size_bytes = 3 if id3version == 2 else 4 is_synchsafe_int = id3version == 4 - header = fh.read(header_size) - if len(header) != header_size: + header = fh.read(header_len) + if len(header) != header_len: return 0 frame_id = self._decode_string(header[:frame_size_bytes]) frame_size: int @@ -1137,7 +1137,7 @@ def _parse_frame(self, frame_size = unpack('>I', header[4:8])[0] if DEBUG: print(f'Found id3 Frame {frame_id} at ' - f'{fh.tell()}-{fh.tell() + frame_size} of {total_size}') + f'{fh.tell()}-{fh.tell() + frame_size} of {self.filesize}') if frame_size > total_size: # invalid frame size, stop here return 0 @@ -1319,23 +1319,12 @@ def __init__(self) -> None: self._max_samplenum = 0 # maximum sample position ever read def _determine_duration(self, fh: BinaryIO) -> None: - max_page_size = 65536 # https://xiph.org/ogg/doc/libogg/ogg_page.html if not self._tags_parsed: self._parse_tag(fh) # determine sample rate fh.seek(0) # and rewind to start if self.duration is not None or not self.samplerate: return # either ogg flac or invalid file - if self.filesize > max_page_size: - # go to last possible page position - fh.seek(-max_page_size, SEEK_END) - data = fh.read() - idx = data.find(b'OggS') # try to find header in read data - if idx != -1: - walker = BytesIO(data) - walker.seek(idx) - for _packet in self._parse_pages(walker): - pass # parse all remaining pages - self.duration = self._max_samplenum / self.samplerate + self.duration = self._max_samplenum / self.samplerate def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False @@ -1404,11 +1393,6 @@ def _parse_tag(self, fh: BinaryIO) -> None: # other tags self._parse_vorbis_comment(walker, has_vendor=False) check_speex_second_packet = False - else: - if DEBUG: - print('Unsupported Ogg page type: ', - packet[:16], file=stderr) - break self._tags_parsed = True def _parse_vorbis_comment(self, @@ -1456,14 +1440,15 @@ def _parse_vorbis_comment(self, def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: # for the spec, see: https://wiki.xiph.org/Ogg previous_page = b'' # contains data from previous (continuing) pages - header_data = fh.read(27) # read ogg page header - while len(header_data) == 27: - version = header_data[4] - if header_data[:4] != b'OggS' or version != 0: + header_len = 27 + page_header = fh.read(header_len) # read ogg page header + while len(page_header) == header_len: + version = page_header[4] + if page_header[:4] != b'OggS' or version != 0: raise ParseError('Invalid OGG header') # https://xiph.org/ogg/doc/framing.html - pos = unpack(' Iterator[bytes]: else: yield previous_page + fh.read(total) previous_page = b'' - header_data = fh.read(27) + page_header = fh.read(header_len) class _Wave(TinyTag): @@ -1524,14 +1509,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: raise ParseError('Invalid WAV header') if self._parse_duration: self.bitdepth = 16 # assume 16bit depth (CD quality) - chunk_header = fh.read(8) - while len(chunk_header) == 8: - subchunkid = chunk_header[:4] - subchunksize = unpack('I', chunk_header[4:8])[0] + header_len = 8 + chunk_header = fh.read(header_len) + while len(chunk_header) == header_len: + subchunk_id = chunk_header[:4] + subchunk_size = unpack('I', chunk_header[4:])[0] # IFF chunks are padded to an even number of bytes - subchunksize += subchunksize % 2 - if subchunkid == b'fmt ' and self._parse_duration: - chunk = fh.read(subchunksize) + subchunk_size += subchunk_size % 2 + if subchunk_id == b'fmt ' and self._parse_duration: + chunk = fh.read(subchunk_size) _format_tag, channels, samplerate = unpack(' None: self.bitrate = samplerate * channels * bitdepth / 1000 self.channels, self.samplerate, self.bitdepth = ( channels, samplerate, bitdepth) - elif subchunkid == b'data' and self._parse_duration: + elif subchunk_id == b'data' and self._parse_duration: if (self.channels is not None and self.samplerate is not None and self.bitdepth is not None): self.duration = ( - subchunksize / self.channels / self.samplerate + subchunk_size / self.channels / self.samplerate / (self.bitdepth / 8)) - fh.seek(subchunksize, SEEK_CUR) - elif subchunkid == b'LIST' and self._parse_tags: - chunk = fh.read(subchunksize) + fh.seek(subchunk_size, SEEK_CUR) + elif subchunk_id == b'LIST' and self._parse_tags: + chunk = fh.read(subchunk_size) if chunk[:4] == b'INFO': walker = BytesIO(chunk) walker.seek(4) # skip header @@ -1569,15 +1555,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: else: self._set_field(fieldname, value) field = walker.read(4) - elif subchunkid in {b'id3 ', b'ID3 '} and self._parse_tags: + elif subchunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: # pylint: disable=protected-access id3 = _ID3() id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) else: # some other chunk, just skip the data - fh.seek(subchunksize, SEEK_CUR) - chunk_header = fh.read(8) + fh.seek(subchunk_size, SEEK_CUR) + chunk_header = fh.read(header_len) self._tags_parsed = True @@ -1603,7 +1589,6 @@ def _parse_tag(self, fh: BinaryIO) -> None: fh.seek(-4, SEEK_CUR) # pylint: disable=protected-access id3 = _ID3() - id3._filehandler = fh id3._parse_tags = self._parse_tags id3._load_image = self._load_image id3._parse_id3v2(fh) @@ -1611,11 +1596,12 @@ def _parse_tag(self, fh: BinaryIO) -> None: if header[:4] != b'fLaC': raise ParseError('Invalid FLAC header') # for spec, see https://xiph.org/flac/ogg_mapping.html - header_data = fh.read(4) - while len(header_data) == 4: - block_type = header_data[0] & 0x7f - is_last_block = header_data[0] & 0x80 - size = unpack('>I', b'\x00' + header_data[1:4])[0] + header_len = 4 + block_header = fh.read(header_len) + while len(block_header) == header_len: + block_type = block_header[0] & 0x7f + is_last_block = block_header[0] & 0x80 + size = unpack('>I', b'\x00' + block_header[1:])[0] # http://xiph.org/flac/format.html#metadata_block_streaminfo if block_type == self._STREAMINFO and self._parse_duration: head = fh.read(size) @@ -1649,9 +1635,9 @@ def _parse_tag(self, fh: BinaryIO) -> None: self.bitrate = self.filesize / duration * 8 / 1000 elif block_type == self._VORBIS_COMMENT and self._parse_tags: # pylint: disable=protected-access + walker = BytesIO(fh.read(size)) oggtag = _Ogg() - oggtag._filehandler = fh - oggtag._parse_vorbis_comment(fh) + oggtag._parse_vorbis_comment(walker) self._update(oggtag) elif block_type == self._PICTURE and self._load_image: fieldname, value = self._parse_image(fh) @@ -1663,7 +1649,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: fh.seek(size, SEEK_CUR) # seek over this block if is_last_block: break - header_data = fh.read(4) + block_header = fh.read(header_len) if id3 is not None: # apply ID3 tags after vorbis self._update(id3) self._tags_parsed = True @@ -1744,16 +1730,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: if (header[:16] != b'0&\xb2u\x8ef\xcf\x11\xa6\xd9\x00\xaa\x00b\xcel' or header[-1:] != b'\x02'): raise ParseError('Invalid WMA header') - while True: - object_id = fh.read(16) - object_size_data = fh.read(8) - if not object_size_data: - break - object_size = unpack(' self.filesize: break # invalid object, stop parsing. + object_id = object_header[:16] if object_id == self._ASF_CONTENT_DESC and self._parse_tags: - walker = BytesIO(fh.read(object_size - 24)) + walker = BytesIO(fh.read(object_size - header_len)) (title_length, author_length, copyright_length, description_length, rating_length) = unpack('<5H', walker.read(10)) @@ -1771,7 +1756,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self._set_field(i_field_name, value) elif object_id == self._ASF_EXT_CONTENT_DESC and self._parse_tags: # http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx#_Toc509555195 - walker = BytesIO(fh.read(object_size - 24)) + walker = BytesIO(fh.read(object_size - header_len)) descriptor_count = unpack(' None: elif value: self._set_field(field_name, value) elif object_id == self._ASF_FILE_PROP and self._parse_duration: - data = fh.read(object_size - 24) + data = fh.read(object_size - header_len) play_duration = unpack(' None: if codec_id_format_tag == 355: # lossless self.bitdepth = unpack(' None: header = fh.read(12) if header[:4] != b'FORM' or header[8:12] not in {b'AIFC', b'AIFF'}: raise ParseError('Invalid AIFF header') - chunk_header = fh.read(8) - while len(chunk_header) == 8: - sub_chunk_id = chunk_header[:4] - sub_chunk_size = unpack('>I', chunk_header[4:8])[0] + header_len = 8 + chunk_header = fh.read(header_len) + while len(chunk_header) == header_len: + subchunk_id = chunk_header[:4] + subchunk_size = unpack('>I', chunk_header[4:])[0] # IFF chunks are padded to an even number of bytes - sub_chunk_size += sub_chunk_size % 2 - if sub_chunk_id in self._AIFF_MAPPING and self._parse_tags: + subchunk_size += subchunk_size % 2 + if subchunk_id in self._AIFF_MAPPING and self._parse_tags: value = self._unpad( - fh.read(sub_chunk_size).decode('utf-8', 'replace')) - self._set_field(self._AIFF_MAPPING[sub_chunk_id], value) - elif sub_chunk_id == b'COMM' and self._parse_duration: - chunk = fh.read(sub_chunk_size) + fh.read(subchunk_size).decode('utf-8', 'replace')) + self._set_field(self._AIFF_MAPPING[subchunk_id], value) + elif subchunk_id == b'COMM' and self._parse_duration: + chunk = fh.read(subchunk_size) channels, num_frames, bitdepth = unpack('>hLh', chunk[:8]) self.channels, self.bitdepth = channels, bitdepth try: @@ -1878,15 +1866,15 @@ def _parse_tag(self, fh: BinaryIO) -> None: sr, duration, bitrate) except OverflowError: pass - elif sub_chunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: + elif subchunk_id in {b'id3 ', b'ID3 '} and self._parse_tags: # pylint: disable=protected-access id3 = _ID3() id3._filehandler = fh id3._load(tags=True, duration=False, image=self._load_image) self._update(id3) else: # some other chunk, just skip the data - fh.seek(sub_chunk_size, SEEK_CUR) - chunk_header = fh.read(8) + fh.seek(subchunk_size, SEEK_CUR) + chunk_header = fh.read(header_len) self._tags_parsed = True def _determine_duration(self, fh: BinaryIO) -> None: From e4a11dec45e42294724a1afc56dba59484652104 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 01:59:18 +0300 Subject: [PATCH 242/305] tinytag.py: remove unused import --- tinytag/tinytag.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b6dbe98..420c516 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -33,7 +33,6 @@ from io import BytesIO from os import PathLike, SEEK_CUR, SEEK_END, SEEK_SET, environ, fsdecode from struct import unpack -from sys import stderr # Lazy imports for type checking if False: # pylint: disable=using-constant-test @@ -1571,11 +1570,7 @@ class _Flac(TinyTag): """FLAC Parser""" _STREAMINFO = 0 - _PADDING = 1 - _APPLICATION = 2 - _SEEKTABLE = 3 _VORBIS_COMMENT = 4 - _CUESHEET = 5 _PICTURE = 6 def _determine_duration(self, fh: BinaryIO) -> None: @@ -1644,8 +1639,6 @@ def _parse_tag(self, fh: BinaryIO) -> None: # pylint: disable=protected-access self.images._set_field(fieldname, value) else: - if DEBUG: - print('Unknown FLAC block type', block_type) fh.seek(size, SEEK_CUR) # seek over this block if is_last_block: break From 1bd1321962ae08622cd0f3f7b742ff918f796f5b Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 05:21:55 +0300 Subject: [PATCH 243/305] OGG: avoid redundant reading of segment data (#230) --- tinytag/tinytag.py | 43 ++++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 420c516..d298aaf 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1320,7 +1320,6 @@ def __init__(self) -> None: def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: self._parse_tag(fh) # determine sample rate - fh.seek(0) # and rewind to start if self.duration is not None or not self.samplerate: return # either ogg flac or invalid file self.duration = self._max_samplenum / self.samplerate @@ -1392,6 +1391,13 @@ def _parse_tag(self, fh: BinaryIO) -> None: # other tags self._parse_vorbis_comment(walker, has_vendor=False) check_speex_second_packet = False + else: + # Optimization: If we need to determine the duration, read + # max_samplenum of remaining pages, but skip contents of + # segments. If we don't need the duration, stop here. + self._tags_parsed = True + if not self._parse_duration: + return self._tags_parsed = True def _parse_vorbis_comment(self, @@ -1438,7 +1444,7 @@ def _parse_vorbis_comment(self, def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: # for the spec, see: https://wiki.xiph.org/Ogg - previous_page = b'' # contains data from previous (continuing) pages + packet_data = bytearray() header_len = 27 page_header = fh.read(header_len) # read ogg page header while len(page_header) == header_len: @@ -1448,21 +1454,24 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: # https://xiph.org/ogg/doc/framing.html pos = unpack(' self._max_samplenum: + self._max_samplenum = pos + seg_sizes = unpack('B' * segments, fh.read(segments)) + read_size = 0 + for seg_size in seg_sizes: # read all segments + read_size += seg_size + # less than 255 bytes means end of packet + if seg_size < 255 and not self._tags_parsed: + packet_data += fh.read(read_size) + yield packet_data + packet_data.clear() + read_size = 0 + if read_size: + if self._tags_parsed: + fh.seek(read_size, SEEK_CUR) + else: # packet continues on next page + packet_data += fh.read(read_size) page_header = fh.read(header_len) From bbfd28045d0217f3a8bcac72c83605e7b3640bff Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 13:01:20 +0300 Subject: [PATCH 244/305] TinyTag: don't include images in as_dict() Avoids a nested dict, and makes the results easier to digest. If you need the images, you should use tag.images.as_dict() instead. --- tinytag/__main__.py | 1 - tinytag/tests/test_all.py | 11 +---------- tinytag/tinytag.py | 11 +++-------- 3 files changed, 4 insertions(+), 19 deletions(-) diff --git a/tinytag/__main__.py b/tinytag/__main__.py index f9285e8..35debcc 100755 --- a/tinytag/__main__.py +++ b/tinytag/__main__.py @@ -49,7 +49,6 @@ def _pop_switch(name: str) -> bool: def _print_tag(tag: TinyTag, fmt: str, header_printed: bool = False) -> bool: data = tag.as_dict() - del data['images'] if fmt == 'json': import json # pylint: disable=import-outside-toplevel print(json.dumps(data, ensure_ascii=False, indent=2)) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 98ba501..6693410 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1637,16 +1637,7 @@ def test_to_str_flat_dict() -> None: "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], 'genre': " - "['genre 1', 'genre 2'], 'url': ['https://example.com'], 'images': " - "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" - "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" - "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " - "'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " - "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" - "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" - "\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', 'description': " - "'some image ë'}]}}" + "['genre 1', 'genre 2'], 'url': ['https://example.com']}" ) assert str(tag.images.as_dict()) == ( "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d298aaf..5296672 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -158,17 +158,12 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: extension.""" return cls._get_parser_for_filename(filename) is not None - def as_dict(self) -> dict[ - str, str | int | float | list[str] | dict[str, list[Image]]]: + def as_dict(self) -> dict[str, str | int | float | list[str]]: """Return a flat dictionary representation of available metadata.""" - fields: dict[ - str, str | int | float | list[str] | dict[str, list[Image]]] = {} + fields: dict[str, str | int | float | list[str]] = {} for key, value in self.__dict__.items(): - if key.startswith('_'): - continue - if isinstance(value, Images): - fields[key] = value.as_dict() + if key.startswith('_') or key == 'images': continue if not isinstance(value, Extra): if value is None: From cee4b92d8a5b657fdace131e6a9512fabcb3da27 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 13:18:28 +0300 Subject: [PATCH 245/305] TinyTag: restore isinstance check --- tinytag/tinytag.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5296672..de01a7e 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -163,7 +163,9 @@ def as_dict(self) -> dict[str, str | int | float | list[str]]: metadata.""" fields: dict[str, str | int | float | list[str]] = {} for key, value in self.__dict__.items(): - if key.startswith('_') or key == 'images': + if key.startswith('_'): + continue + if isinstance(value, Images): continue if not isinstance(value, Extra): if value is None: From 8718ef98d035380f7e21fad1af6f8b75b96acdec Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 14:49:51 +0300 Subject: [PATCH 246/305] test_all.py: fix binary path test on Windows --- tinytag/tests/test_all.py | 299 +++++++++++++++++++------------------- 1 file changed, 148 insertions(+), 151 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 6693410..1ec47ef 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -6,11 +6,10 @@ from __future__ import annotations import os.path -import shutil -import sys from io import BytesIO from pathlib import Path +from platform import python_implementation, system from typing import Any import pytest @@ -19,8 +18,8 @@ from tinytag.tinytag import _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff -testfiles = dict([ - ('samples/vbri.mp3', { +TEST_FILES = dict([ + ('vbri.mp3', { 'extra': {}, 'channels': 2, 'samplerate': 44100, @@ -35,7 +34,7 @@ 'comment': 'Ripped by THSLIVE', 'bitrate': 125.33333333333333, }), - ('samples/cbr.mp3', { + ('cbr.mp3', { 'extra': {}, 'channels': 2, 'samplerate': 44100, @@ -50,7 +49,7 @@ 'genre': 'Dance', 'comment': 'Ripped by THSLIVE', }), - ('samples/vbr_xing_header.mp3', { + ('vbr_xing_header.mp3', { 'extra': {}, 'bitrate': 186.04383278145696, 'channels': 1, @@ -58,7 +57,7 @@ 'duration': 3.944489795918367, 'filesize': 91731, }), - ('samples/vbr_xing_header_2channel.mp3', { + ('vbr_xing_header_2channel.mp3', { 'extra': { 'encoder_settings': [ 'LAME 32bits version 3.99.5 (http://lame.sf.net)' @@ -75,7 +74,7 @@ 'title': 'Lochaber No More', 'year': '1992', }), - ('samples/id3v22-test.mp3', { + ('id3v22-test.mp3', { 'extra': { 'encoded_by': ['iTunes v4.6'], 'itunnorm': [ @@ -101,7 +100,7 @@ 'bitrate': 160.0, 'comment': 'Waterbug Records, www.anaismitchell.com', }), - ('samples/silence-44-s-v1.mp3', { + ('silence-44-s-v1.mp3', { 'extra': {}, 'channels': 2, 'samplerate': 44100, @@ -115,7 +114,7 @@ 'filesize': 15070, 'bitrate': 32.0, }), - ('samples/id3v1-latin1.mp3', { + ('id3v1-latin1.mp3', { 'extra': {}, 'genre': 'Rock', 'album': 'The Young Americans', @@ -126,7 +125,7 @@ 'year': '1993', 'comment': ' ', }), - ('samples/UTF16.mp3', { + ('UTF16.mp3', { 'extra': { 'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], 'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'], @@ -164,7 +163,7 @@ 'genre': 'Indie', 'comment': 'Track 7', }), - ('samples/utf-8-id3v2.mp3', { + ('utf-8-id3v2.mp3', { 'extra': {}, 'genre': 'Acustico', 'track_total': 21, @@ -176,15 +175,15 @@ 'disc_total': 0, 'year': '2003', }), - ('samples/empty_file.mp3', { + ('empty_file.mp3', { 'extra': {}, 'filesize': 0 }), - ('samples/incomplete.mp3', { + ('incomplete.mp3', { 'extra': {}, 'filesize': 3 }), - ('samples/silence-44khz-56k-mono-1s.mp3', { + ('silence-44khz-56k-mono-1s.mp3', { 'extra': {}, 'channels': 1, 'samplerate': 44100, @@ -192,7 +191,7 @@ 'filesize': 7280, 'bitrate': 56.0, }), - ('samples/silence-22khz-mono-1s.mp3', { + ('silence-22khz-mono-1s.mp3', { 'extra': {}, 'channels': 1, 'samplerate': 22050, @@ -200,7 +199,7 @@ 'bitrate': 32.0, 'duration': 1.0438932496075353, }), - ('samples/id3v24-long-title.mp3', { + ('id3v24-long-title.mp3', { 'extra': { 'copyright': [ '2013 Marathon Artists under exclsuive license from ' @@ -221,7 +220,7 @@ 'comment': 'Amazon.com Song ID: 240853806', 'year': '2013', }), - ('samples/utf16be.mp3', { + ('utf16be.mp3', { 'extra': {}, 'title': '52-girls', 'filesize': 2048, @@ -231,7 +230,7 @@ 'genre': 'Rock', 'year': '1981', }), - ('samples/id3v22.TCO.genre.mp3', { + ('id3v22.TCO.genre.mp3', { 'extra': { 'encoded_by': ['iTunes 11.0.4'], 'itunnorm': [ @@ -251,7 +250,7 @@ 'genre': 'Pop', 'title': 'Applause', }), - ('samples/id3_comment_utf_16_with_bom.mp3', { + ('id3_comment_utf_16_with_bom.mp3', { 'extra': { 'copyright': ['(c) 2008 nin'], 'isrc': ['USTC40852229'], @@ -271,7 +270,7 @@ 'year': '2008', 'comment': '3/4 time', }), - ('samples/id3_comment_utf_16_double_bom.mp3', { + ('id3_comment_utf_16_double_bom.mp3', { 'extra': { 'label': ['Unclear'] }, @@ -282,7 +281,7 @@ 'title': 'The Embrace (Romano Alfieri Remix)', 'year': '2012', }), - ('samples/id3_genre_id_out_of_bounds.mp3', { + ('id3_genre_id_out_of_bounds.mp3', { 'extra': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', @@ -291,7 +290,7 @@ 'title': '01 GREAT BIG WHITE WORLD', 'year': '0', }), - ('samples/image-text-encoding.mp3', { + ('image-text-encoding.mp3', { 'extra': {}, 'channels': 1, 'samplerate': 22050, @@ -300,7 +299,7 @@ 'bitrate': 32.0, 'duration': 1.0438932496075353, }), - ('samples/id3v1_does_not_overwrite_id3v2.mp3', { + ('id3v1_does_not_overwrite_id3v2.mp3', { 'extra': { 'love rating': ['L'], 'publisher': ['Century Media'], @@ -315,7 +314,7 @@ 'track': 1, 'year': '1992', }), - ('samples/non_ascii_filename_äää.mp3', { + ('non_ascii_filename_äää.mp3', { 'extra': { 'encoder_settings': ['Lavf58.20.100'] }, @@ -325,7 +324,7 @@ 'samplerate': 44100, 'bitrate': 127.6701030927835, }), - ('samples/chinese_id3.mp3', { + ('chinese_id3.mp3', { 'extra': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', @@ -339,7 +338,7 @@ 'title': '½ÇÂäÖ®¸è', 'track': 1, }), - ('samples/cut_off_titles.mp3', { + ('cut_off_titles.mp3', { 'extra': { 'encoder_settings': ['Lavf54.29.104'] }, @@ -352,7 +351,7 @@ 'samplerate': 44100, 'title': 'Tony Hawk VS Wayne Gretzky', }), - ('samples/id3_xxx_lang.mp3', { + ('id3_xxx_lang.mp3', { 'extra': { 'script': ['Latn'], 'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'], @@ -412,7 +411,7 @@ 'track_total': 12, 'year': '2004', }), - ('samples/vbr8.mp3', { + ('vbr8.mp3', { 'filesize': 9504, 'bitrate': 8.25, 'channels': 1, @@ -420,7 +419,7 @@ 'extra': {}, 'samplerate': 8000, }), - ('samples/vbr8stereo.mp3', { + ('vbr8stereo.mp3', { 'filesize': 9504, 'bitrate': 8.25, 'channels': 2, @@ -428,7 +427,7 @@ 'extra': {}, 'samplerate': 8000, }), - ('samples/vbr11.mp3', { + ('vbr11.mp3', { 'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 1, @@ -436,7 +435,7 @@ 'extra': {}, 'samplerate': 11025, }), - ('samples/vbr11stereo.mp3', { + ('vbr11stereo.mp3', { 'filesize': 9360, 'bitrate': 8.143465909090908, 'channels': 2, @@ -444,7 +443,7 @@ 'extra': {}, 'samplerate': 11025, }), - ('samples/vbr16.mp3', { + ('vbr16.mp3', { 'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 1, @@ -452,7 +451,7 @@ 'extra': {}, 'samplerate': 16000, }), - ('samples/vbr16stereo.mp3', { + ('vbr16stereo.mp3', { 'filesize': 9432, 'bitrate': 8.251968503937007, 'channels': 2, @@ -460,7 +459,7 @@ 'extra': {}, 'samplerate': 16000, }), - ('samples/vbr22.mp3', { + ('vbr22.mp3', { 'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 1, @@ -468,7 +467,7 @@ 'extra': {}, 'samplerate': 22050, }), - ('samples/vbr22stereo.mp3', { + ('vbr22stereo.mp3', { 'filesize': 9282, 'bitrate': 8.145021489971347, 'channels': 2, @@ -476,7 +475,7 @@ 'extra': {}, 'samplerate': 22050, }), - ('samples/vbr32.mp3', { + ('vbr32.mp3', { 'filesize': 37008, 'bitrate': 32.50592885375494, 'channels': 1, @@ -484,7 +483,7 @@ 'extra': {}, 'samplerate': 32000, }), - ('samples/vbr32stereo.mp3', { + ('vbr32stereo.mp3', { 'filesize': 37008, 'bitrate': 32.50592885375494, 'channels': 2, @@ -492,7 +491,7 @@ 'extra': {}, 'samplerate': 32000, }), - ('samples/vbr44.mp3', { + ('vbr44.mp3', { 'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 1, @@ -500,7 +499,7 @@ 'extra': {}, 'samplerate': 44100, }), - ('samples/vbr44stereo.mp3', { + ('vbr44stereo.mp3', { 'filesize': 36609, 'bitrate': 32.21697198275862, 'channels': 2, @@ -508,7 +507,7 @@ 'extra': {}, 'samplerate': 44100, }), - ('samples/vbr48.mp3', { + ('vbr48.mp3', { 'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 1, @@ -516,7 +515,7 @@ 'extra': {}, 'samplerate': 48000, }), - ('samples/vbr48stereo.mp3', { + ('vbr48stereo.mp3', { 'filesize': 36672, 'bitrate': 32.33862433862434, 'channels': 2, @@ -524,7 +523,7 @@ 'extra': {}, 'samplerate': 48000, }), - ('samples/id3v24_genre_null_byte.mp3', { + ('id3v24_genre_null_byte.mp3', { 'extra': {}, 'filesize': 256, 'album': '\u79d8\u5bc6', @@ -536,7 +535,7 @@ 'track': 10, 'year': '2008', }), - ('samples/vbr_xing_header_short.mp3', { + ('vbr_xing_header_short.mp3', { 'filesize': 432, 'bitrate': 24.0, 'channels': 1, @@ -544,7 +543,7 @@ 'extra': {}, 'samplerate': 8000, }), - ('samples/id3_multiple_artists.mp3', { + ('id3_multiple_artists.mp3', { 'extra': { 'artist': [ 'artist2', @@ -563,7 +562,7 @@ 'artist': 'artist1', 'genre': 'something 1', }), - ('samples/id3_frames.mp3', { + ('id3_frames.mp3', { 'filesize': 27576, 'bitrate': 50.03636363636364, 'channels': 1, @@ -571,18 +570,18 @@ 'samplerate': 16000, 'extra': {}, }), - ('samples/id3v22_with_image.mp3', { + ('id3v22_with_image.mp3', { 'extra': {}, 'filesize': 2311, 'title': 'image', }), - ('samples/utf16_no_bom.mp3', { + ('utf16_no_bom.mp3', { 'extra': {}, 'filesize': 1069, 'title': 'no bom test ë', 'artist': 'no bom test 2 ë', }), - ('samples/empty.ogg', { + ('empty.ogg', { 'extra': {}, 'duration': 3.684716553287982, 'filesize': 4328, @@ -590,7 +589,7 @@ 'samplerate': 44100, 'channels': 2, }), - ('samples/multipage-setup.ogg', { + ('multipage-setup.ogg', { 'extra': { 'transcoded': ['mp3;241'], 'replaygain_album_gain': ['-10.29 dB'], @@ -611,7 +610,7 @@ 'comment': 'SRCL-6240', 'channels': 2, }), - ('samples/test.ogg', { + ('test.ogg', { 'extra': {}, 'duration': 1.0, 'album': 'the boss', @@ -625,7 +624,7 @@ 'channels': 2, 'comment': 'hello!', }), - ('samples/corrupt_metadata.ogg', { + ('corrupt_metadata.ogg', { 'extra': {}, 'filesize': 18648, 'bitrate': 80.0, @@ -633,7 +632,7 @@ 'samplerate': 44100, 'channels': 1, }), - ('samples/composer.ogg', { + ('composer.ogg', { 'extra': {}, 'filesize': 4480, 'album': 'An Album', @@ -649,7 +648,7 @@ 'year': '2007', 'comment': 'A Comment', }), - ('samples/ogg_with_image.ogg', { + ('ogg_with_image.ogg', { 'extra': {}, 'channels': 1, 'duration': 0.1, @@ -659,7 +658,7 @@ 'artist': 'Sample Artist', 'title': 'Sample Title', }), - ('samples/test.opus', { + ('test.opus', { 'extra': { 'encoder': ['Lavc57.24.102 libopus'], 'arrange': ['\u6771\u65b9'], @@ -688,7 +687,7 @@ 'disc_total': 1, 'track_total': 13, }), - ('samples/8khz_5s.opus', { + ('8khz_5s.opus', { 'extra': { 'encoder': ['opusenc from opus-tools 0.2'] }, @@ -697,7 +696,7 @@ 'samplerate': 48000, 'duration': 5.0065, }), - ('samples/test_flac.oga', { + ('test_flac.oga', { 'extra': { 'copyright': ['test3'], 'isrc': ['test4'], @@ -717,7 +716,7 @@ 'track': 5, 'year': '2023', }), - ('samples/test.spx', { + ('test.spx', { 'extra': {}, 'filesize': 7921, 'channels': 1, @@ -728,7 +727,7 @@ 'title': 'test2', 'comment': 'Encoded with Speex 1.2.0', }), - ('samples/test.wav', { + ('test.wav', { 'extra': {}, 'channels': 2, 'duration': 1.0, @@ -737,7 +736,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/test3sMono.wav', { + ('test3sMono.wav', { 'extra': {}, 'channels': 1, 'duration': 3.0, @@ -746,7 +745,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/test-tagged.wav', { + ('test-tagged.wav', { 'extra': {}, 'channels': 2, 'duration': 1.0, @@ -762,7 +761,7 @@ 'comment': 'hello', 'year': '2014', }), - ('samples/test-riff-tags.wav', { + ('test-riff-tags.wav', { 'extra': {}, 'channels': 2, 'duration': 1.0, @@ -776,7 +775,7 @@ 'comment': 'hello', 'year': '2014', }), - ('samples/silence-22khz-mono-1s.wav', { + ('silence-22khz-mono-1s.wav', { 'extra': {}, 'channels': 1, 'duration': 0.9991836734693877, @@ -785,7 +784,7 @@ 'samplerate': 22050, 'bitdepth': 16, }), - ('samples/id3_header_with_a_zero_byte.wav', { + ('id3_header_with_a_zero_byte.wav', { 'extra': { 'title': ['Stacked'] }, @@ -800,7 +799,7 @@ 'track': 17, 'album': 'prototypes', }), - ('samples/adpcm.wav', { + ('adpcm.wav', { 'extra': {}, 'channels': 1, 'duration': 12.167256235827665, @@ -816,7 +815,7 @@ 'genre': 'test genre', 'year': '1990', }), - ('samples/riff_extra_zero.wav', { + ('riff_extra_zero.wav', { 'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, @@ -831,7 +830,7 @@ 'year': '1996', 'track': 3, }), - ('samples/riff_extra_zero_2.wav', { + ('riff_extra_zero_2.wav', { 'extra': {}, 'channels': 2, 'duration': 0.11609977324263039, @@ -845,7 +844,7 @@ 'genre': 'Pop Electronica', 'track': 7, }), - ('samples/wav_invalid_track_number.wav', { + ('wav_invalid_track_number.wav', { 'extra': {}, 'filesize': 8908, 'bitrate': 705.6, @@ -854,7 +853,7 @@ 'channels': 1, 'bitdepth': 16, }), - ('samples/gsm_6_10.wav', { + ('gsm_6_10.wav', { 'extra': {}, 'bitdepth': 1, 'bitrate': 44.1, @@ -870,7 +869,7 @@ 'comment': 'some comment here', 'genre': 'Bass', }), - ('samples/wav_with_image.wav', { + ('wav_with_image.wav', { 'extra': {}, 'channels': 1, 'duration': 2.14475, @@ -879,7 +878,7 @@ 'samplerate': 8000, 'bitdepth': 8, }), - ('samples/flac1sMono.flac', { + ('flac1sMono.flac', { 'extra': {}, 'genre': 'Avantgarde', 'album': 'alb', @@ -895,7 +894,7 @@ 'bitdepth': 16, 'comment': 'hello', }), - ('samples/flac453sStereo.flac', { + ('flac453sStereo.flac', { 'extra': {}, 'channels': 2, 'duration': 453.51473922902494, @@ -904,7 +903,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/flac1.5sStereo.flac', { + ('flac1.5sStereo.flac', { 'extra': {}, 'channels': 2, 'album': 'alb', @@ -920,7 +919,7 @@ 'bitdepth': 16, 'comment': 'hello', }), - ('samples/flac_application.flac', { + ('flac_application.flac', { 'extra': { 'replaygain_track_peak': ['0.9976'], 'musicbrainz_albumartistid': [ @@ -947,7 +946,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/no-tags.flac', { + ('no-tags.flac', { 'extra': {}, 'channels': 2, 'duration': 3.684716553287982, @@ -956,7 +955,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/variable-block.flac', { + ('variable-block.flac', { 'extra': { 'discid': ['AA0B360B'], 'japanese title': ['アップルシード オリジナル・サウンドトラック'], @@ -985,11 +984,11 @@ 'comment': 'Original Soundtrack', 'composer': 'Boom Boom Satellites (Lyrics)', }), - ('samples/106-invalid-streaminfo.flac', { + ('106-invalid-streaminfo.flac', { 'extra': {}, 'filesize': 4692 }), - ('samples/106-short-picture-block-size.flac', { + ('106-short-picture-block-size.flac', { 'extra': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, @@ -998,7 +997,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/with_padded_id3_header.flac', { + ('with_padded_id3_header.flac', { 'extra': {}, 'filesize': 16070, 'album': 'album', @@ -1014,7 +1013,7 @@ 'year': '2018', 'comment': 'comment', }), - ('samples/with_padded_id3_header2.flac', { + ('with_padded_id3_header2.flac', { 'extra': { 'mcdi': [ '2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00' @@ -1050,7 +1049,7 @@ 'year': '2018', 'comment': 'comment', }), - ('samples/flac_invalid_track_number.flac', { + ('flac_invalid_track_number.flac', { 'extra': {}, 'filesize': 235, 'bitrate': 18.8, @@ -1059,7 +1058,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/flac_with_image.flac', { + ('flac_with_image.flac', { 'extra': { 'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'], @@ -1076,7 +1075,7 @@ 'samplerate': 44100, 'bitdepth': 16, }), - ('samples/test2.wma', { + ('test2.wma', { 'extra': { '_track': ['0'], 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], @@ -1101,7 +1100,7 @@ 'composer': 'Foo Fighters', 'channels': 2, }), - ('samples/lossless.wma', { + ('lossless.wma', { 'extra': {}, 'samplerate': 44100, 'bitrate': 667.296, @@ -1110,7 +1109,7 @@ 'duration': 43.133, 'channels': 2, }), - ('samples/wma_invalid_track_number.wma', { + ('wma_invalid_track_number.wma', { 'extra': { 'encoder_settings': ['Lavf60.16.100'] }, @@ -1120,7 +1119,7 @@ 'samplerate': 44100, 'channels': 1, }), - ('samples/test.m4a', { + ('test.m4a', { 'extra': { 'itunsmpb': [ ' 00000000 00000840 000001DC 0000000000D3E9E4 00000000' @@ -1151,7 +1150,7 @@ 'artist': 'Marian', 'filesize': 61432, }), - ('samples/mpeg4_with_image.m4a', { + ('mpeg4_with_image.m4a', { 'extra': { 'publisher': ['test7'], 'bpm': ['1'], @@ -1165,7 +1164,7 @@ 'channels': 1, 'bitrate': 27.887, }), - ('samples/alac_file.m4a', { + ('alac_file.m4a', { 'extra': { 'copyright': ['© Hyperion Records Ltd, London'], 'lyrics': ['Album notes:'], @@ -1190,7 +1189,7 @@ 'bitrate': 436.743, 'bitdepth': 16, }), - ('samples/mpeg4_desc_cmt.m4a', { + ('mpeg4_desc_cmt.m4a', { 'extra': { 'description': ['test description'], 'encoded_by': ['Lavf59.27.100'] @@ -1202,7 +1201,7 @@ 'duration': 2.36, 'samplerate': 44100, }), - ('samples/mpeg4_xa9des.m4a', { + ('mpeg4_xa9des.m4a', { 'extra': { 'description': ['test description'] }, @@ -1210,7 +1209,7 @@ 'comment': 'test comment', 'duration': 727.1066666666667, }), - ('samples/test2.m4a', { + ('test2.m4a', { 'extra': { 'publisher': ['test7'], 'bpm': ['99999'], @@ -1224,7 +1223,7 @@ 'channels': 1, 'bitrate': 27.887, }), - ('samples/test-tagged.aiff', { + ('test-tagged.aiff', { 'extra': {}, 'channels': 2, 'duration': 1.0, @@ -1240,7 +1239,7 @@ 'comment': 'hello', 'year': '2014', }), - ('samples/test.aiff', { + ('test.aiff', { 'extra': { 'copyright': ['℗ 1992 Ace Records'] }, @@ -1253,7 +1252,7 @@ 'title': 'Go Out and Get Some', 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), - ('samples/pluck-pcm8.aiff', { + ('pluck-pcm8.aiff', { 'extra': {}, 'channels': 2, 'duration': 0.2999546485260771, @@ -1267,7 +1266,7 @@ 'comment': 'Audacity Pluck + Wahwah', 'year': '2013', }), - ('samples/M1F1-mulawC-AFsp.afc', { + ('M1F1-mulawC-AFsp.afc', { 'extra': { 'comment': ['user: kabal@CAPELLA', 'program: CopyAudio'] }, @@ -1279,13 +1278,13 @@ 'bitdepth': 16, 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC', }), - ('samples/invalid_sample_rate.aiff', { + ('invalid_sample_rate.aiff', { 'extra': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16, }), - ('samples/aiff_extra_tags.aiff', { + ('aiff_extra_tags.aiff', { 'extra': { 'copyright': ['test'], 'isrc': ['CC-XXX-YY-NNNNN'] @@ -1299,7 +1298,7 @@ 'title': 'song title', 'artist': 'artist 1;artist 2', }), - ('samples/aiff_with_image.aiff', { + ('aiff_with_image.aiff', { 'extra': {}, 'channels': 1, 'duration': 2.176, @@ -1311,7 +1310,7 @@ }), ]) -testfolder = os.path.join(os.path.dirname(__file__)) +SAMPLE_FOLDER = os.path.join(os.path.dirname(__file__), 'samples') def compare_tag(results: dict[str, Any], @@ -1350,10 +1349,10 @@ def error_fmt(value: str | int | float) -> str: fmt_string % fmt_values -@pytest.mark.parametrize("testfile,expected", testfiles.items()) +@pytest.mark.parametrize("testfile,expected", TEST_FILES.items()) def test_file_reading_all(testfile: str, expected: dict[str, dict[str, Any]]) -> None: - filename = os.path.join(testfolder, testfile) + filename = os.path.join(SAMPLE_FOLDER, testfile) tag = TinyTag.get(filename, tags=True, duration=True, image=True) results = { key: val for key, val in tag.__dict__.items() @@ -1364,10 +1363,10 @@ def test_file_reading_all(testfile: str, compare_tag(results, expected, filename) -@pytest.mark.parametrize("testfile,expected", testfiles.items()) +@pytest.mark.parametrize("testfile,expected", TEST_FILES.items()) def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: - filename = os.path.join(testfolder, testfile) + filename = os.path.join(SAMPLE_FOLDER, testfile) excluded_attrs = { "bitdepth", "bitrate", "channels", "duration", "samplerate" } @@ -1385,10 +1384,10 @@ def test_file_reading_tags(testfile: str, assert tag.images.any is None -@pytest.mark.parametrize("testfile,expected", testfiles.items()) +@pytest.mark.parametrize("testfile,expected", TEST_FILES.items()) def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: - filename = os.path.join(testfolder, testfile) + filename = os.path.join(SAMPLE_FOLDER, testfile) allowed_attrs = { "bitdepth", "bitrate", "channels", "duration", "filesize", "samplerate"} @@ -1407,15 +1406,15 @@ def test_file_reading_duration(testfile: str, def test_pathlib_compatibility() -> None: - testfile = next(iter(testfiles.keys())) - filename = Path(testfolder) / testfile + testfile = next(iter(TEST_FILES.keys())) + filename = Path(SAMPLE_FOLDER) / testfile TinyTag.get(filename) assert TinyTag.is_supported(filename) def test_file_obj_compatibility() -> None: - testfile = next(iter(testfiles.keys())) - filename = os.path.join(testfolder, testfile) + testfile = next(iter(TEST_FILES.keys())) + filename = os.path.join(SAMPLE_FOLDER, testfile) with open(filename, 'rb') as file_handle: tag = TinyTag.get(file_obj=file_handle) file_handle.seek(0) @@ -1423,28 +1422,26 @@ def test_file_obj_compatibility() -> None: assert tag.filesize == tag_bytesio.filesize -@pytest.mark.skipif(sys.platform == "win32", - reason='Windows does not support binary paths') +@pytest.mark.skipif( + system() == 'Windows' and python_implementation() == 'PyPy', + reason="PyPy on Windows not supported" +) def test_binary_path_compatibility() -> None: binary_file_path = os.path.join( - os.path.dirname(__file__).encode('utf-8'), b'\x01.mp3') - testfile = os.path.join( - testfolder, next(iter(testfiles.keys()))).encode('utf-8') - shutil.copy(testfile, binary_file_path) - assert os.path.exists(binary_file_path) - TinyTag.get(binary_file_path) - os.unlink(binary_file_path) - assert not os.path.exists(binary_file_path) + SAMPLE_FOLDER, 'non_ascii_filename_äää.mp3').encode('utf-8') + tag = TinyTag.get(binary_file_path) + assert tag.samplerate == 44100 + assert tag.extra['encoder_settings'] == ['Lavf58.20.100'] def test_unsupported_extension() -> None: - bogus_file = os.path.join(testfolder, 'samples/there_is_no_such_ext.bogus') + bogus_file = os.path.join(SAMPLE_FOLDER, 'there_is_no_such_ext.bogus') with pytest.raises(TinyTagException): TinyTag.get(bogus_file) def test_override_encoding() -> None: - chinese_id3 = os.path.join(testfolder, 'samples/chinese_id3.mp3') + chinese_id3 = os.path.join(SAMPLE_FOLDER, 'chinese_id3.mp3') tag = TinyTag.get(chinese_id3, encoding='gbk') assert tag.artist == '苏云' assert tag.album == '角落之歌' @@ -1474,33 +1471,33 @@ def test_unsubclassed_tinytag_parse_tag() -> None: def test_mp3_length_estimation() -> None: # pylint: disable=protected-access _ID3._MAX_ESTIMATION_SEC = 0.7 - tag = TinyTag.get(os.path.join(testfolder, 'samples/silence-44-s-v1.mp3')) + tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, 'silence-44-s-v1.mp3')) assert tag.duration is not None assert 3.5 < tag.duration < 4.0 @pytest.mark.parametrize("path,cls", [ - ('samples/silence-44-s-v1.mp3', _Flac), - ('samples/flac1.5sStereo.flac', _Ogg), - ('samples/flac1.5sStereo.flac', _Wave), - ('samples/flac1.5sStereo.flac', _Wma), - ('samples/ilbm.aiff', _Aiff), + ('silence-44-s-v1.mp3', _Flac), + ('flac1.5sStereo.flac', _Ogg), + ('flac1.5sStereo.flac', _Wave), + ('flac1.5sStereo.flac', _Wma), + ('ilbm.aiff', _Aiff), ]) def test_invalid_file(path: str, cls: type[TinyTag]) -> None: with pytest.raises(TinyTagException): - cls.get(os.path.join(testfolder, path)) + cls.get(os.path.join(SAMPLE_FOLDER, path)) @pytest.mark.parametrize('path,expected_size,desc', [ - ('samples/image-text-encoding.mp3', 5708, 'cover'), - ('samples/id3v22_with_image.mp3', 1220, 'some image ë'), - ('samples/mpeg4_with_image.m4a', 1220, None), - ('samples/flac_with_image.flac', 1220, 'some image ë'), - ('samples/wav_with_image.wav', 4627, 'some image ë'), - ('samples/aiff_with_image.aiff', 1220, 'some image ë'), + ('image-text-encoding.mp3', 5708, 'cover'), + ('id3v22_with_image.mp3', 1220, 'some image ë'), + ('mpeg4_with_image.m4a', 1220, None), + ('flac_with_image.flac', 1220, 'some image ë'), + ('wav_with_image.wav', 4627, 'some image ë'), + ('aiff_with_image.aiff', 1220, 'some image ë'), ]) def test_image_loading(path: str, expected_size: int, desc: str) -> None: - tag = TinyTag.get(os.path.join(testfolder, path), image=True) + tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, path), image=True) image = tag.images.any manual_image = None if tag.images.front_cover: @@ -1525,7 +1522,7 @@ def test_image_loading(path: str, expected_size: int, desc: str) -> None: def test_image_loading_extra() -> None: tag = TinyTag.get( - os.path.join(testfolder, 'samples/ogg_with_image.ogg'), image=True) + os.path.join(SAMPLE_FOLDER, 'ogg_with_image.ogg'), image=True) image = tag.images.extra['bright_colored_fish'][0] assert image.data is not None assert tag.images.any is not None @@ -1546,27 +1543,27 @@ def test_image_loading_extra() -> None: def test_mp3_utf_8_invalid_string() -> None: tag = TinyTag.get( - os.path.join(testfolder, 'samples/utf-8-id3v2-invalid-string.mp3')) + os.path.join(SAMPLE_FOLDER, 'utf-8-id3v2-invalid-string.mp3')) # the title used to be Gran dia, but I replaced the first byte with 0xFF, # which should be ignored here assert tag.title == '�ran día' @pytest.mark.parametrize("testfile,expected", [ - ('samples/detect_mp3_id3.x', _ID3), - ('samples/detect_mp3_fffb.x', _ID3), - ('samples/detect_ogg_flac.x', _Ogg), - ('samples/detect_ogg_opus.x', _Ogg), - ('samples/detect_ogg_vorbis.x', _Ogg), - ('samples/detect_wav.x', _Wave), - ('samples/detect_flac.x', _Flac), - ('samples/detect_wma.x', _Wma), - ('samples/detect_mp4_m4a.x', _MP4), - ('samples/detect_aiff.x', _Aiff), + ('detect_mp3_id3.x', _ID3), + ('detect_mp3_fffb.x', _ID3), + ('detect_ogg_flac.x', _Ogg), + ('detect_ogg_opus.x', _Ogg), + ('detect_ogg_vorbis.x', _Ogg), + ('detect_wav.x', _Wave), + ('detect_flac.x', _Flac), + ('detect_wma.x', _Wma), + ('detect_mp4_m4a.x', _MP4), + ('detect_aiff.x', _Aiff), ]) def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None: # pylint: disable=protected-access - filename = os.path.join(testfolder, testfile) + filename = os.path.join(SAMPLE_FOLDER, testfile) with open(filename, 'rb') as file_handle: parser = TinyTag._get_parser_class(filename, file_handle) assert parser == expected @@ -1581,7 +1578,7 @@ def test_show_hint_for_wrong_usage() -> None: def test_deprecations() -> None: - file_path = os.path.join(testfolder, 'samples/id3v24-long-title.mp3') + file_path = os.path.join(SAMPLE_FOLDER, 'id3v24-long-title.mp3') with pytest.warns(DeprecationWarning): tag = TinyTag.get(filename=file_path, image=True, ignore_errors=True) with pytest.warns(DeprecationWarning): @@ -1595,7 +1592,7 @@ def test_deprecations() -> None: def test_to_str() -> None: tag = TinyTag.get( - os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) + os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) assert str(tag).endswith( "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " "'bitdepth': 16, 'samplerate': 44100, 'artist': 'artist 1', " @@ -1632,7 +1629,7 @@ def test_to_str() -> None: def test_to_str_flat_dict() -> None: tag = TinyTag.get( - os.path.join(testfolder, 'samples/flac_with_image.flac'), image=True) + os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) assert str(tag.as_dict()).endswith( "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " From ed3ba8783da85749c71e88ea427af4ca08bf7fd1 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 15:51:51 +0300 Subject: [PATCH 247/305] Use startswith() when possible --- tinytag/tinytag.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index de01a7e..bf205c5 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -211,24 +211,24 @@ def _get_parser_for_file_handle( # https://en.wikipedia.org/wiki/List_of_file_signatures header = filehandle.read(35) filehandle.seek(0) - if header[:3] == b'ID3' or header[:2] == b'\xff\xfb': + if header.startswith(b'ID3') or header.startswith(b'\xff\xfb'): return _ID3 - if header[:4] == b'fLaC': + if header.startswith(b'fLaC'): return _Flac if ((header[4:8] == b'ftyp' and header[8:11] in {b'M4A', b'M4B', b'aax'}) or b'\xff\xf1' in header): return _MP4 - if (header[:4] == b'OggS' + if (header.startswith(b'OggS') and (header[29:33] == b'FLAC' or header[29:35] == b'vorbis' or header[28:32] == b'Opus' or header[29:34] == b'Speex')): return _Ogg - if header[:4] == b'RIFF' and header[8:12] == b'WAVE': + if header.startswith(b'RIFF') and header[8:12] == b'WAVE': return _Wave - if header[:16] == (b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00\xAA' - b'\x00\x62\xCE\x6C'): + if header.startswith(b'\x30\x26\xB2\x75\x8E\x66\xCF\x11\xA6\xD9\x00' + b'\xAA\x00\x62\xCE\x6C'): return _Wma - if header[:4] == b'FORM' and header[8:12] in {b'AIFF', b'AIFC'}: + if header.startswith(b'FORM') and header[8:12] in {b'AIFF', b'AIFC'}: return _Aiff return None @@ -1013,7 +1013,7 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: # for info on the specs, see: http://id3.org/Developer%20Information header = fh.read(10) # check if there is an ID3v2 tag at the beginning of the file - if header[:3] == b'ID3': + if header.startswith(b'ID3'): major = header[3] if DEBUG: print(f'Found id3 v2.{major}') @@ -1164,7 +1164,7 @@ def _parse_frame(self, if value.isdecimal(): genre_id = int(value) # funkier: the TCO may contain genres in parens, e.g '(13)' - elif value[:1] == '(': + elif value.startswith('('): end_pos = value.find(')') parens_text = value[1:end_pos] if end_pos > 0 and parens_text.isdecimal(): @@ -1239,12 +1239,13 @@ def _decode_string(self, value: bytes, language: bool = False) -> str: # strip optional additional null bytes value = value.lstrip(b'\x00') # read byte order mark to determine endianness - encoding = 'UTF-16be' if value[:2] == b'\xfe\xff' else 'UTF-16le' + encoding = ('UTF-16be' if value.startswith(b'\xfe\xff') + else 'UTF-16le') # strip the bom if it exists - if value[:2] in {b'\xfe\xff', b'\xff\xfe'}: + if value.startswith(b'\xfe\xff') or value.startswith(b'\xff\xfe'): value = value[2:] if len(value) % 2 == 0 else value[2:-1] # remove ADDITIONAL EXTRA BOM :facepalm: - if value[:4] == b'\x00\x00\xff\xfe': + if value.startswith(b'\x00\x00\xff\xfe'): value = value[4:] elif first_byte == b'\x02': # UTF-16 without BOM # strip optional null byte, if byte count uneven @@ -1325,17 +1326,17 @@ def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False check_speex_second_packet = False for packet in self._parse_pages(fh): - if packet[:7] == b"\x01vorbis": + if packet.startswith(b"\x01vorbis"): if self._parse_duration: self.channels, self.samplerate = unpack( " None: if (version & 0xF0) == 0: # only major version 0 supported self.channels = ch self.samplerate = 48000 # opus always uses 48khz - elif packet[:8] == b'OpusTags': + elif packet.startswith(b'OpusTags'): if self._parse_tags: # parse opus metadata: walker = BytesIO(packet) walker.seek(8) # jump over header name self._parse_vorbis_comment(walker) - elif packet[:5] == b'\x7fFLAC': + elif packet.startswith(b'\x7fFLAC'): # https://xiph.org/flac/ogg_mapping.html walker = BytesIO(packet) # jump over header name, version and number of headers @@ -1372,7 +1373,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: if block_type == _Flac._VORBIS_COMMENT: self._parse_vorbis_comment(walker) check_flac_second_packet = False - elif packet[:8] == b'Speex ': + elif packet.startswith(b'Speex '): # https://speex.org/docs/manual/speex-manual/node8.html if self._parse_duration: self.samplerate = unpack(" None: fh.seek(subchunk_size, SEEK_CUR) elif subchunk_id == b'LIST' and self._parse_tags: chunk = fh.read(subchunk_size) - if chunk[:4] == b'INFO': + if chunk.startswith(b'INFO'): walker = BytesIO(chunk) walker.seek(4) # skip header field = walker.read(4) @@ -1586,7 +1587,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _parse_tag(self, fh: BinaryIO) -> None: id3 = None header = fh.read(4) - if header[:3] == b'ID3': # parse ID3 header if it exists + if header.startswith(b'ID3'): # parse ID3 header if it exists fh.seek(-4, SEEK_CUR) # pylint: disable=protected-access id3 = _ID3() From cec6d9c289c0760a35601d1e1b03ac8e71c3b2c5 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 15:56:51 +0300 Subject: [PATCH 248/305] Minor cleanups --- .github/workflows/tests.yml | 2 +- tinytag/tinytag.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b1f6fbe..2e1581a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -58,7 +58,7 @@ jobs: python -m mypy -p tinytag - name: Unit tests - run: coverage run -m pytest + run: python -m coverage run -m pytest env: TINYTAG_DEBUG: true diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index bf205c5..6f3a487 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -938,7 +938,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: layer_id = (conf >> 1) & 0x03 channel_mode = (rest >> 6) & 0x03 # check for eleven 1s, validate bitrate and sample rate - if (not header[:2] > b'\xFF\xE0' + if (header[:2] <= b'\xFF\xE0' or (first_mpeg_id is not None and first_mpeg_id != mpeg_id) or br_id > 14 or br_id == 0 or sr_id == 3 or layer_id == 0 or mpeg_id == 1): @@ -1017,10 +1017,7 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: major = header[3] if DEBUG: print(f'Found id3 v2.{major}') - # unsync = (header[5] & 0x80) > 0 extended = (header[5] & 0x40) > 0 - # experimental = (header[5] & 0x20) > 0 - # footer = (header[5] & 0x10) > 0 size = self._unsynchsafe(unpack('4B', header[6:10])) self._bytepos_after_id3v2 = size return size, extended, major From 2e02d137f8e8202c4519140d7bef4a8d63947424 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 16:04:48 +0300 Subject: [PATCH 249/305] test_all.py: explicit string concatenation --- tinytag/tests/test_all.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 1ec47ef..11d06a5 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -139,8 +139,8 @@ 'musicbrainz album type': ['Album'], 'musicbrainz album release country': ['United States'], 'ufid': [ - 'http://musicbrainz.org\x00' - 'cf639964-eabb-4c40-9673-c2117e456ea5' + ('http://musicbrainz.org\x00' + 'cf639964-eabb-4c40-9673-c2117e456ea5') ], 'publisher': ['4AD'], 'tdat': ['1105'], @@ -381,16 +381,16 @@ 'media': ['CD'], 'tso2': ['Perfect Circle, A'], 'ufid': [ - 'http://musicbrainz.org\x00' - 'd2b8f0e6-735a-42ee-adf0-7eca4e65cd72' + ('http://musicbrainz.org\x00' + 'd2b8f0e6-735a-42ee-adf0-7eca4e65cd72') ], 'tsop': ['Perfect Circle, A'], 'original_year': ['2004'], 'tdat': ['0211'], 'ipls': [ - 'producer\x00Billy Howerdel\x00' - 'producer\x00Maynard James Keenan\x00' - 'engineer\x00Billy Howerdel\x00engineer\x00Critter' + ('producer\x00Billy Howerdel\x00' + 'producer\x00Maynard James Keenan\x00' + 'engineer\x00Billy Howerdel\x00engineer\x00Critter') ], }, 'filesize': 6943, From 72b2a0a916ea44b70f16444d443573787f37ce53 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 16:56:40 +0300 Subject: [PATCH 250/305] test_cli.py: fix wrong params unit test --- tinytag/tests/test_cli.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index cfd6cdc..ffa3b2b 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -7,7 +7,7 @@ import os import sys -from subprocess import check_output, CalledProcessError +from subprocess import check_output, CalledProcessError, STDOUT from tempfile import NamedTemporaryFile import pytest @@ -29,7 +29,8 @@ def run_cli(args: str) -> str: debug_env = str(os.environ.pop("TINYTAG_DEBUG", None)) output = check_output( - f'{sys.executable} -m tinytag ' + args, cwd=project_folder, shell=True) + f'{sys.executable} -m tinytag ' + args, cwd=project_folder, + shell=True, stderr=STDOUT) if debug_env: os.environ["TINYTAG_DEBUG"] = debug_env return output.decode('utf-8') @@ -40,8 +41,10 @@ def file_size(filename: str) -> int: def test_wrong_params() -> None: - with pytest.raises(CalledProcessError): - assert 'tinytag [options] None: From ca8c380452dc068298849a47554fa71c237eec1b Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 17:01:46 +0300 Subject: [PATCH 251/305] test_cli.py: fix wrong params test on Windows --- tinytag/tests/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index ffa3b2b..ea7b104 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -43,8 +43,8 @@ def file_size(filename: str) -> int: def test_wrong_params() -> None: with pytest.raises(CalledProcessError) as excinfo: run_cli('-lol') - assert excinfo.value.stdout == (b"-lol: [Errno 2] No such file or " - b"directory: '-lol'\n") + output = excinfo.value.stdout.strip() + assert output == b"-lol: [Errno 2] No such file or directory: '-lol'" def test_print_help() -> None: From e6ceaccd9308ff86a08b76dfbe9a3c488cf25ad0 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 20:45:36 +0300 Subject: [PATCH 252/305] MP4: add test for mvhd version 1 --- tinytag/tests/samples/mvhd_version_1.m4a | Bin 0 -> 2048 bytes tinytag/tests/test_all.py | 9 +++++++++ tinytag/tinytag.py | 6 ++---- 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 tinytag/tests/samples/mvhd_version_1.m4a diff --git a/tinytag/tests/samples/mvhd_version_1.m4a b/tinytag/tests/samples/mvhd_version_1.m4a new file mode 100644 index 0000000000000000000000000000000000000000..595680bb91ad1400e13e69fd23149de650dbb229 GIT binary patch literal 2048 zcmeHIy-EW?5Z+5-w6I8_;9qf~MIb5>V!%QPDufhbAwIz6cHhQKh4O|4gJ}*3c@y!9m#{T;iJoN{cdWZ{t(3Lp>wr$UJ1w)NHsOGXvq%&}P)Ecg?ErpF*nEvNzEA!QEB^c$M~ei3)}qft#2P zg=a?bom6!XVj-2JzzFoz;d*g=1nM4AB!_{%P(pT@q!M+y2%b#7GK(U@#C+jmDi(vB zHSpqcQYl>>ErOPI!U)>-6M6>L&k`ELoa9bCHOS0PNEJ+-+2RaYV>x-Ka4EtH0^D&Q z1=o`b)XH_s;=qg4(#3k)<)p)vpf literal 0 HcmV?d00001 diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 11d06a5..a3546e7 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1223,6 +1223,15 @@ 'channels': 1, 'bitrate': 27.887, }), + ('mvhd_version_1.m4a', { + 'extra': {}, + 'title': '64-bit test', + 'filesize': 2048, + 'samplerate': 44100, + 'duration': 123251.6585941043, + 'channels': 2, + 'bitrate': 0.0, + }), ('test-tagged.aiff', { 'extra': {}, 'channels': 2, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 6f3a487..c17a9fe 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -713,13 +713,11 @@ def _parse_audio_sample_entry_alac(cls, data: bytes) -> dict[str, int]: def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # http://stackoverflow.com/a/3639993/1191373 version = data[0] - # jump over flags + # jump over flags, create & mod times if version == 0: # uses 32 bit integers for timestamps - # jump over create & mod times time_scale, duration = unpack('>II', data[12:20]) else: # version == 1: # uses 64 bit integers for timestamps - # jump over create & mod times - time_scale, duration = unpack('>Iq', data[20:28]) + time_scale, duration = unpack('>IQ', data[20:32]) return {'duration': duration / time_scale} From b06284d3a8e4c6995ae972224974dcb40c7fa5df Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 20 Oct 2024 21:29:15 +0300 Subject: [PATCH 253/305] Update README --- README.md | 42 +++++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 6c52800..cc17618 100644 --- a/README.md +++ b/README.md @@ -36,13 +36,16 @@ python3 -m pip install tinytag * WMA * AIFF / AIFF-C * Same API for all formats + * Small, portable library + * High code coverage * Pure Python, no dependencies * Supports Python 3.7 or higher - * High test coverage - * A few hundred lines of code (just include it in your project!) -tinytag only provides the minimum needed for _reading_ meta-data. -It can determine track number, total tracks, title, artist, album, year, duration and any more. +## Usage + +tinytag only provides the minimum needed for _reading_ metadata, and presents +it in a simple format. It can determine track number, total tracks, title, +artist, album, year, duration and more. from tinytag import TinyTag tag = TinyTag.get('/some/music.mp3') @@ -51,12 +54,19 @@ It can determine track number, total tracks, title, artist, album, year, duratio Alternatively you can use tinytag directly on the command line: - $ python -m tinytag --format csv /some/music.mp3 + $ python3 -m tinytag --format csv /some/music.mp3 > {"filename": "/some/music.mp3", "filesize": 30212227, "album": "Album", "albumartist": "Artist", "artist": "Artist", "audio_offset": null, "bitrate": 256, "channels": 2, "comment": null, "composer": null, "disc": "1", "disc_total": null, "duration": 10, "genre": null, "samplerate": 44100, "title": "Title", "track": "5", "track_total": null, "year": "2012"} -Check `python -m tinytag --help` for all CLI options, for example other output formats. +Check `python3 -m tinytag --help` for all CLI options, for example other +output formats. + +Support for changing/writing metadata will not be added. Use another library +such as [Mutagen](https://mutagen.readthedocs.io/) for this. -To receive a list of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: +### Supported Files + +To receive a tuple of file extensions tinytag supports, use the +`SUPPORTED_FILE_EXTENSIONS` constant: TinyTag.SUPPORTED_FILE_EXTENSIONS @@ -64,7 +74,9 @@ Alternatively, check if a file is supported: is_supported = TinyTag.is_supported('/some/music.mp3') -List of possible attributes you can get with TinyTag: +### Common Metadata + +List of common attributes you can get with tinytag: tag.album # album as string tag.albumartist # album artist as string @@ -85,6 +97,8 @@ List of possible attributes you can get with TinyTag: tag.track_total # total number of tracks as string tag.year # year or date as string +### Additional Metadata + For non-common fields and fields specific to single file formats, use `extra`: tag.extra # a dict of additional data @@ -92,17 +106,23 @@ For non-common fields and fields specific to single file formats, use `extra`: The `extra` dict currently *may* contain the following data: `url`, `isrc`, `text`, `initial_key`, `lyrics`, `copyright` -Additionally you can also get cover images from ID3 tags: +### Images + +Additionally you can also get cover images from tags: tag = TinyTag.get('/some/music.mp3', image=True) image_data = tag.get_image() +### Encoding + To open files using a specific encoding, you can use the `encoding` parameter. -This parameter is however only used for formats where the encoding isn't explicitly -specified. +This parameter is however only used for formats where the encoding is not +explicitly specified. TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') +### File-like Objects + To use a file-like object (e.g. BytesIO) instead of a file path, pass a `file_obj` keyword argument: From d270fe00c8d9306967e237f12fef441c9f16835d Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 21 Oct 2024 01:04:47 +0300 Subject: [PATCH 254/305] CI: stop testing PyPy 3.7 There are too many issues related to test dependencies. Just rely on the regular Python 3.7 tests. --- .github/workflows/tests.yml | 15 +++------------ pyproject.toml | 2 ++ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2e1581a..4d5eef9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,16 +19,10 @@ jobs: include: - os: ubuntu-22.04 python: 3.7 - - os: ubuntu-22.04 - python: pypy-3.7 - os: macos-13 python: 3.7 - - os: macos-13 - python: pypy-3.7 - os: windows-latest python: 3.7 - - os: windows-latest - python: pypy-3.7 steps: - name: Checkout code @@ -43,7 +37,7 @@ jobs: cache: 'pip' - name: Install dependencies - run: python -m pip install build coverage flit reuse .[tests] + run: python -m pip install build flit reuse .[tests] - name: PEP 8 style checks run: python -m pycodestyle . @@ -52,10 +46,7 @@ jobs: run: python -m pylint --recursive=y . - name: Typing - if: matrix.python != 'pypy-3.7' - run: | - python -m pip install mypy - python -m mypy -p tinytag + run: python -m mypy -p tinytag - name: Unit tests run: python -m coverage run -m pytest @@ -69,7 +60,7 @@ jobs: run: python -m build --no-isolation - name: REUSE compliance - if: matrix.python != '3.7' && matrix.python != 'pypy-3.7' + if: matrix.python != '3.7' run: python -m reuse lint - name: Coveralls diff --git a/pyproject.toml b/pyproject.toml index ac90011..a5f3f8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,8 @@ Homepage = "https://github.com/tinytag/tinytag" [project.optional-dependencies] tests = [ + "coverage", + "mypy", "pycodestyle", "pylint", "pytest" From cc14dd6432c52967cd2e24934c9a4440e51739a4 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 21 Oct 2024 01:07:58 +0300 Subject: [PATCH 255/305] Simplify int/float type hints --- tinytag/tests/test_all.py | 6 +++--- tinytag/tinytag.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index a3546e7..1dc83ea 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1326,8 +1326,8 @@ def compare_tag(results: dict[str, Any], expected: dict[str, Any], file: str, prev_path: str | None = None) -> None: def compare_values(path: str, - result_val: str | int | float, - expected_val: str | int | float) -> bool: + result_val: str | float, + expected_val: str | float) -> bool: # lets not copy *all* the lyrics inside the fixture if (path == 'extra.lyrics' and isinstance(expected_val, list) @@ -1337,7 +1337,7 @@ def compare_values(path: str, return result_val == pytest.approx(expected_val) return result_val == expected_val - def error_fmt(value: str | int | float) -> str: + def error_fmt(value: str | float) -> str: return f'{repr(value)} ({type(value)})' assert isinstance(results, dict) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index c17a9fe..5b5b3d7 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -101,7 +101,7 @@ def __init__(self) -> None: self._parse_tags = True self._load_image = False self._tags_parsed = False - self.__dict__: dict[str, str | int | float | Extra | Images] + self.__dict__: dict[str, str | float | Extra | Images] def __repr__(self) -> str: return str({ @@ -158,10 +158,10 @@ def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: extension.""" return cls._get_parser_for_filename(filename) is not None - def as_dict(self) -> dict[str, str | int | float | list[str]]: + def as_dict(self) -> dict[str, str | float | list[str]]: """Return a flat dictionary representation of available metadata.""" - fields: dict[str, str | int | float | list[str]] = {} + fields: dict[str, str | float | list[str]] = {} for key, value in self.__dict__.items(): if key.startswith('_'): continue @@ -265,7 +265,7 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: self._filehandler.seek(0) self._determine_duration(self._filehandler) - def _set_field(self, fieldname: str, value: str | int | float, + def _set_field(self, fieldname: str, value: str | float, check_conflict: bool = True) -> None: if fieldname.startswith(self._EXTRA_PREFIX): fieldname = fieldname[len(self._EXTRA_PREFIX):] From ff441b9ff6c273176b9579689a776b288e86fbe3 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 25 Oct 2024 13:13:04 +0300 Subject: [PATCH 256/305] Update changelog --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index cc17618..5edde95 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str - **BREAKING:** 'extra' dict stores values in list form +- **BREAKING:** 'as_dict()' method (previously undocumented) returns tag fields in list form - **BREAKING:** TinyTagException no longer inherits LookupError - **BREAKING:** TinyTag subclasses are now private - **BREAKING:** Remove function to use custom audio file samples in tests @@ -146,7 +147,9 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - Provide access to custom metadata fields through the 'extra' dict - Provide access to all available images - Add more standard 'extra' fields +- Use Flit as Python build backend instead of Setuptools - ID3: Fix invalid sample rate/duration in some cases +- ID3: Fix reading of UTF-16 strings without BOM - FLAC: Apply ID3 tags after Vorbis - OGG/WMA: Set missing 'channels' field - WMA: Set missing 'extra.copyright' field From b4425efc7b778c827e06e5d9981ab113a4f89ede Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 28 Oct 2024 15:17:31 +0200 Subject: [PATCH 257/305] TinyTag: store filename as string --- tinytag/tinytag.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 5b5b3d7..b288861 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -74,7 +74,7 @@ class TinyTag: _file_extension_mapping: dict[tuple[str, ...], type[TinyTag]] | None = None def __init__(self) -> None: - self.filename: bytes | str | PathLike[Any] | None = None + self.filename: str | None = None self.filesize = 0 self.duration: float | None = None self.channels: int | None = None @@ -120,9 +120,12 @@ def get(cls, ignore_errors: bool | None = None) -> TinyTag: """Return a tag object for an audio file.""" should_close_file = file_obj is None - if filename and should_close_file: - # pylint: disable=consider-using-with - file_obj = open(filename, 'rb') + filename_str = None + if filename: + if should_close_file: + # pylint: disable=consider-using-with + file_obj = open(filename, 'rb') + filename_str = fsdecode(filename) if file_obj is None: raise ValueError( 'Either filename or file_obj argument is required') @@ -136,11 +139,11 @@ def get(cls, file_obj.seek(0, SEEK_END) filesize = file_obj.tell() file_obj.seek(0) - parser_class = cls._get_parser_class(filename, file_obj) + parser_class = cls._get_parser_class(filename_str, file_obj) tag = parser_class() tag._filehandler = file_obj tag._default_encoding = encoding - tag.filename = filename + tag.filename = filename_str tag.filesize = filesize if filesize > 0: try: @@ -156,7 +159,8 @@ def get(cls, def is_supported(cls, filename: bytes | str | PathLike[Any]) -> bool: """Check if a specific file is supported based on its file extension.""" - return cls._get_parser_for_filename(filename) is not None + filename_str = fsdecode(filename) + return cls._get_parser_for_filename(filename_str) is not None def as_dict(self) -> dict[str, str | float | list[str]]: """Return a flat dictionary representation of available @@ -183,9 +187,7 @@ def as_dict(self) -> dict[str, str | float | list[str]]: return fields @classmethod - def _get_parser_for_filename( - cls, filename: bytes | str | PathLike[Any] - ) -> type[TinyTag] | None: + def _get_parser_for_filename(cls, filename: str) -> type[TinyTag] | None: if cls._file_extension_mapping is None: cls._file_extension_mapping = { ('.mp1', '.mp2', '.mp3'): _ID3, @@ -197,7 +199,7 @@ def _get_parser_for_filename( '.aax', '.aaxc'): _MP4, ('.aiff', '.aifc', '.aif', '.afc'): _Aiff, } - filename = fsdecode(filename).lower() + filename = filename.lower() for ext, tagclass in cls._file_extension_mapping.items(): if filename.endswith(ext): return tagclass @@ -235,7 +237,7 @@ def _get_parser_for_file_handle( @classmethod def _get_parser_class( cls, - filename: bytes | str | PathLike[Any] | None = None, + filename: str | None = None, filehandle: BinaryIO | None = None ) -> type[TinyTag]: if cls != TinyTag: From 3d4420bb1caa14d5b160a4f38609d15538af4f0c Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 28 Oct 2024 16:19:38 +0200 Subject: [PATCH 258/305] Remove standard 'original_date/year' extra fields There's some ambiguity around the time-based fields that would be better to research and address in a future update. --- tinytag/tests/test_all.py | 3 ++- tinytag/tinytag.py | 9 --------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 1dc83ea..4706c7f 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -385,7 +385,8 @@ 'd2b8f0e6-735a-42ee-adf0-7eca4e65cd72') ], 'tsop': ['Perfect Circle, A'], - 'original_year': ['2004'], + 'tory': ['2004'], + 'originalyear': ['2004'], 'tdat': ['0211'], 'ipls': [ ('producer\x00Billy Howerdel\x00' diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index b288861..27e79d0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -470,8 +470,6 @@ class _MP4(TinyTag): 'lyricist': 'extra.lyricist', 'media': 'extra.media', 'website': 'extra.url', - 'originaldate': 'extra.original_date', - 'originalyear': 'extra.original_year', 'license': 'extra.license', 'barcode': 'extra.barcode', 'catalognumber': 'extra.catalog_number', @@ -753,15 +751,12 @@ class _ID3(TinyTag): 'TENC': 'extra.encoded_by', 'TEN': 'extra.encoded_by', 'TSSE': 'extra.encoder_settings', 'TSS': 'extra.encoder_settings', 'TMED': 'extra.media', 'TMT': 'extra.media', - 'TDOR': 'extra.original_date', - 'TORY': 'extra.original_year', 'TOR': 'extra.original_year', 'WCOP': 'extra.license', } _ID3_MAPPING_CUSTOM = { 'artists': 'artist', 'director': 'extra.director', 'license': 'extra.license', - 'originalyear': 'extra.original_year', 'barcode': 'extra.barcode', 'catalognumber': 'extra.catalog_number', } @@ -1301,8 +1296,6 @@ class _Ogg(TinyTag): 'encodedby': 'extra.encoded_by', 'encodersettings': 'extra.encoder_settings', 'media': 'extra.media', - 'originaldate': 'extra.original_date', - 'originalyear': 'extra.original_year', 'license': 'extra.license', 'barcode': 'extra.barcode', 'catalognumber': 'extra.catalog_number', @@ -1695,8 +1688,6 @@ class _Wma(TinyTag): 'WM/EncodedBy': 'extra.encoded_by', 'WM/EncodingSettings': 'extra.encoder_settings', 'WM/Media': 'extra.media', - 'WM/OriginalReleaseTime': 'extra.original_date', - 'WM/OriginalReleaseYear': 'extra.original_year', 'WM/Barcode': 'extra.barcode', 'WM/CatalogNo': 'extra.catalog_number', } From 6515b53d2f54fd804fbab619f7f455f37d407adc Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 29 Oct 2024 18:27:49 +0200 Subject: [PATCH 259/305] Move 'other' image type to extra dict Avoid possible confusion between the 'other' image and 'extra' dict. --- tinytag/tests/test_all.py | 8 ++++---- tinytag/tinytag.py | 11 +++++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 4706c7f..0c89708 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1512,8 +1512,8 @@ def test_image_loading(path: str, expected_size: int, desc: str) -> None: manual_image = None if tag.images.front_cover: manual_image = tag.images.front_cover[0] - elif tag.images.other: - manual_image = tag.images.other[0] + else: + manual_image = tag.images.extra["other"][0] assert image is not None assert manual_image is not None assert image.name in {'front_cover', 'other'} @@ -1616,7 +1616,7 @@ def test_to_str() -> None: "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': " "'image/jpeg', 'description': 'some image ë'}], 'back_cover': [], " - "'leaflet': [], 'media': [], 'other': [], 'extra': " + "'leaflet': [], 'media': [], 'extra': " "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" @@ -1628,7 +1628,7 @@ def test_to_str() -> None: "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " - "'back_cover': [], 'leaflet': [], 'media': [], 'other': [], 'extra': " + "'back_cover': [], 'leaflet': [], 'media': [], 'extra': " "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 27e79d0..ffb320d 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -61,7 +61,7 @@ class UnsupportedFormatError(TinyTagException): class TinyTag: - """A class containing audio file metadata.""" + """A class containing audio file properties and metadata.""" SUPPORTED_FILE_EXTENSIONS = ( '.mp1', '.mp2', '.mp3', @@ -81,6 +81,7 @@ def __init__(self) -> None: self.bitrate: float | None = None self.bitdepth: int | None = None self.samplerate: int | None = None + self.artist: str | None = None self.albumartist: str | None = None self.composer: str | None = None @@ -93,8 +94,10 @@ def __init__(self) -> None: self.genre: str | None = None self.year: str | None = None self.comment: str | None = None + self.extra = Extra() self.images = Images() + self._filehandler: BinaryIO | None = None self._default_encoding: str | None = None # override for some formats self._parse_duration = True @@ -349,7 +352,7 @@ def audio_offset(self) -> None: class Extra(_Extra): - """A dictionary containing additional fields of an audio file.""" + """A dictionary containing additional metadata fields of an audio file.""" class Images: @@ -361,7 +364,7 @@ def __init__(self) -> None: self.back_cover: list[Image] = [] self.leaflet: list[Image] = [] self.media: list[Image] = [] - self.other: list[Image] = [] + self.extra = ImagesExtra() self.__dict__: dict[str, list[Image] | ImagesExtra] @@ -820,7 +823,7 @@ class _ID3(TinyTag): 'png': 'image/png', } _IMAGE_TYPES = ( - 'other', + 'extra.other', 'extra.icon', 'extra.other_icon', 'front_cover', From 28175c4f8204ff226d670076b18d29d9fd795352 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 29 Oct 2024 20:23:00 +0200 Subject: [PATCH 260/305] Images: don't use lists for common images (#231) Matches the behavior of common metadata fields. Changing the behavior would be unexpected and needlessly complicated, as these attributes are supposed to be "quick-access" for beginners. --- tinytag/tests/test_all.py | 18 ++++++++---------- tinytag/tinytag.py | 35 ++++++++++++++++------------------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0c89708..34c6040 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1509,10 +1509,8 @@ def test_invalid_file(path: str, cls: type[TinyTag]) -> None: def test_image_loading(path: str, expected_size: int, desc: str) -> None: tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, path), image=True) image = tag.images.any - manual_image = None - if tag.images.front_cover: - manual_image = tag.images.front_cover[0] - else: + manual_image = tag.images.front_cover + if manual_image is None: manual_image = tag.images.extra["other"][0] assert image is not None assert manual_image is not None @@ -1612,11 +1610,11 @@ def test_to_str() -> None: "'comment': None, 'extra': {'artist': ['artist 2', 'artist 3'], " "'album': ['album 2'], 'genre': ['genre 2'], " "'url': ['https://example.com']}, 'images': {'front_cover': " - "[{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" + "{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': " - "'image/jpeg', 'description': 'some image ë'}], 'back_cover': [], " - "'leaflet': [], 'media': [], 'extra': " + "'image/jpeg', 'description': 'some image ë'}, 'back_cover': None, " + "'media': None, 'extra': " "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" @@ -1624,11 +1622,11 @@ def test_to_str() -> None: "'some image ë'}]}}}" ) assert str(tag.images) == ( - "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" + "{'front_cover': {'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " - "'back_cover': [], 'leaflet': [], 'media': [], 'extra': " + "'mime_type': 'image/jpeg', 'description': 'some image ë'}, " + "'back_cover': None, 'media': None, 'extra': " "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ffb320d..d72548c 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -360,13 +360,12 @@ class Images: _EXTRA_PREFIX = 'extra.' def __init__(self) -> None: - self.front_cover: list[Image] = [] - self.back_cover: list[Image] = [] - self.leaflet: list[Image] = [] - self.media: list[Image] = [] + self.front_cover: Image | None = None + self.back_cover: Image | None = None + self.media: Image | None = None self.extra = ImagesExtra() - self.__dict__: dict[str, list[Image] | ImagesExtra] + self.__dict__: dict[str, Image | ImagesExtra] def __repr__(self) -> str: return str({ @@ -385,8 +384,8 @@ def any(self) -> Image | None: for image in extra_images: return image continue - for image in value: - return image + if value is not None: + return value return None def as_dict(self) -> dict[str, list[Image]]: @@ -394,8 +393,8 @@ def as_dict(self) -> dict[str, list[Image]]: images: dict[str, list[Image]] = {} for key, value in self.__dict__.items(): if not isinstance(value, ImagesExtra): - if value: - images[key] = value + if value is not None: + images[key] = [value] continue for extra_key, extra_values in value.items(): extra_images = images.get(extra_key) @@ -405,7 +404,8 @@ def as_dict(self) -> dict[str, list[Image]]: return images def _set_field(self, fieldname: str, value: Image) -> None: - if fieldname.startswith(self._EXTRA_PREFIX): + old_value = self.__dict__.get(fieldname) + if fieldname.startswith(self._EXTRA_PREFIX) or old_value is not None: fieldname = fieldname[len(self._EXTRA_PREFIX):] extra_values = self.extra.get(fieldname, []) extra_values.append(value) @@ -413,12 +413,9 @@ def _set_field(self, fieldname: str, value: Image) -> None: print(f'Setting extra image field "{fieldname}"') self.extra[fieldname] = extra_values return - values = self.__dict__.get(fieldname, []) - if isinstance(values, list): - values.append(value) - if DEBUG: - print(f'Setting image field "{fieldname}"') - self.__dict__[fieldname] = values + if DEBUG: + print(f'Setting image field "{fieldname}"') + self.__dict__[fieldname] = value def _update(self, other: Images) -> None: for key, value in other.__dict__.items(): @@ -428,8 +425,8 @@ def _update(self, other: Images) -> None: self._set_field( self._EXTRA_PREFIX + extra_key, image_extra) continue - for image in value: - self._set_field(key, image) + if value is not None: + self._set_field(key, value) class ImagesExtra(_ImagesExtra): @@ -828,7 +825,7 @@ class _ID3(TinyTag): 'extra.other_icon', 'front_cover', 'back_cover', - 'leaflet', + 'extra.leaflet', 'media', 'extra.lead_artist', 'extra.artist', From 6f614fe8a1ce5803ce69345e02049e1996940f22 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 29 Oct 2024 20:59:46 +0200 Subject: [PATCH 261/305] Clearly mark deprecations in changelog --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5edde95..41cf3f2 100644 --- a/README.md +++ b/README.md @@ -140,10 +140,10 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a - **BREAKING:** TinyTag subclasses are now private - **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 +- **DEPRECATION:** Mark 'ignore_errors' parameter for TinyTag.get() as obsolete +- **DEPRECATION:** Mark 'audio_offset' attribute as obsolete +- **DEPRECATION:** Deprecate 'get_image()' method in favor of 'images.any' property - Add type hints to codebase -- Mark 'ignore_errors' parameter for TinyTag.get() as obsolete -- Mark 'audio_offset' attribute as obsolete -- Deprecate 'get_image()' method in favor of 'images.any' property - Provide access to custom metadata fields through the 'extra' dict - Provide access to all available images - Add more standard 'extra' fields From 597ae428b822af0c84447a6d8fd131d855eaa14b Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 31 Oct 2024 03:12:59 +0200 Subject: [PATCH 262/305] Images: video -> screen_capture --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d72548c..4b888aa 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -836,7 +836,7 @@ class _ID3(TinyTag): 'extra.recording_location', 'extra.during_recording', 'extra.during_performance', - 'extra.video', + 'extra.screen_capture', 'extra.bright_colored_fish', 'extra.illustration', 'extra.band_logo', From 0227cc7b48f1af414f8fdf26572c9973000f636a Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 31 Oct 2024 03:37:29 +0200 Subject: [PATCH 263/305] Add missing ID3v1 genre --- tinytag/tinytag.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4b888aa..075ae93 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -784,7 +784,6 @@ class _ID3(TinyTag): 'Native American', 'Cabaret', 'New Wave', 'Psychadelic', 'Rave', 'Showtunes', 'Trailer', 'Lo-Fi', 'Tribal', 'Acid Punk', 'Acid Jazz', 'Polka', 'Retro', 'Musical', 'Rock & Roll', 'Hard Rock', - # Wimamp Extended Genres 'Folk', 'Folk-Rock', 'National Folk', 'Swing', 'Fast Fusion', 'Bebob', 'Latin', 'Revival', 'Celtic', 'Bluegrass', 'Avantgarde', 'Gothic Rock', @@ -795,12 +794,10 @@ class _ID3(TinyTag): 'Tango', 'Samba', 'Folklore', 'Ballad', 'Power Ballad', 'Rhythmic Soul', 'Freestyle', 'Duet', 'Punk Rock', 'Drum Solo', 'A capella', 'Euro-House', 'Dance Hall', 'Goa', 'Drum & Bass', - - # according to https://de.wikipedia.org/wiki/Liste_der_ID3v1-Genres: 'Club-House', 'Hardcore Techno', 'Terror', 'Indie', 'BritPop', - '', # don't use ethnic slur ("Negerpunk", WTF!) - 'Polsk Punk', 'Beat', 'Christian Gangsta Rap', 'Heavy Metal', - 'Black Metal', 'Contemporary Christian', 'Christian Rock', + 'Afro-Punk', 'Polsk Punk', 'Beat', 'Christian Gangsta Rap', + 'Heavy Metal', 'Black Metal', 'Contemporary Christian', + 'Christian Rock', # WinAmp 1.91 'Merengue', 'Salsa', 'Thrash Metal', 'Anime', 'Jpop', 'Synthpop', # WinAmp 5.6 From 50229474525f8b8757e822cf7990041bf36f93f4 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 1 Nov 2024 19:08:09 +0200 Subject: [PATCH 264/305] Remove custom __repr__ implementations from objects The output is confusing, since it looks identical to a dictionary, even though the objects are not dictionaries. Users can use vars() on the objects instead to inspect member variables. --- tinytag/tests/test_all.py | 29 +++++++++++------------------ tinytag/tinytag.py | 12 ------------ 2 files changed, 11 insertions(+), 30 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 34c6040..abecafe 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1598,30 +1598,22 @@ def test_deprecations() -> None: assert tag.get_image() == tag.images.any.data -def test_to_str() -> None: +def test_str_vars() -> None: tag = TinyTag.get( os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) - assert str(tag).endswith( - "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " + assert ( + "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " + "'channels': 1, 'bitrate': 225.92, " "'bitdepth': 16, 'samplerate': 44100, 'artist': 'artist 1', " "'albumartist': None, 'composer': None, 'album': 'album 1', " "'disc': None, 'disc_total': None, 'title': None, 'track': None, " "'track_total': None, 'genre': 'genre 1', 'year': None, " "'comment': None, 'extra': {'artist': ['artist 2', 'artist 3'], " "'album': ['album 2'], 'genre': ['genre 2'], " - "'url': ['https://example.com']}, 'images': {'front_cover': " - "{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF" - "\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_" - "PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', 'mime_type': " - "'image/jpeg', 'description': 'some image ë'}, 'back_cover': None, " - "'media': None, 'extra': " - "{'bright_colored_fish': [{'name': 'bright_colored_fish', 'data': " - "b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H" - "\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00" - "\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', 'description': " - "'some image ë'}]}}}" - ) - assert str(tag.images) == ( + "'url': ['https://example.com']}, 'images': None: ) -def test_to_str_flat_dict() -> None: +def test_str_flat_dict() -> None: tag = TinyTag.get( os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) assert str(tag.as_dict()).endswith( - "'filesize': 2824, 'duration': 0.1, 'channels': 1, 'bitrate': 225.92, " + "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " + "'channels': 1, 'bitrate': 225.92, " "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], 'genre': " "['genre 1', 'genre 2'], 'url': ['https://example.com']}" diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 075ae93..ed6e9ee 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -106,12 +106,6 @@ def __init__(self) -> None: self._tags_parsed = False self.__dict__: dict[str, str | float | Extra | Images] - def __repr__(self) -> str: - return str({ - key: value for key, value in self.__dict__.items() - if not key.startswith('_') - }) - @classmethod def get(cls, filename: bytes | str | PathLike[Any] | None = None, @@ -367,12 +361,6 @@ def __init__(self) -> None: self.extra = ImagesExtra() self.__dict__: dict[str, Image | ImagesExtra] - def __repr__(self) -> str: - return str({ - key: value for key, value in self.__dict__.items() - if not key.startswith('_') - }) - @property def any(self) -> Image | None: """Return a cover image. From daf1b23ad58f1497c4accc564196eda96a60938e Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Nov 2024 01:25:55 +0200 Subject: [PATCH 265/305] Mark 'extra' dict as deprecated in favor of 'other' dict (#232) Changing 'extra' dict value types in tinytag 2.0.0 would break every existing program that expects values of a certain type. In order to make migration smoother: - Add an 'extra' property that returns the equivalent of the old 'extra' dict, and deprecate it - Provide a replacement 'other' dict containing values in list form --- README.md | 10 +- tinytag/__init__.py | 4 +- tinytag/tests/test_all.py | 260 ++++++++++++++------------- tinytag/tests/test_cli.py | 2 +- tinytag/tinytag.py | 369 ++++++++++++++++++++------------------ 5 files changed, 331 insertions(+), 314 deletions(-) diff --git a/README.md b/README.md index 41cf3f2..f282a65 100644 --- a/README.md +++ b/README.md @@ -134,25 +134,25 @@ To use a file-like object (e.g. BytesIO) instead of a file path, pass a ### 2.0.0 (Unreleased) - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str -- **BREAKING:** 'extra' dict stores values in list form -- **BREAKING:** 'as_dict()' method (previously undocumented) returns tag fields in list form +- **BREAKING:** 'as_dict()' method (previously undocumented) returns tag field values in list form - **BREAKING:** TinyTagException no longer inherits LookupError - **BREAKING:** TinyTag subclasses are now private - **BREAKING:** Remove function to use custom audio file samples in tests - **BREAKING:** Remove support for Python 2 - **DEPRECATION:** Mark 'ignore_errors' parameter for TinyTag.get() as obsolete - **DEPRECATION:** Mark 'audio_offset' attribute as obsolete +- **DEPRECATION:** Deprecate 'extra' dict in favor of 'other' dict with values in list form - **DEPRECATION:** Deprecate 'get_image()' method in favor of 'images.any' property - Add type hints to codebase -- Provide access to custom metadata fields through the 'extra' dict +- Provide access to custom metadata fields through the 'other' dict - Provide access to all available images -- Add more standard 'extra' fields +- Add more standard 'other' fields - Use Flit as Python build backend instead of Setuptools - ID3: Fix invalid sample rate/duration in some cases - ID3: Fix reading of UTF-16 strings without BOM - FLAC: Apply ID3 tags after Vorbis - OGG/WMA: Set missing 'channels' field -- WMA: Set missing 'extra.copyright' field +- WMA: Set missing 'other.copyright' field - WMA: Raise exception if file is invalid - Various optimizations diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 5994e41..ab10bac 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -4,10 +4,10 @@ """Audio file metadata reader""" from .tinytag import ( - TinyTag, Extra, Image, Images, ImagesExtra, + TinyTag, Image, Images, OtherFields, OtherImages, TinyTagException, ParseError, UnsupportedFormatError ) __all__ = ( - "TinyTag", "Extra", "Image", "Images", "ImagesExtra", + "TinyTag", "Image", "Images", "OtherFields", "OtherImages", "TinyTagException", "ParseError", "UnsupportedFormatError" ) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index abecafe..8bca070 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -20,7 +20,7 @@ TEST_FILES = dict([ ('vbri.mp3', { - 'extra': {}, + 'other': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, @@ -35,7 +35,7 @@ 'bitrate': 125.33333333333333, }), ('cbr.mp3', { - 'extra': {}, + 'other': {}, 'channels': 2, 'samplerate': 44100, 'duration': 0.48866995073891617, @@ -50,7 +50,7 @@ 'comment': 'Ripped by THSLIVE', }), ('vbr_xing_header.mp3', { - 'extra': {}, + 'other': {}, 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, @@ -58,7 +58,7 @@ 'filesize': 91731, }), ('vbr_xing_header_2channel.mp3', { - 'extra': { + 'other': { 'encoder_settings': [ 'LAME 32bits version 3.99.5 (http://lame.sf.net)' ], @@ -75,7 +75,7 @@ 'year': '1992', }), ('id3v22-test.mp3', { - 'extra': { + 'other': { 'encoded_by': ['iTunes v4.6'], 'itunnorm': [ ' 0000044E 00000061 00009B67 000044C3 00022478 00022182' @@ -101,7 +101,7 @@ 'comment': 'Waterbug Records, www.anaismitchell.com', }), ('silence-44-s-v1.mp3', { - 'extra': {}, + 'other': {}, 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', @@ -115,7 +115,7 @@ 'bitrate': 32.0, }), ('id3v1-latin1.mp3', { - 'extra': {}, + 'other': {}, 'genre': 'Rock', 'album': 'The Young Americans', 'title': 'Play Dead', @@ -126,7 +126,7 @@ 'comment': ' ', }), ('UTF16.mp3', { - 'extra': { + 'other': { 'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], 'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'], 'musicbrainz album artist id': [ @@ -164,7 +164,7 @@ 'comment': 'Track 7', }), ('utf-8-id3v2.mp3', { - 'extra': {}, + 'other': {}, 'genre': 'Acustico', 'track_total': 21, 'track': 1, @@ -176,15 +176,15 @@ 'year': '2003', }), ('empty_file.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 0 }), ('incomplete.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 3 }), ('silence-44khz-56k-mono-1s.mp3', { - 'extra': {}, + 'other': {}, 'channels': 1, 'samplerate': 44100, 'duration': 1.0265261269342902, @@ -192,7 +192,7 @@ 'bitrate': 56.0, }), ('silence-22khz-mono-1s.mp3', { - 'extra': {}, + 'other': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 4284, @@ -200,7 +200,7 @@ 'duration': 1.0438932496075353, }), ('id3v24-long-title.mp3', { - 'extra': { + 'other': { 'copyright': [ '2013 Marathon Artists under exclsuive license from ' 'Courtney Barnett' @@ -221,7 +221,7 @@ 'year': '2013', }), ('utf16be.mp3', { - 'extra': {}, + 'other': {}, 'title': '52-girls', 'filesize': 2048, 'track': 6, @@ -231,7 +231,7 @@ 'year': '1981', }), ('id3v22.TCO.genre.mp3', { - 'extra': { + 'other': { 'encoded_by': ['iTunes 11.0.4'], 'itunnorm': [ ' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A' @@ -251,7 +251,7 @@ 'title': 'Applause', }), ('id3_comment_utf_16_with_bom.mp3', { - 'extra': { + 'other': { 'copyright': ['(c) 2008 nin'], 'isrc': ['USTC40852229'], 'bpm': ['60'], @@ -271,7 +271,7 @@ 'comment': '3/4 time', }), ('id3_comment_utf_16_double_bom.mp3', { - 'extra': { + 'other': { 'label': ['Unclear'] }, 'filesize': 512, @@ -282,7 +282,7 @@ 'year': '2012', }), ('id3_genre_id_out_of_bounds.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', @@ -291,7 +291,7 @@ 'year': '0', }), ('image-text-encoding.mp3', { - 'extra': {}, + 'other': {}, 'channels': 1, 'samplerate': 22050, 'filesize': 11104, @@ -300,7 +300,7 @@ 'duration': 1.0438932496075353, }), ('id3v1_does_not_overwrite_id3v2.mp3', { - 'extra': { + 'other': { 'love rating': ['L'], 'publisher': ['Century Media'], 'popm': ['MusicBee\x00Ä'] @@ -315,7 +315,7 @@ 'year': '1992', }), ('non_ascii_filename_äää.mp3', { - 'extra': { + 'other': { 'encoder_settings': ['Lavf58.20.100'] }, 'filesize': 80919, @@ -325,7 +325,7 @@ 'bitrate': 127.6701030927835, }), ('chinese_id3.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', @@ -339,7 +339,7 @@ 'track': 1, }), ('cut_off_titles.mp3', { - 'extra': { + 'other': { 'encoder_settings': ['Lavf54.29.104'] }, 'filesize': 1000, @@ -352,7 +352,7 @@ 'title': 'Tony Hawk VS Wayne Gretzky', }), ('id3_xxx_lang.mp3', { - 'extra': { + 'other': { 'script': ['Latn'], 'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'], 'musicbrainz album type': ['album'], @@ -417,7 +417,7 @@ 'bitrate': 8.25, 'channels': 1, 'duration': 9.216, - 'extra': {}, + 'other': {}, 'samplerate': 8000, }), ('vbr8stereo.mp3', { @@ -425,7 +425,7 @@ 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, - 'extra': {}, + 'other': {}, 'samplerate': 8000, }), ('vbr11.mp3', { @@ -433,7 +433,7 @@ 'bitrate': 8.143465909090908, 'channels': 1, 'duration': 9.195102040816327, - 'extra': {}, + 'other': {}, 'samplerate': 11025, }), ('vbr11stereo.mp3', { @@ -441,7 +441,7 @@ 'bitrate': 8.143465909090908, 'channels': 2, 'duration': 9.195102040816327, - 'extra': {}, + 'other': {}, 'samplerate': 11025, }), ('vbr16.mp3', { @@ -449,7 +449,7 @@ 'bitrate': 8.251968503937007, 'channels': 1, 'duration': 9.144, - 'extra': {}, + 'other': {}, 'samplerate': 16000, }), ('vbr16stereo.mp3', { @@ -457,7 +457,7 @@ 'bitrate': 8.251968503937007, 'channels': 2, 'duration': 9.144, - 'extra': {}, + 'other': {}, 'samplerate': 16000, }), ('vbr22.mp3', { @@ -465,7 +465,7 @@ 'bitrate': 8.145021489971347, 'channels': 1, 'duration': 9.11673469387755, - 'extra': {}, + 'other': {}, 'samplerate': 22050, }), ('vbr22stereo.mp3', { @@ -473,7 +473,7 @@ 'bitrate': 8.145021489971347, 'channels': 2, 'duration': 9.11673469387755, - 'extra': {}, + 'other': {}, 'samplerate': 22050, }), ('vbr32.mp3', { @@ -481,7 +481,7 @@ 'bitrate': 32.50592885375494, 'channels': 1, 'duration': 9.108, - 'extra': {}, + 'other': {}, 'samplerate': 32000, }), ('vbr32stereo.mp3', { @@ -489,7 +489,7 @@ 'bitrate': 32.50592885375494, 'channels': 2, 'duration': 9.108, - 'extra': {}, + 'other': {}, 'samplerate': 32000, }), ('vbr44.mp3', { @@ -497,7 +497,7 @@ 'bitrate': 32.21697198275862, 'channels': 1, 'duration': 9.09061224489796, - 'extra': {}, + 'other': {}, 'samplerate': 44100, }), ('vbr44stereo.mp3', { @@ -505,7 +505,7 @@ 'bitrate': 32.21697198275862, 'channels': 2, 'duration': 9.09061224489796, - 'extra': {}, + 'other': {}, 'samplerate': 44100, }), ('vbr48.mp3', { @@ -513,7 +513,7 @@ 'bitrate': 32.33862433862434, 'channels': 1, 'duration': 9.072, - 'extra': {}, + 'other': {}, 'samplerate': 48000, }), ('vbr48stereo.mp3', { @@ -521,11 +521,11 @@ 'bitrate': 32.33862433862434, 'channels': 2, 'duration': 9.072, - 'extra': {}, + 'other': {}, 'samplerate': 48000, }), ('id3v24_genre_null_byte.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 256, 'album': '\u79d8\u5bc6', 'albumartist': 'aiko', @@ -541,11 +541,11 @@ 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, - 'extra': {}, + 'other': {}, 'samplerate': 8000, }), ('id3_multiple_artists.mp3', { - 'extra': { + 'other': { 'artist': [ 'artist2', 'artist3', @@ -569,21 +569,21 @@ 'channels': 1, 'duration': 3.96, 'samplerate': 16000, - 'extra': {}, + 'other': {}, }), ('id3v22_with_image.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 2311, 'title': 'image', }), ('utf16_no_bom.mp3', { - 'extra': {}, + 'other': {}, 'filesize': 1069, 'title': 'no bom test ë', 'artist': 'no bom test 2 ë', }), ('empty.ogg', { - 'extra': {}, + 'other': {}, 'duration': 3.684716553287982, 'filesize': 4328, 'bitrate': 112.0, @@ -591,7 +591,7 @@ 'channels': 2, }), ('multipage-setup.ogg', { - 'extra': { + 'other': { 'transcoded': ['mp3;241'], 'replaygain_album_gain': ['-10.29 dB'], 'replaygain_album_peak': ['1.50579047'], @@ -612,7 +612,7 @@ 'channels': 2, }), ('test.ogg', { - 'extra': {}, + 'other': {}, 'duration': 1.0, 'album': 'the boss', 'year': '2006', @@ -626,7 +626,7 @@ 'comment': 'hello!', }), ('corrupt_metadata.ogg', { - 'extra': {}, + 'other': {}, 'filesize': 18648, 'bitrate': 80.0, 'duration': 2.132358276643991, @@ -634,7 +634,7 @@ 'channels': 1, }), ('composer.ogg', { - 'extra': {}, + 'other': {}, 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', @@ -650,7 +650,7 @@ 'comment': 'A Comment', }), ('ogg_with_image.ogg', { - 'extra': {}, + 'other': {}, 'channels': 1, 'duration': 0.1, 'filesize': 5759, @@ -660,7 +660,7 @@ 'title': 'Sample Title', }), ('test.opus', { - 'extra': { + 'other': { 'encoder': ['Lavc57.24.102 libopus'], 'arrange': ['\u6771\u65b9'], 'catalogid': ['ARCD0024'], @@ -689,7 +689,7 @@ 'track_total': 13, }), ('8khz_5s.opus', { - 'extra': { + 'other': { 'encoder': ['opusenc from opus-tools 0.2'] }, 'filesize': 7251, @@ -698,7 +698,7 @@ 'duration': 5.0065, }), ('test_flac.oga', { - 'extra': { + 'other': { 'copyright': ['test3'], 'isrc': ['test4'], 'lyrics': ['test7'] @@ -718,7 +718,7 @@ 'year': '2023', }), ('test.spx', { - 'extra': {}, + 'other': {}, 'filesize': 7921, 'channels': 1, 'samplerate': 16000, @@ -729,7 +729,7 @@ 'comment': 'Encoded with Speex 1.2.0', }), ('test.wav', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176444, @@ -738,7 +738,7 @@ 'bitdepth': 16, }), ('test3sMono.wav', { - 'extra': {}, + 'other': {}, 'channels': 1, 'duration': 3.0, 'filesize': 264644, @@ -747,7 +747,7 @@ 'bitdepth': 16, }), ('test-tagged.wav', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176688, @@ -763,7 +763,7 @@ 'year': '2014', }), ('test-riff-tags.wav', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 1.0, 'filesize': 176540, @@ -777,7 +777,7 @@ 'year': '2014', }), ('silence-22khz-mono-1s.wav', { - 'extra': {}, + 'other': {}, 'channels': 1, 'duration': 0.9991836734693877, 'filesize': 48160, @@ -786,7 +786,7 @@ 'bitdepth': 16, }), ('id3_header_with_a_zero_byte.wav', { - 'extra': { + 'other': { 'title': ['Stacked'] }, 'channels': 1, @@ -801,7 +801,7 @@ 'album': 'prototypes', }), ('adpcm.wav', { - 'extra': {}, + 'other': {}, 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, @@ -817,7 +817,7 @@ 'year': '1990', }), ('riff_extra_zero.wav', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20670, @@ -832,7 +832,7 @@ 'track': 3, }), ('riff_extra_zero_2.wav', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20682, @@ -846,7 +846,7 @@ 'track': 7, }), ('wav_invalid_track_number.wav', { - 'extra': {}, + 'other': {}, 'filesize': 8908, 'bitrate': 705.6, 'duration': 0.1, @@ -855,7 +855,7 @@ 'bitdepth': 16, }), ('gsm_6_10.wav', { - 'extra': {}, + 'other': {}, 'bitdepth': 1, 'bitrate': 44.1, 'channels': 1, @@ -871,7 +871,7 @@ 'genre': 'Bass', }), ('wav_with_image.wav', { - 'extra': {}, + 'other': {}, 'channels': 1, 'duration': 2.14475, 'filesize': 22902, @@ -880,7 +880,7 @@ 'bitdepth': 8, }), ('flac1sMono.flac', { - 'extra': {}, + 'other': {}, 'genre': 'Avantgarde', 'album': 'alb', 'year': '2014', @@ -896,7 +896,7 @@ 'comment': 'hello', }), ('flac453sStereo.flac', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 453.51473922902494, 'filesize': 84236, @@ -905,7 +905,7 @@ 'bitdepth': 16, }), ('flac1.5sStereo.flac', { - 'extra': {}, + 'other': {}, 'channels': 2, 'album': 'alb', 'year': '2014', @@ -921,7 +921,7 @@ 'comment': 'hello', }), ('flac_application.flac', { - 'extra': { + 'other': { 'replaygain_track_peak': ['0.9976'], 'musicbrainz_albumartistid': [ 'e5c7b94f-e264-473c-bb0f-37c85d4d5c70' @@ -948,7 +948,7 @@ 'bitdepth': 16, }), ('no-tags.flac', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, @@ -957,7 +957,7 @@ 'bitdepth': 16, }), ('variable-block.flac', { - 'extra': { + 'other': { 'discid': ['AA0B360B'], 'japanese title': ['アップルシード オリジナル・サウンドトラック'], 'organization': ['Sony Music Records (SRCP-371)'], @@ -986,11 +986,11 @@ 'composer': 'Boom Boom Satellites (Lyrics)', }), ('106-invalid-streaminfo.flac', { - 'extra': {}, + 'other': {}, 'filesize': 4692 }), ('106-short-picture-block-size.flac', { - 'extra': {}, + 'other': {}, 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, @@ -999,7 +999,7 @@ 'bitdepth': 16, }), ('with_padded_id3_header.flac', { - 'extra': {}, + 'other': {}, 'filesize': 16070, 'album': 'album', 'artist': 'artist', @@ -1015,7 +1015,7 @@ 'comment': 'comment', }), ('with_padded_id3_header2.flac', { - 'extra': { + 'other': { 'mcdi': [ '2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00' '\x00\x00W5\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00' @@ -1051,7 +1051,7 @@ 'comment': 'comment', }), ('flac_invalid_track_number.flac', { - 'extra': {}, + 'other': {}, 'filesize': 235, 'bitrate': 18.8, 'channels': 1, @@ -1060,7 +1060,7 @@ 'bitdepth': 16, }), ('flac_with_image.flac', { - 'extra': { + 'other': { 'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'], 'album': ['album 2'], @@ -1077,7 +1077,7 @@ 'bitdepth': 16, }), ('test2.wma', { - 'extra': { + 'other': { '_track': ['0'], 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], 'encodingtime': ['128861118183900000'], @@ -1102,7 +1102,7 @@ 'channels': 2, }), ('lossless.wma', { - 'extra': {}, + 'other': {}, 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, @@ -1111,7 +1111,7 @@ 'channels': 2, }), ('wma_invalid_track_number.wma', { - 'extra': { + 'other': { 'encoder_settings': ['Lavf60.16.100'] }, 'filesize': 3940, @@ -1121,7 +1121,7 @@ 'channels': 1, }), ('test.m4a', { - 'extra': { + 'other': { 'itunsmpb': [ ' 00000000 00000840 000001DC 0000000000D3E9E4 00000000' ' 00000000 00000000 00000000 00000000 00000000 00000000' @@ -1152,7 +1152,7 @@ 'filesize': 61432, }), ('mpeg4_with_image.m4a', { - 'extra': { + 'other': { 'publisher': ['test7'], 'bpm': ['1'], 'encoded_by': ['Lavf60.3.100'] @@ -1166,7 +1166,7 @@ 'bitrate': 27.887, }), ('alac_file.m4a', { - 'extra': { + 'other': { 'copyright': ['© Hyperion Records Ltd, London'], 'lyrics': ['Album notes:'], 'upc': ['0034571177380'] @@ -1191,7 +1191,7 @@ 'bitdepth': 16, }), ('mpeg4_desc_cmt.m4a', { - 'extra': { + 'other': { 'description': ['test description'], 'encoded_by': ['Lavf59.27.100'] }, @@ -1203,7 +1203,7 @@ 'samplerate': 44100, }), ('mpeg4_xa9des.m4a', { - 'extra': { + 'other': { 'description': ['test description'] }, 'filesize': 2639, @@ -1211,7 +1211,7 @@ 'duration': 727.1066666666667, }), ('test2.m4a', { - 'extra': { + 'other': { 'publisher': ['test7'], 'bpm': ['99999'], 'encoded_by': ['Lavf60.3.100'] @@ -1225,7 +1225,7 @@ 'bitrate': 27.887, }), ('mvhd_version_1.m4a', { - 'extra': {}, + 'other': {}, 'title': '64-bit test', 'filesize': 2048, 'samplerate': 44100, @@ -1234,7 +1234,7 @@ 'bitrate': 0.0, }), ('test-tagged.aiff', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 1.0, 'filesize': 177620, @@ -1250,7 +1250,7 @@ 'year': '2014', }), ('test.aiff', { - 'extra': { + 'other': { 'copyright': ['℗ 1992 Ace Records'] }, 'channels': 2, @@ -1263,7 +1263,7 @@ 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), ('pluck-pcm8.aiff', { - 'extra': {}, + 'other': {}, 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, @@ -1277,7 +1277,7 @@ 'year': '2013', }), ('M1F1-mulawC-AFsp.afc', { - 'extra': { + 'other': { 'comment': ['user: kabal@CAPELLA', 'program: CopyAudio'] }, 'channels': 2, @@ -1289,13 +1289,13 @@ 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC', }), ('invalid_sample_rate.aiff', { - 'extra': {}, + 'other': {}, 'channels': 1, 'filesize': 4096, 'bitdepth': 16, }), ('aiff_extra_tags.aiff', { - 'extra': { + 'other': { 'copyright': ['test'], 'isrc': ['CC-XXX-YY-NNNNN'] }, @@ -1309,7 +1309,7 @@ 'artist': 'artist 1;artist 2', }), ('aiff_with_image.aiff', { - 'extra': {}, + 'other': {}, 'channels': 1, 'duration': 2.176, 'filesize': 21044, @@ -1330,7 +1330,7 @@ def compare_values(path: str, result_val: str | float, expected_val: str | float) -> bool: # lets not copy *all* the lyrics inside the fixture - if (path == 'extra.lyrics' + if (path == 'other.lyrics' and isinstance(expected_val, list) and isinstance(result_val, list)): return result_val[0].startswith(expected_val[0]) @@ -1378,7 +1378,7 @@ def test_file_reading_tags(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(SAMPLE_FOLDER, testfile) excluded_attrs = { - "bitdepth", "bitrate", "channels", "duration", "samplerate" + 'bitdepth', 'bitrate', 'channels', 'duration', 'samplerate' } tag = TinyTag.get(filename, tags=True, duration=False) results = { @@ -1399,14 +1399,14 @@ def test_file_reading_duration(testfile: str, expected: dict[str, dict[str, Any]]) -> None: filename = os.path.join(SAMPLE_FOLDER, testfile) allowed_attrs = { - "bitdepth", "bitrate", "channels", "duration", - "filesize", "samplerate"} + 'bitdepth', 'bitrate', 'channels', 'duration', + 'filesize', 'samplerate'} tag = TinyTag.get(filename, tags=False, duration=True) results = { key: val for key, val in tag.__dict__.items() if not key.startswith('_') and val is not None } - for attr_name in ('filename', 'extra', 'images'): + for attr_name in ('filename', 'other', 'images'): del results[attr_name] expected = { key: val for key, val in expected.items() if key in allowed_attrs @@ -1434,14 +1434,14 @@ def test_file_obj_compatibility() -> None: @pytest.mark.skipif( system() == 'Windows' and python_implementation() == 'PyPy', - reason="PyPy on Windows not supported" + reason='PyPy on Windows not supported' ) def test_binary_path_compatibility() -> None: binary_file_path = os.path.join( SAMPLE_FOLDER, 'non_ascii_filename_äää.mp3').encode('utf-8') tag = TinyTag.get(binary_file_path) assert tag.samplerate == 44100 - assert tag.extra['encoder_settings'] == ['Lavf58.20.100'] + assert tag.other['encoder_settings'] == ['Lavf58.20.100'] def test_unsupported_extension() -> None: @@ -1511,10 +1511,10 @@ def test_image_loading(path: str, expected_size: int, desc: str) -> None: image = tag.images.any manual_image = tag.images.front_cover if manual_image is None: - manual_image = tag.images.extra["other"][0] + manual_image = tag.images.other['generic'][0] assert image is not None assert manual_image is not None - assert image.name in {'front_cover', 'other'} + assert image.name in {'front_cover', 'generic'} assert image.data is not None assert image.data == manual_image.data with pytest.warns(DeprecationWarning): @@ -1528,10 +1528,10 @@ def test_image_loading(path: str, expected_size: int, desc: str) -> None: assert image.description == desc -def test_image_loading_extra() -> None: +def test_image_loading_other() -> None: tag = TinyTag.get( os.path.join(SAMPLE_FOLDER, 'ogg_with_image.ogg'), image=True) - image = tag.images.extra['bright_colored_fish'][0] + image = tag.images.other['bright_colored_fish'][0] assert image.data is not None assert tag.images.any is not None assert tag.images.any.data == image.data @@ -1542,10 +1542,10 @@ def test_image_loading_extra() -> None: assert image.description == 'some image ë' assert len(image.data) == 1220 assert str(image) == ( - "{'name': 'bright_colored_fish', 'data': b'\\xff\\xd8\\xff\\xe0\\x00" + "Image(name='bright_colored_fish', data=b'\\xff\\xd8\\xff\\xe0\\x00" "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "'mime_type': 'image/jpeg', 'description': 'some image ë'}" + "mime_type='image/jpeg', description='some image ë')" ) @@ -1586,13 +1586,15 @@ def test_show_hint_for_wrong_usage() -> None: def test_deprecations() -> None: - file_path = os.path.join(SAMPLE_FOLDER, 'id3v24-long-title.mp3') + file_path = os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac') with pytest.warns(DeprecationWarning): tag = TinyTag.get(filename=file_path, image=True, ignore_errors=True) with pytest.warns(DeprecationWarning): tag = TinyTag.get(filename=file_path, image=True, ignore_errors=False) with pytest.warns(DeprecationWarning): assert tag.audio_offset is None + with pytest.warns(DeprecationWarning): + assert str(tag.extra) == "{'url': 'https://example.com'}" with pytest.warns(DeprecationWarning): assert tag.images.any is not None assert tag.get_image() == tag.images.any.data @@ -1601,6 +1603,7 @@ def test_deprecations() -> None: def test_str_vars() -> None: tag = TinyTag.get( os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) + vars_str = str(vars(tag)) assert ( "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " "'channels': 1, 'bitrate': 225.92, " @@ -1608,22 +1611,25 @@ def test_str_vars() -> None: "'albumartist': None, 'composer': None, 'album': 'album 1', " "'disc': None, 'disc_total': None, 'title': None, 'track': None, " "'track_total': None, 'genre': 'genre 1', 'year': None, " - "'comment': None, 'extra': {'artist': ['artist 2', 'artist 3'], " - "'album': ['album 2'], 'genre': ['genre 2'], " - "'url': ['https://example.com']}, 'images': None: "['genre 1', 'genre 2'], 'url': ['https://example.com']}" ) assert str(tag.images.as_dict()) == ( - "{'front_cover': [{'name': 'front_cover', 'data': b'\\xff\\xd8\\xff" + "{'front_cover': [Image(name='front_cover', data=b'\\xff\\xd8\\xff" "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "'mime_type': 'image/jpeg', 'description': 'some image ë'}], " - "'bright_colored_fish': [{'name': 'bright_colored_fish', " - "'data': b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" + "mime_type='image/jpeg', description='some image ë')], " + "'bright_colored_fish': [Image(name='bright_colored_fish', " + "data=b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01" - "\\x00\\x00\\x02\\xa0lcm..', 'mime_type': 'image/jpeg', " - "'description': 'some image ë'}]}" + "\\x00\\x00\\x02\\xa0lcm..', mime_type='image/jpeg', " + "description='some image ë')]}" ) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index ea7b104..77276ba 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -21,7 +21,7 @@ tinytag_attributes = { 'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', - 'extra', 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', + 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', 'track_total', 'year' } diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ed6e9ee..58f9e6c 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -39,10 +39,10 @@ from collections.abc import Callable, Iterator from typing import Any, BinaryIO, Dict, List - _Extra = Dict[str, List[str]] - _ImagesExtra = Dict[str, List["Image"]] + _OtherFields = Dict[str, List[str]] + _OtherImages = Dict[str, List["Image"]] else: - _Extra = _ImagesExtra = dict + _OtherFields = _OtherImages = dict # some of the parsers can print debug info DEBUG = bool(environ.get('TINYTAG_DEBUG')) @@ -61,7 +61,7 @@ class UnsupportedFormatError(TinyTagException): class TinyTag: - """A class containing audio file properties and metadata.""" + """A class containing audio file properties and metadata fields.""" SUPPORTED_FILE_EXTENSIONS = ( '.mp1', '.mp2', '.mp3', @@ -70,12 +70,13 @@ class TinyTag: '.m4b', '.m4a', '.m4r', '.m4v', '.mp4', '.aax', '.aaxc', '.aiff', '.aifc', '.aif', '.afc' ) - _EXTRA_PREFIX = 'extra.' + _OTHER_PREFIX = 'other.' _file_extension_mapping: dict[tuple[str, ...], type[TinyTag]] | None = None def __init__(self) -> None: self.filename: str | None = None self.filesize = 0 + self.duration: float | None = None self.channels: int | None = None self.bitrate: float | None = None @@ -95,8 +96,8 @@ def __init__(self) -> None: self.year: str | None = None self.comment: str | None = None - self.extra = Extra() self.images = Images() + self.other = OtherFields() self._filehandler: BinaryIO | None = None self._default_encoding: str | None = None # override for some formats @@ -104,7 +105,7 @@ def __init__(self) -> None: self._parse_tags = True self._load_image = False self._tags_parsed = False - self.__dict__: dict[str, str | float | Extra | Images] + self.__dict__: dict[str, str | float | Images | OtherFields] @classmethod def get(cls, @@ -130,7 +131,7 @@ def get(cls, # pylint: disable=import-outside-toplevel from warnings import warn warn('ignore_errors argument is obsolete, and will be removed in ' - 'a future 2.x release', DeprecationWarning, stacklevel=2) + 'the future', DeprecationWarning, stacklevel=2) try: # pylint: disable=protected-access file_obj.seek(0, SEEK_END) @@ -168,7 +169,7 @@ def as_dict(self) -> dict[str, str | float | list[str]]: continue if isinstance(value, Images): continue - if not isinstance(value, Extra): + if not isinstance(value, OtherFields): if value is None: continue if key != 'filename' and isinstance(value, str): @@ -176,11 +177,11 @@ def as_dict(self) -> dict[str, str | float | list[str]]: else: fields[key] = value continue - for extra_key, extra_values in value.items(): - extra_fields = fields.get(extra_key) - if not isinstance(extra_fields, list): - extra_fields = fields[extra_key] = [] - extra_fields += extra_values + for other_key, other_values in value.items(): + other_fields = fields.get(other_key) + if not isinstance(other_fields, list): + other_fields = fields[other_key] = [] + other_fields += other_values return fields @classmethod @@ -266,28 +267,28 @@ def _load(self, tags: bool, duration: bool, image: bool = False) -> None: def _set_field(self, fieldname: str, value: str | float, check_conflict: bool = True) -> None: - if fieldname.startswith(self._EXTRA_PREFIX): - fieldname = fieldname[len(self._EXTRA_PREFIX):] + if fieldname.startswith(self._OTHER_PREFIX): + fieldname = fieldname[len(self._OTHER_PREFIX):] if check_conflict and fieldname in self.__dict__: fieldname = '_' + fieldname - extra_values = self.extra.get(fieldname, []) - if not isinstance(value, str) or value in extra_values: + other_values = self.other.get(fieldname, []) + if not isinstance(value, str) or value in other_values: return - extra_values.append(value) + other_values.append(value) if DEBUG: print( - f'Setting extra field "{fieldname}" to "{extra_values!r}"') - self.extra[fieldname] = extra_values + f'Setting other field "{fieldname}" to "{other_values!r}"') + self.other[fieldname] = other_values return old_value = self.__dict__.get(fieldname) new_value = value if isinstance(new_value, str): - # First value goes in tag, others in tag.extra + # First value goes in tag, others in tag.other values = new_value.split('\x00') for index, i_value in enumerate(values): if index or old_value and i_value != old_value: self._set_field( - self._EXTRA_PREFIX + fieldname, i_value, + self._OTHER_PREFIX + fieldname, i_value, check_conflict=False) continue new_value = i_value @@ -311,11 +312,11 @@ def _update(self, other: TinyTag) -> None: for key, value in other.__dict__.items(): if key.startswith('_'): continue - if isinstance(value, Extra): - for extra_key, extra_values in other.extra.items(): - for extra_value in extra_values: + if isinstance(value, OtherFields): + for other_key, other_values in other.other.items(): + for other_value in other_values: self._set_field( - self._EXTRA_PREFIX + extra_key, extra_value, + self._OTHER_PREFIX + other_key, other_value, check_conflict=False) elif isinstance(value, Images): self.images._update(value) # pylint: disable=protected-access @@ -328,10 +329,10 @@ def _unpad(s: str) -> str: return s.strip('b\x00') def get_image(self) -> bytes | None: - """Deprecated, use images.any instead.""" + """Deprecated, use 'images.any' instead.""" from warnings import warn # pylint: disable=import-outside-toplevel - warn('get_image() is deprecated, and will be removed in a future 2.x ' - 'release. Use images.any instead.', + warn('get_image() is deprecated, and will be removed in the future. ' + "Use 'images.any' instead.", DeprecationWarning, stacklevel=2) image = self.images.any return image.data if image is not None else None @@ -340,26 +341,32 @@ def get_image(self) -> bytes | None: def audio_offset(self) -> None: """Obsolete.""" from warnings import warn # pylint: disable=import-outside-toplevel - warn('audio_offset attribute is obsolete, and will be ' - 'removed in a future 2.x release', + warn("'audio_offset' attribute is obsolete, and will be " + 'removed in the future', DeprecationWarning, stacklevel=2) - -class Extra(_Extra): - """A dictionary containing additional metadata fields of an audio file.""" + @property + def extra(self) -> dict[str, str]: + """Deprecated, use 'other' instead.""" + from warnings import warn # pylint: disable=import-outside-toplevel + warn("'extra' attribute is deprecated, and will be " + "removed in the future. Use 'other' instead.", + DeprecationWarning, stacklevel=2) + extra_keys = {'copyright', 'initial_key', 'isrc', 'lyrics', 'url'} + return {k: v[0] for k, v in self.other.items() if k in extra_keys} class Images: """A class containing images embedded in an audio file.""" - _EXTRA_PREFIX = 'extra.' + _OTHER_PREFIX = 'other.' def __init__(self) -> None: self.front_cover: Image | None = None self.back_cover: Image | None = None self.media: Image | None = None - self.extra = ImagesExtra() - self.__dict__: dict[str, Image | ImagesExtra] + self.other = OtherImages() + self.__dict__: dict[str, Image | OtherImages] @property def any(self) -> Image | None: @@ -367,9 +374,9 @@ def any(self) -> Image | None: If not present, fall back to any other available image. """ for value in self.__dict__.values(): - if isinstance(value, ImagesExtra): - for extra_images in value.values(): - for image in extra_images: + if isinstance(value, OtherImages): + for other_images in value.values(): + for image in other_images: return image continue if value is not None: @@ -380,26 +387,26 @@ def as_dict(self) -> dict[str, list[Image]]: """Return a flat dictionary representation of available images.""" images: dict[str, list[Image]] = {} for key, value in self.__dict__.items(): - if not isinstance(value, ImagesExtra): + if not isinstance(value, OtherImages): if value is not None: images[key] = [value] continue - for extra_key, extra_values in value.items(): - extra_images = images.get(extra_key) - if not isinstance(extra_images, list): - extra_images = images[extra_key] = [] - extra_images += extra_values + for other_key, other_values in value.items(): + other_images = images.get(other_key) + if not isinstance(other_images, list): + other_images = images[other_key] = [] + other_images += other_values return images def _set_field(self, fieldname: str, value: Image) -> None: old_value = self.__dict__.get(fieldname) - if fieldname.startswith(self._EXTRA_PREFIX) or old_value is not None: - fieldname = fieldname[len(self._EXTRA_PREFIX):] - extra_values = self.extra.get(fieldname, []) - extra_values.append(value) + if fieldname.startswith(self._OTHER_PREFIX) or old_value is not None: + fieldname = fieldname[len(self._OTHER_PREFIX):] + other_values = self.other.get(fieldname, []) + other_values.append(value) if DEBUG: - print(f'Setting extra image field "{fieldname}"') - self.extra[fieldname] = extra_values + print(f'Setting other image field "{fieldname}"') + self.other[fieldname] = other_values return if DEBUG: print(f'Setting image field "{fieldname}"') @@ -407,21 +414,16 @@ def _set_field(self, fieldname: str, value: Image) -> None: def _update(self, other: Images) -> None: for key, value in other.__dict__.items(): - if isinstance(value, ImagesExtra): - for extra_key, extra_values in value.items(): - for image_extra in extra_values: + if isinstance(value, OtherImages): + for other_key, other_values in value.items(): + for image_other in other_values: self._set_field( - self._EXTRA_PREFIX + extra_key, image_extra) + self._OTHER_PREFIX + other_key, image_other) continue if value is not None: self._set_field(key, value) -class ImagesExtra(_ImagesExtra): - """A dictionary containing additional images embedded in an audio - file.""" - - class Image: """A class representing an image embedded in an audio file.""" def __init__(self, @@ -438,7 +440,16 @@ def __repr__(self) -> str: data = variables.get("data") if data is not None: variables["data"] = (data[:45] + b'..') if len(data) > 45 else data - return str(variables) + data_str = ', '.join(f'{k}={v!r}' for k, v in variables.items()) + return f'{type(self).__name__}({data_str})' + + +class OtherFields(_OtherFields): + """A dictionary containing additional metadata fields of an audio file.""" + + +class OtherImages(_OtherImages): + """A dictionary containing additional images embedded in an audio file.""" class _MP4(TinyTag): @@ -450,17 +461,17 @@ class _MP4(TinyTag): _CUSTOM_FIELD_NAME_MAPPING = { 'artists': 'artist', - 'conductor': 'extra.conductor', - 'discsubtitle': 'extra.set_subtitle', - 'initialkey': 'extra.initial_key', - 'isrc': 'extra.isrc', - 'language': 'extra.language', - 'lyricist': 'extra.lyricist', - 'media': 'extra.media', - 'website': 'extra.url', - 'license': 'extra.license', - 'barcode': 'extra.barcode', - 'catalognumber': 'extra.catalog_number', + 'conductor': 'other.conductor', + 'discsubtitle': 'other.set_subtitle', + 'initialkey': 'other.initial_key', + 'isrc': 'other.isrc', + 'language': 'other.language', + 'lyricist': 'other.lyricist', + 'media': 'other.media', + 'website': 'other.url', + 'license': 'other.license', + 'barcode': 'other.barcode', + 'catalognumber': 'other.catalog_number', } _IMAGE_MIME_TYPES = { 13: 'image/jpeg', @@ -504,26 +515,26 @@ def _parse_tag(self, fh: BinaryIO) -> None: b'\xa9ART': {b'data': _MP4._data_parser('artist')}, b'\xa9alb': {b'data': _MP4._data_parser('album')}, b'\xa9cmt': {b'data': _MP4._data_parser('comment')}, - b'\xa9con': {b'data': _MP4._data_parser('extra.conductor')}, + b'\xa9con': {b'data': _MP4._data_parser('other.conductor')}, # need test-data for this - # b'cpil': {b'data': _MP4._data_parser('extra.compilation')}, + # b'cpil': {b'data': _MP4._data_parser('other.compilation')}, b'\xa9day': {b'data': _MP4._data_parser('year')}, - b'\xa9des': {b'data': _MP4._data_parser('extra.description')}, - b'\xa9dir': {b'data': _MP4._data_parser('extra.director')}, + b'\xa9des': {b'data': _MP4._data_parser('other.description')}, + b'\xa9dir': {b'data': _MP4._data_parser('other.director')}, b'\xa9gen': {b'data': _MP4._data_parser('genre')}, - b'\xa9lyr': {b'data': _MP4._data_parser('extra.lyrics')}, + b'\xa9lyr': {b'data': _MP4._data_parser('other.lyrics')}, b'\xa9mvn': {b'data': _MP4._data_parser('movement')}, b'\xa9nam': {b'data': _MP4._data_parser('title')}, - b'\xa9pub': {b'data': _MP4._data_parser('extra.publisher')}, - b'\xa9too': {b'data': _MP4._data_parser('extra.encoded_by')}, + b'\xa9pub': {b'data': _MP4._data_parser('other.publisher')}, + b'\xa9too': {b'data': _MP4._data_parser('other.encoded_by')}, b'\xa9wrt': {b'data': _MP4._data_parser('composer')}, b'aART': {b'data': _MP4._data_parser('albumartist')}, - b'cprt': {b'data': _MP4._data_parser('extra.copyright')}, - b'desc': {b'data': _MP4._data_parser('extra.description')}, + b'cprt': {b'data': _MP4._data_parser('other.copyright')}, + b'desc': {b'data': _MP4._data_parser('other.description')}, b'disk': {b'data': _MP4._nums_parser('disc', 'disc_total')}, b'gnre': {b'data': _MP4._parse_id3v1_genre}, b'trkn': {b'data': _MP4._nums_parser('track', 'track_total')}, - b'tmpo': {b'data': _MP4._data_parser('extra.bpm')}, + b'tmpo': {b'data': _MP4._data_parser('other.bpm')}, b'covr': {b'data': _MP4._parse_cover_image}, b'----': _MP4._parse_custom_field, }}}}} @@ -649,7 +660,7 @@ def _parse_custom_field( field_name = atom_value.decode('utf-8', 'replace') # pylint: disable=protected-access field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get( - field_name, TinyTag._EXTRA_PREFIX + field_name) + field_name, TinyTag._OTHER_PREFIX + field_name) elif atom_type == b'data': data_atom = fh.read(atom_size) else: @@ -725,28 +736,28 @@ class _ID3(TinyTag): 'TPOS': 'disc', 'TPA': 'disc', 'TPE2': 'albumartist', 'TP2': 'albumartist', 'TCOM': 'composer', 'TCM': 'composer', - 'WOAR': 'extra.url', 'WAR': 'extra.url', - 'TSRC': 'extra.isrc', 'TRC': 'extra.isrc', - 'TCOP': 'extra.copyright', 'TCR': 'extra.copyright', - 'TBPM': 'extra.bpm', 'TBP': 'extra.bpm', - 'TKEY': 'extra.initial_key', 'TKE': 'extra.initial_key', - 'TLAN': 'extra.language', 'TLA': 'extra.language', - 'TPUB': 'extra.publisher', 'TPB': 'extra.publisher', - 'USLT': 'extra.lyrics', 'ULT': 'extra.lyrics', - 'TPE3': 'extra.conductor', 'TP3': 'extra.conductor', - 'TEXT': 'extra.lyricist', 'TXT': 'extra.lyricist', - 'TSST': 'extra.set_subtitle', - 'TENC': 'extra.encoded_by', 'TEN': 'extra.encoded_by', - 'TSSE': 'extra.encoder_settings', 'TSS': 'extra.encoder_settings', - 'TMED': 'extra.media', 'TMT': 'extra.media', - 'WCOP': 'extra.license', + 'WOAR': 'other.url', 'WAR': 'other.url', + 'TSRC': 'other.isrc', 'TRC': 'other.isrc', + 'TCOP': 'other.copyright', 'TCR': 'other.copyright', + 'TBPM': 'other.bpm', 'TBP': 'other.bpm', + 'TKEY': 'other.initial_key', 'TKE': 'other.initial_key', + 'TLAN': 'other.language', 'TLA': 'other.language', + 'TPUB': 'other.publisher', 'TPB': 'other.publisher', + 'USLT': 'other.lyrics', 'ULT': 'other.lyrics', + 'TPE3': 'other.conductor', 'TP3': 'other.conductor', + 'TEXT': 'other.lyricist', 'TXT': 'other.lyricist', + 'TSST': 'other.set_subtitle', + 'TENC': 'other.encoded_by', 'TEN': 'other.encoded_by', + 'TSSE': 'other.encoder_settings', 'TSS': 'other.encoder_settings', + 'TMED': 'other.media', 'TMT': 'other.media', + 'WCOP': 'other.license', } _ID3_MAPPING_CUSTOM = { 'artists': 'artist', - 'director': 'extra.director', - 'license': 'extra.license', - 'barcode': 'extra.barcode', - 'catalognumber': 'extra.catalog_number', + 'director': 'other.director', + 'license': 'other.license', + 'barcode': 'other.barcode', + 'catalognumber': 'other.catalog_number', } _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} @@ -805,29 +816,29 @@ class _ID3(TinyTag): 'png': 'image/png', } _IMAGE_TYPES = ( - 'extra.other', - 'extra.icon', - 'extra.other_icon', + 'other.generic', + 'other.icon', + 'other.alt_icon', 'front_cover', 'back_cover', - 'extra.leaflet', + 'other.leaflet', 'media', - 'extra.lead_artist', - 'extra.artist', - 'extra.conductor', - 'extra.band', - 'extra.composer', - 'extra.lyricist', - 'extra.recording_location', - 'extra.during_recording', - 'extra.during_performance', - 'extra.screen_capture', - 'extra.bright_colored_fish', - 'extra.illustration', - 'extra.band_logo', - 'extra.publisher_logo', + 'other.lead_artist', + 'other.artist', + 'other.conductor', + 'other.band', + 'other.composer', + 'other.lyricist', + 'other.recording_location', + 'other.during_recording', + 'other.during_performance', + 'other.screen_capture', + 'other.bright_colored_fish', + 'other.illustration', + 'other.band_logo', + 'other.publisher_logo', ) - _UNKNOWN_IMAGE_TYPE = 'extra.unknown' + _UNKNOWN_IMAGE_TYPE = 'other.unknown' # see this page for the magic values used in mp3: # http://www.mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm @@ -1065,7 +1076,7 @@ def __parse_custom_field(self, content: str) -> bool: if custom_field_name_lower and separator and value: field_name = self._ID3_MAPPING_CUSTOM.get( custom_field_name_lower, - self._EXTRA_PREFIX + custom_field_name_lower) + self._OTHER_PREFIX + custom_field_name_lower) self._set_field(field_name, value) return True return False @@ -1080,8 +1091,8 @@ def _create_tag_image(cls, if 0 <= pic_type <= len(cls._IMAGE_TYPES): field_name = cls._IMAGE_TYPES[pic_type] name = field_name - if field_name.startswith(cls._EXTRA_PREFIX): - name = field_name[len(cls._EXTRA_PREFIX):] + if field_name.startswith(cls._OTHER_PREFIX): + name = field_name[len(cls._OTHER_PREFIX):] image = Image(name, data) if mime_type: image.mime_type = mime_type @@ -1120,7 +1131,7 @@ def _parse_frame(self, if fieldname: if not self._parse_tags: return frame_size - language = fieldname in {'comment', 'extra.lyrics'} + language = fieldname in {'comment', 'other.lyrics'} value = self._decode_string(content, language) if not value: return frame_size @@ -1188,12 +1199,12 @@ def _parse_frame(self, # pylint: disable=protected-access self.images._set_field(field_name, image) elif frame_id not in self._DISALLOWED_FRAME_IDS: - # unknown, try to add to extra dict + # unknown, try to add to other dict if self._parse_tags: value = self._decode_string(content) if value: self._set_field( - self._EXTRA_PREFIX + frame_id.lower(), value) + self._OTHER_PREFIX + frame_id.lower(), value) return frame_size def _decode_string(self, value: bytes, language: bool = False) -> str: @@ -1221,7 +1232,7 @@ def _decode_string(self, value: bytes, language: bool = False) -> str: # strip the bom if it exists if value.startswith(b'\xfe\xff') or value.startswith(b'\xff\xfe'): value = value[2:] if len(value) % 2 == 0 else value[2:-1] - # remove ADDITIONAL EXTRA BOM :facepalm: + # remove ADDITIONAL OTHER BOM :facepalm: if value.startswith(b'\x00\x00\xff\xfe'): value = value[4:] elif first_byte == b'\x02': # UTF-16 without BOM @@ -1264,26 +1275,26 @@ class _Ogg(TinyTag): 'comment': 'comment', 'comments': 'comment', 'composer': 'composer', - 'bpm': 'extra.bpm', - 'copyright': 'extra.copyright', - 'isrc': 'extra.isrc', - 'lyrics': 'extra.lyrics', - 'publisher': 'extra.publisher', - 'language': 'extra.language', - 'director': 'extra.director', - 'website': 'extra.url', - 'conductor': 'extra.conductor', - 'lyricist': 'extra.lyricist', - 'discsubtitle': 'extra.set_subtitle', - 'setsubtitle': 'extra.set_subtitle', - 'initialkey': 'extra.initial_key', - 'key': 'extra.initial_key', - 'encodedby': 'extra.encoded_by', - 'encodersettings': 'extra.encoder_settings', - 'media': 'extra.media', - 'license': 'extra.license', - 'barcode': 'extra.barcode', - 'catalognumber': 'extra.catalog_number', + 'bpm': 'other.bpm', + 'copyright': 'other.copyright', + 'isrc': 'other.isrc', + 'lyrics': 'other.lyrics', + 'publisher': 'other.publisher', + 'language': 'other.language', + 'director': 'other.director', + 'website': 'other.url', + 'conductor': 'other.conductor', + 'lyricist': 'other.lyricist', + 'discsubtitle': 'other.set_subtitle', + 'setsubtitle': 'other.set_subtitle', + 'initialkey': 'other.initial_key', + 'key': 'other.initial_key', + 'encodedby': 'other.encoded_by', + 'encodersettings': 'other.encoder_settings', + 'media': 'other.media', + 'license': 'other.license', + 'barcode': 'other.barcode', + 'catalognumber': 'other.catalog_number', } def __init__(self) -> None: @@ -1401,7 +1412,7 @@ def _parse_vorbis_comment(self, if DEBUG: print('Found Vorbis Comment', key, value[:64]) fieldname = self._VORBIS_MAPPING.get( - key_lower, self._EXTRA_PREFIX + key_lower) + key_lower, self._OTHER_PREFIX + key_lower) if fieldname in { 'track', 'disc', 'track_total', 'disc_total' }: @@ -1459,23 +1470,23 @@ class _Wave(TinyTag): b'TITL': 'title', b'IPRD': 'album', b'IART': 'artist', - b'IBPM': 'extra.bpm', + b'IBPM': 'other.bpm', b'ICMT': 'comment', b'IMUS': 'composer', - b'ICOP': 'extra.copyright', + b'ICOP': 'other.copyright', b'ICRD': 'year', b'IGNR': 'genre', - b'ILNG': 'extra.language', - b'ISRC': 'extra.isrc', - b'IPUB': 'extra.publisher', + b'ILNG': 'other.language', + b'ISRC': 'other.isrc', + b'IPUB': 'other.publisher', b'IPRT': 'track', b'ITRK': 'track', b'TRCK': 'track', - b'IBSU': 'extra.url', + b'IBSU': 'other.url', b'YEAR': 'year', - b'IWRI': 'extra.lyricist', - b'IENC': 'extra.encoded_by', - b'IMED': 'extra.media', + b'IWRI': 'other.lyricist', + b'IENC': 'other.encoded_by', + b'IMED': 'other.media', } def _determine_duration(self, fh: BinaryIO) -> None: @@ -1659,22 +1670,22 @@ class _Wma(TinyTag): 'WM/Genre': 'genre', 'WM/AlbumTitle': 'album', 'WM/Composer': 'composer', - 'WM/Publisher': 'extra.publisher', - 'WM/BeatsPerMinute': 'extra.bpm', - 'WM/InitialKey': 'extra.initial_key', - 'WM/Lyrics': 'extra.lyrics', - 'WM/Language': 'extra.language', - 'WM/Director': 'extra.director', - 'WM/AuthorURL': 'extra.url', - 'WM/ISRC': 'extra.isrc', - 'WM/Conductor': 'extra.conductor', - 'WM/Writer': 'extra.lyricist', - 'WM/SetSubTitle': 'extra.set_subtitle', - 'WM/EncodedBy': 'extra.encoded_by', - 'WM/EncodingSettings': 'extra.encoder_settings', - 'WM/Media': 'extra.media', - 'WM/Barcode': 'extra.barcode', - 'WM/CatalogNo': 'extra.catalog_number', + 'WM/Publisher': 'other.publisher', + 'WM/BeatsPerMinute': 'other.bpm', + 'WM/InitialKey': 'other.initial_key', + 'WM/Lyrics': 'other.lyrics', + 'WM/Language': 'other.language', + 'WM/Director': 'other.director', + 'WM/AuthorURL': 'other.url', + 'WM/ISRC': 'other.isrc', + 'WM/Conductor': 'other.conductor', + 'WM/Writer': 'other.lyricist', + 'WM/SetSubTitle': 'other.set_subtitle', + 'WM/EncodedBy': 'other.encoded_by', + 'WM/EncodingSettings': 'other.encoder_settings', + 'WM/Media': 'other.media', + 'WM/Barcode': 'other.barcode', + 'WM/CatalogNo': 'other.catalog_number', } _UNPACK_FORMATS = { 1: ' None: data_blocks = { 'title': title_length, 'artist': author_length, - 'extra.copyright': copyright_length, + 'other.copyright': copyright_length, 'comment': description_length, '_rating': rating_length, } @@ -1753,7 +1764,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: if field_name is None: # custom field if name.startswith('WM/'): name = name[3:] - field_name = self._EXTRA_PREFIX + name.lower() + field_name = self._OTHER_PREFIX + name.lower() if field_name in {'track', 'disc'}: if isinstance(value, int) or value.isdecimal(): self._set_field(field_name, int(value)) @@ -1807,7 +1818,7 @@ class _Aiff(TinyTag): b'NAME': 'title', b'AUTH': 'artist', b'ANNO': 'comment', - b'(c) ': 'extra.copyright', + b'(c) ': 'other.copyright', } def _parse_tag(self, fh: BinaryIO) -> None: From be8261797d5622600bf485fe51e46b65f5b045c3 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Nov 2024 21:20:12 +0200 Subject: [PATCH 266/305] ID3: add more frames to ignored list --- tinytag/tests/test_all.py | 6 ------ tinytag/tinytag.py | 17 ++++++++++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 8bca070..0916720 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1016,12 +1016,6 @@ }), ('with_padded_id3_header2.flac', { 'other': { - 'mcdi': [ - '2\x01\x05\x00\x10\x01\x00\x00\x00\x00\x00\x00\x10\x02\x00' - '\x00\x00W5\x00\x10\x03\x00\x00\x00\x90\x0c\x00\x10\x04\x00' - '\x00\x00ä7\x00\x10\x05\x00\x00\x013«\x00\x10ª\x00\x00\x01' - '\x8c\xa0' - ], 'tlen': ['297666'], 'encoded_by': ['Exact Audio Copy (Sicherer Modus)'], 'encoder_settings': [ diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 58f9e6c..f8ed42a 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -761,8 +761,19 @@ class _ID3(TinyTag): } _IMAGE_FRAME_IDS = {'APIC', 'PIC'} _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} - _DISALLOWED_FRAME_IDS = { - 'CHAP', 'CTOC', 'PRIV', 'RGAD', 'GEOB', 'GEO' + _IGNORED_FRAME_IDS = { + 'AENC', 'CRA', + 'CHAP', + 'COMR', + 'CRM', + 'CTOC', + 'ENCR', + 'GEOB', 'GEO', + 'GRID', + 'MCDI', 'MCI', + 'PRIV', + 'RGAD', + 'STC', 'SYTC' } _MAX_ESTIMATION_SEC = 30.0 _CBR_DETECTION_FRAME_COUNT = 5 @@ -1198,7 +1209,7 @@ def _parse_frame(self, content[desc_end_pos:], pic_type, mime_type, desc) # pylint: disable=protected-access self.images._set_field(field_name, image) - elif frame_id not in self._DISALLOWED_FRAME_IDS: + elif frame_id not in self._IGNORED_FRAME_IDS: # unknown, try to add to other dict if self._parse_tags: value = self._decode_string(content) From 2d86962bceb019480eea45de031d5e98cd0e8531 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Nov 2024 21:45:29 +0200 Subject: [PATCH 267/305] ID3: ignore ATXT frame --- tinytag/tinytag.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index f8ed42a..9b77bdc 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -763,6 +763,7 @@ class _ID3(TinyTag): _CUSTOM_FRAME_IDS = {'TXXX', 'TXX'} _IGNORED_FRAME_IDS = { 'AENC', 'CRA', + 'ATXT', 'CHAP', 'COMR', 'CRM', From 233b613504b7a0a123baabb603b3959f686fabf9 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Nov 2024 22:33:34 +0200 Subject: [PATCH 268/305] Remove unnecessary SEEK_SET import --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 9b77bdc..27ae101 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -31,7 +31,7 @@ from __future__ import annotations from binascii import a2b_base64 from io import BytesIO -from os import PathLike, SEEK_CUR, SEEK_END, SEEK_SET, environ, fsdecode +from os import PathLike, SEEK_CUR, SEEK_END, environ, fsdecode from struct import unpack # Lazy imports for type checking @@ -1039,7 +1039,7 @@ def _parse_id3v2(self, fh: BinaryIO) -> None: if frame_size == 0: break parsed_size += frame_size - fh.seek(end_pos, SEEK_SET) + fh.seek(end_pos) def _parse_id3v1(self, fh: BinaryIO) -> None: if fh.read(3) != b'TAG': # check if this is an ID3 v1 tag From 6c255b2f84592c448958ad8997076c541c4499d6 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 2 Nov 2024 23:33:43 +0200 Subject: [PATCH 269/305] ID3: avoid unnecessary seeking to end --- tinytag/tinytag.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 27ae101..0903ab3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -776,6 +776,7 @@ class _ID3(TinyTag): 'RGAD', 'STC', 'SYTC' } + _ID3V1_TAG_SIZE = 128 _MAX_ESTIMATION_SEC = 30.0 _CBR_DETECTION_FRAME_COUNT = 5 _USE_XING_HEADER = True # much faster, but can be deactivated for testing @@ -991,8 +992,8 @@ def _determine_duration(self, fh: BinaryIO) -> None: and len(last_bitrates) == 1) if frames == max_estimation_frames or is_cbr: # try to estimate duration - fh.seek(-128, 2) # jump to last byte (leaving out id3v1 tag) - stream_size = fh.tell() - audio_offset + stream_size = ( + self.filesize - audio_offset - self._ID3V1_TAG_SIZE) est_frame_count = stream_size / (frame_size_accu / frames) samples = est_frame_count * self._SAMPLES_PER_FRAME self.duration = samples / samplerate @@ -1006,8 +1007,9 @@ def _determine_duration(self, fh: BinaryIO) -> None: def _parse_tag(self, fh: BinaryIO) -> None: self._parse_id3v2(fh) - if self.filesize > 128: - fh.seek(-128, SEEK_END) # try parsing id3v1 in last 128 bytes + if self.filesize >= self._ID3V1_TAG_SIZE: + # try parsing id3v1 at the end of file + fh.seek(self.filesize - self._ID3V1_TAG_SIZE) self._parse_id3v1(fh) def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: From b8307290b7a35301c843da3af3df175e0a0e8548 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 00:15:03 +0200 Subject: [PATCH 270/305] Minor cleanups --- tinytag/tinytag.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 0903ab3..68a435f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -26,7 +26,7 @@ # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. -"""Audio file metadata reader""" +"""Audio file metadata reader.""" from __future__ import annotations from binascii import a2b_base64 @@ -338,12 +338,13 @@ def get_image(self) -> bytes | None: return image.data if image is not None else None @property - def audio_offset(self) -> None: + def audio_offset(self) -> None: # pylint: disable=useless-return """Obsolete.""" from warnings import warn # pylint: disable=import-outside-toplevel warn("'audio_offset' attribute is obsolete, and will be " 'removed in the future', DeprecationWarning, stacklevel=2) + return None @property def extra(self) -> dict[str, str]: @@ -453,7 +454,7 @@ class OtherImages(_OtherImages): class _MP4(TinyTag): - """MP4 Audio Parser + """MP4 Audio Parser. https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/Metadata/Metadata.html https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap2/qtff2.html @@ -715,13 +716,13 @@ def _parse_mvhd(cls, data: bytes) -> dict[str, float]: # jump over flags, create & mod times if version == 0: # uses 32 bit integers for timestamps time_scale, duration = unpack('>II', data[12:20]) - else: # version == 1: # uses 64 bit integers for timestamps + else: # version == 1: # uses 64-bit integers for timestamps time_scale, duration = unpack('>IQ', data[20:32]) return {'duration': duration / time_scale} class _ID3(TinyTag): - """MP3 Parser""" + """MP3 Parser.""" _ID3_MAPPING = { # Mapping from Frame ID to a field of the TinyTag @@ -979,7 +980,7 @@ def _determine_duration(self, fh: BinaryIO) -> None: return walker.seek(walker_offset) - frames += 1 # it's most probably an mp3 frame + frames += 1 # it's most probably a mp3 frame bitrate_accu += frame_br if frames == 1: audio_offset = file_offset + walker.tell() @@ -1268,7 +1269,7 @@ def _unsynchsafe(ints: tuple[int, ...]) -> int: class _Ogg(TinyTag): - """OGG Parser""" + """OGG Parser.""" _VORBIS_MAPPING = { 'album': 'album', @@ -1474,7 +1475,7 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: class _Wave(TinyTag): - """WAVE Parser + """WAVE Parser. https://sno.phy.queensu.ca/~phil/exiftool/TagNames/RIFF.html """ @@ -1574,7 +1575,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: class _Flac(TinyTag): - """FLAC Parser""" + """FLAC Parser.""" _STREAMINFO = 0 _VORBIS_COMMENT = 4 @@ -1669,7 +1670,7 @@ def _parse_image(cls, fh: BinaryIO) -> tuple[str, Image]: class _Wma(TinyTag): - """WMA Parser + """WMA Parser. http://web.archive.org/web/20131203084402/http://msdn.microsoft.com/en-us/library/bb643323.aspx http://uguisu.skr.jp/Windows/format_asf.html @@ -1807,7 +1808,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: class _Aiff(TinyTag): - """"AIFF Parser + """AIFF Parser. https://en.wikipedia.org/wiki/Audio_Interchange_File_Format#Data_format https://web.archive.org/web/20171118222232/http://www-mmsp.ece.mcgill.ca/documents/audioformats/aiff/aiff.html @@ -1817,7 +1818,7 @@ class _Aiff(TinyTag): * IFF strings are not supposed to be null terminated, but sometimes are. - * Some tools might throw more metadata into the ANNO chunk but it is + * Some tools might throw more metadata into the ANNO chunk, but it is wildly unreliable to count on it. In fact, the official spec recommends against using it. That said... this code throws the ANNO field into comment and hopes for the best. From 9ec4600dc7fc434789cce23d42d15a154b67f70a Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 14:18:45 +0200 Subject: [PATCH 271/305] tinytag.py: avoid false positives about unused code --- tinytag/tinytag.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 68a435f..3492fab 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -34,8 +34,10 @@ from os import PathLike, SEEK_CUR, SEEK_END, environ, fsdecode from struct import unpack -# Lazy imports for type checking -if False: # pylint: disable=using-constant-test +# Lazy imports for type checking. Define TYPE_CHECKING here instead of using +# typing.TYPE_CHECKING to avoid importing the typing module on runtime. +TYPE_CHECKING = False +if TYPE_CHECKING: from collections.abc import Callable, Iterator from typing import Any, BinaryIO, Dict, List From 7a0ae982ffee95886b221c2a368bf7d2abc71ddb Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 14:59:45 +0200 Subject: [PATCH 272/305] tinytag.py: rename dict type aliases --- tinytag/tinytag.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 3492fab..51ec83f 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -41,10 +41,11 @@ from collections.abc import Callable, Iterator from typing import Any, BinaryIO, Dict, List - _OtherFields = Dict[str, List[str]] - _OtherImages = Dict[str, List["Image"]] + _StringListDict = Dict[str, List[str]] + _ImageListDict = Dict[str, List["Image"]] else: - _OtherFields = _OtherImages = dict + _StringListDict = dict + _ImageListDict = dict # some of the parsers can print debug info DEBUG = bool(environ.get('TINYTAG_DEBUG')) @@ -99,7 +100,7 @@ def __init__(self) -> None: self.comment: str | None = None self.images = Images() - self.other = OtherFields() + self.other: _StringListDict = OtherFields() self._filehandler: BinaryIO | None = None self._default_encoding: str | None = None # override for some formats @@ -368,7 +369,7 @@ def __init__(self) -> None: self.back_cover: Image | None = None self.media: Image | None = None - self.other = OtherImages() + self.other: _ImageListDict = OtherImages() self.__dict__: dict[str, Image | OtherImages] @property @@ -447,11 +448,11 @@ def __repr__(self) -> str: return f'{type(self).__name__}({data_str})' -class OtherFields(_OtherFields): +class OtherFields(_StringListDict): """A dictionary containing additional metadata fields of an audio file.""" -class OtherImages(_OtherImages): +class OtherImages(_ImageListDict): """A dictionary containing additional images embedded in an audio file.""" From 1d9748f69441f921aa1102d36fd472b275a5d349 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 16:24:06 +0200 Subject: [PATCH 273/305] Revert "tinytag.py: avoid false positives about unused code" This reverts commit 9ec4600dc7fc434789cce23d42d15a154b67f70a. --- tinytag/tinytag.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 51ec83f..4e8a44d 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -34,10 +34,8 @@ from os import PathLike, SEEK_CUR, SEEK_END, environ, fsdecode from struct import unpack -# Lazy imports for type checking. Define TYPE_CHECKING here instead of using -# typing.TYPE_CHECKING to avoid importing the typing module on runtime. -TYPE_CHECKING = False -if TYPE_CHECKING: +# Lazy imports for type checking +if False: # pylint: disable=using-constant-test from collections.abc import Callable, Iterator from typing import Any, BinaryIO, Dict, List From d9f7676d7f17dcf4fbe38f248f12cf7b2b193a22 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 16:27:49 +0200 Subject: [PATCH 274/305] tinytag.py: mark DEBUG constant as private --- tinytag/tinytag.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 4e8a44d..bee8507 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -46,7 +46,7 @@ _ImageListDict = dict # some of the parsers can print debug info -DEBUG = bool(environ.get('TINYTAG_DEBUG')) +_DEBUG = bool(environ.get('TINYTAG_DEBUG')) class TinyTagException(Exception): @@ -276,7 +276,7 @@ def _set_field(self, fieldname: str, value: str | float, if not isinstance(value, str) or value in other_values: return other_values.append(value) - if DEBUG: + if _DEBUG: print( f'Setting other field "{fieldname}" to "{other_values!r}"') self.other[fieldname] = other_values @@ -298,7 +298,7 @@ def _set_field(self, fieldname: str, value: str | float, elif not new_value and old_value: # Prioritize non-zero integer values return - if DEBUG: + if _DEBUG: print(f'Setting field "{fieldname}" to "{new_value!r}"') self.__dict__[fieldname] = new_value @@ -406,11 +406,11 @@ def _set_field(self, fieldname: str, value: Image) -> None: fieldname = fieldname[len(self._OTHER_PREFIX):] other_values = self.other.get(fieldname, []) other_values.append(value) - if DEBUG: + if _DEBUG: print(f'Setting other image field "{fieldname}"') self.other[fieldname] = other_values return - if DEBUG: + if _DEBUG: print(f'Setting image field "{fieldname}"') self.__dict__[fieldname] = value @@ -557,7 +557,7 @@ def _traverse_atoms(self, if atom_size <= 0: # empty atom, jump to next one atom_header = fh.read(header_len) continue - if DEBUG: + if _DEBUG: print(f'{" " * 4 * len(curr_path)} ' f'pos: {fh.tell() - header_len} ' f'atom: {atom_type!r} len: {atom_size + header_len}') @@ -574,7 +574,7 @@ def _traverse_atoms(self, # if the path-leaf is a callable, call it on the atom data elif callable(sub_path): for fieldname, value in sub_path(fh.read(atom_size)).items(): - if DEBUG: + if _DEBUG: print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname) if fieldname.startswith('images.'): if self._load_image: @@ -1022,7 +1022,7 @@ def _parse_id3v2_header(self, fh: BinaryIO) -> tuple[int, bool, int]: # check if there is an ID3v2 tag at the beginning of the file if header.startswith(b'ID3'): major = header[3] - if DEBUG: + if _DEBUG: print(f'Found id3 v2.{major}') extended = (header[5] & 0x40) > 0 size = self._unsynchsafe(unpack('4B', header[6:10])) @@ -1135,7 +1135,7 @@ def _parse_frame(self, frame_size = self._unsynchsafe(unpack('4B', header[4:8])) else: frame_size = unpack('>I', header[4:8])[0] - if DEBUG: + if _DEBUG: print(f'Found id3 Frame {frame_id} at ' f'{fh.tell()}-{fh.tell() + frame_size} of {self.filesize}') if frame_size > total_size: @@ -1418,14 +1418,14 @@ def _parse_vorbis_comment(self, key_lower = key.lower() if key_lower == "metadata_block_picture": if self._load_image: - if DEBUG: + if _DEBUG: print('Found Vorbis Image', key, value[:64]) # pylint: disable=protected-access fieldname, fieldvalue = _Flac._parse_image( BytesIO(a2b_base64(value))) self.images._set_field(fieldname, fieldvalue) else: - if DEBUG: + if _DEBUG: print('Found Vorbis Comment', key, value[:64]) fieldname = self._VORBIS_MAPPING.get( key_lower, self._OTHER_PREFIX + key_lower) From 43b8ea0a7988c0c2d338b1be8a0d152e2f079db5 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 16:33:18 +0200 Subject: [PATCH 275/305] tinytag.py: make mypy happy --- tinytag/tinytag.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index bee8507..e9901b3 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -42,8 +42,7 @@ _StringListDict = Dict[str, List[str]] _ImageListDict = Dict[str, List["Image"]] else: - _StringListDict = dict - _ImageListDict = dict + _StringListDict = _ImageListDict = dict # some of the parsers can print debug info _DEBUG = bool(environ.get('TINYTAG_DEBUG')) From 7227676dd2a72d7267a9ca6bef19558c2789a24a Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 16:55:36 +0200 Subject: [PATCH 276/305] Update documentation for 2.0.0 (#217) --- README.md | 319 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 287 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index f282a65..4f5216b 100644 --- a/README.md +++ b/README.md @@ -41,21 +41,55 @@ python3 -m pip install tinytag * Pure Python, no dependencies * Supports Python 3.7 or higher + ## Usage tinytag only provides the minimum needed for _reading_ metadata, and presents it in a simple format. It can determine track number, total tracks, title, artist, album, year, duration and more. - from tinytag import TinyTag - tag = TinyTag.get('/some/music.mp3') - print('This track is by %s.' % tag.artist) - print('It is %f seconds long.' % tag.duration) - +```python +from tinytag import TinyTag +tag: TinyTag = TinyTag.get('/some/music.mp3') + +print(f'This track is by {tag.artist}.') +print(f'It is {tag.duration:.2f} seconds long.') +``` + +> [!WARNING] +> The `ignore_errors` parameter of `TinyTag.get()` is obsolete as of tinytag +> 2.0.0, and will be removed in the future. + Alternatively you can use tinytag directly on the command line: - $ python3 -m tinytag --format csv /some/music.mp3 - > {"filename": "/some/music.mp3", "filesize": 30212227, "album": "Album", "albumartist": "Artist", "artist": "Artist", "audio_offset": null, "bitrate": 256, "channels": 2, "comment": null, "composer": null, "disc": "1", "disc_total": null, "duration": 10, "genre": null, "samplerate": 44100, "title": "Title", "track": "5", "track_total": null, "year": "2012"} + $ python3 -m tinytag /some/music.mp3 + { + "filename": "/some/music.mp3", + "filesize": 3243226, + "duration": 173.52, + "channels": 2, + "bitrate": 128, + "samplerate": 44100, + "artist": [ + "artist name" + ], + "album": [ + "album name" + ], + "title": [ + "track name" + ], + "track": 4, + "genre": [ + "Jazz" + ], + "year": [ + "2010" + ], + "comment": [ + "Some comment here" + ] + } Check `python3 -m tinytag --help` for all CLI options, for example other output formats. @@ -68,50 +102,261 @@ such as [Mutagen](https://mutagen.readthedocs.io/) for this. To receive a tuple of file extensions tinytag supports, use the `SUPPORTED_FILE_EXTENSIONS` constant: - TinyTag.SUPPORTED_FILE_EXTENSIONS +```python +TinyTag.SUPPORTED_FILE_EXTENSIONS +``` -Alternatively, check if a file is supported: +Alternatively, check if a file is supported by providing its path: - is_supported = TinyTag.is_supported('/some/music.mp3') +```python +is_supported = TinyTag.is_supported('/some/music.mp3') +``` ### Common Metadata -List of common attributes you can get with tinytag: +tinytag provides some common attributes, which always contain a single value. +These are helpful when you need quick access to common metadata. + +#### File/Audio Properties + + tag.bitdepth # bit depth as integer (for lossless audio) + tag.bitrate # bitrate in kBits/s as float + tag.duration # audio duration in seconds as float + tag.filename # filename as string + tag.filesize # file size in bytes as integer + tag.samplerate # samples per second as integer + +> [!WARNING] +> The `tag.audio_offset` attribute is obsolete as of tinytag 2.0.0, and will +> be removed in the future. + +#### Metadata Fields tag.album # album as string tag.albumartist # album artist as string tag.artist # artist name as string - tag.audio_offset # number of bytes before audio data begins - tag.bitdepth # bit depth for lossless audio - tag.bitrate # bitrate in kBits/s tag.comment # file comment as string - tag.composer # composer as string - tag.disc # disc number - tag.disc_total # the total number of discs - tag.duration # duration of the song in seconds - tag.filesize # file size in bytes + tag.composer # composer as string + tag.disc # disc number as integer + tag.disc_total # total number of discs as integer tag.genre # genre as string - tag.samplerate # samples per second - tag.title # title of the song - tag.track # track number as string - tag.track_total # total number of tracks as string + tag.title # title of the song as string + tag.track # track number as integer + tag.track_total # total number of tracks as integer tag.year # year or date as string ### Additional Metadata -For non-common fields and fields specific to single file formats, use `extra`: +For additional values of the same field type, non-common metadata fields, or +metadata specific to certain file formats, use `other`: + + tag.other # a dictionary of additional fields + +> [!WARNING] +> The `other` dictionary has replaced the `extra` dictionary in tinytag 2.0.0. +> The latter will be removed in a future release. + +The following `other` field names are standardized in tinytag, and optionally +present when files provide such metadata: + + barcode + bpm + catalog_number + conductor + copyright + director + encoded_by + encoder_settings + initial_key + isrc + language + license + lyricist + lyrics + media + publisher + set_subtitle + url + +Additional `other` field names not documented above may be present, but are +format-specific and may change or disappear in future tinytag releases. If +tinytag does not expose metadata you need, or you wish to standardize more +field names, open a feature request on GitHub for discussion. + +`other` values are always provided as strings, and are not guaranteed to be +valid. Should e.g. the `bpm` value in the file contain non-numeric characters, +tinytag will provide the string as-is. It is your responsibility to handle +possible exceptions, e.g. when converting the value to an integer. + +Multiple values of the same field type are provided if a file contains them. +Values are always provided as a list, even when only a single value exists. + +Example: + +```python +from tinytag import OtherFields, TinyTag + +tag: TinyTag = TinyTag.get('/some/music.mp3') +other_fields: OtherFields = tag.other +catalog_numbers: list[str] | None = other_fields.get('catalog_number') + +if catalog_numbers: + catalog_number: str = catalog_numbers[0] + print(catalog_number) + +print(catalog_numbers) +``` + +Output: + + > 10 + > ['10'] + +When a file contains multiple values for a [common metadata field](#common-metadata) +(e.g. `artist`), the primary value is accessed through the common attribute +(`tag.artist`), and any additional values through the `other` dictionary +(`tag.other['artist']`). + +Example: + +```python +from tinytag import TinyTag + +tag: TinyTag = TinyTag.get('/some/music.mp3') +artist: str | None = tag.artist +additional_artists: list[str] | None = tag.other.get('artist') + +print(artist) +print(additional_artists) +``` + +Output: - tag.extra # a dict of additional data + > main artist + > ['another artist', 'yet another artist'] -The `extra` dict currently *may* contain the following data: - `url`, `isrc`, `text`, `initial_key`, `lyrics`, `copyright` +### All Metadata + +If you need to receive all available metadata as key-value pairs in a flat +dictionary, use the `as_dict()` method. This combines the common attributes +and `other` dictionary, which can be more convenient in some cases. + + from tinytag import TinyTag + + tag: TinyTag = TinyTag.get('/some/music.mp3') + metadata: dict = tag.as_dict() ### Images -Additionally you can also get cover images from tags: +Additionally, you can also read embedded images by passing a `image=True` +keyword argument to `TinyTag.get()`. + +If you need to receive an image of a specific kind, including its description, +use `images`: + + tag.images # available embedded images + +The following common image attributes are available, providing the first +located image of each kind: + + tag.images.front_cover # front cover as 'Image' object + tag.images.back_cover # back cover as 'Image' object + tag.images.media # media (e.g. CD label) as 'Image' object + +When present, any additional images are available in an `images.other` +dictionary, using the following standardized key names: + + generic + icon + alt_icon + front_cover + back_cover + media + leaflet + lead_artist + artist + conductor + band + composer + lyricist + recording_location + during_recording + during_performance + screen_capture + bright_colored_fish + illustration + band_logo + publisher_logo + unknown + +Provided values are always lists containing at least one `Image` object. + +The `Image` object provides the following attributes: + + data # image data as bytes + name # image name/kind as string + mime_type # image MIME type as string + description # image description as string + +To receive any available image, prioritizing the front cover, use `images.any`: + +```python +from tinytag import Image, TinyTag + +tag: TinyTag = TinyTag.get('/some/music.ogg', image=True) +image: Image | None = tag.images.any + +if image is not None: + data: bytes = image.data + name: str = image.name + mime_type: str = image.mime_type + description: str = image.description + + print(len(data)) + print(name) + print(mime_type) + print(description) +``` + +Output: - tag = TinyTag.get('/some/music.mp3', image=True) - image_data = tag.get_image() + > 74452 + > front_cover + > image/jpeg + > some image description + +> [!WARNING] +> `tag.images.any` has replaced `tag.get_image()` in tinytag 2.0.0. +> `tag.get_image()` will be removed in a future tinytag 2.x release. + +To receive a common image, e.g. `front_cover`: + +```python +from tinytag import Image, Images, TinyTag + +tag: TinyTag = TinyTag.get('/some/music.ogg', image=True) +images: Images = tag.images +cover_image: Image = images.front_cover + +if cover_image is not None: + data: bytes = cover_image.data + description: str = cover_image.description +``` + +To receive an additional image, e.g. `bright_colored_fish`: + +```python +from tinytag import Image, OtherImages, TinyTag + +tag: TinyTag = TinyTag.get('/some/music.ogg', image=True) +other_images: OtherImages = tag.images.other +fish_images: list[Image] | None = other_images.get('bright_colored_fish') + +if fish_images: + image = fish_images[0] # Use first image + data = image.data + description = image.description +``` ### Encoding @@ -119,14 +364,24 @@ To open files using a specific encoding, you can use the `encoding` parameter. This parameter is however only used for formats where the encoding is not explicitly specified. - TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') +```python +TinyTag.get('a_file_with_gbk_encoding.mp3', encoding='gbk') +``` ### File-like Objects To use a file-like object (e.g. BytesIO) instead of a file path, pass a `file_obj` keyword argument: - TinyTag.get(file_obj=your_file_obj) +```python +TinyTag.get(file_obj=your_file_obj) +``` + +### Exceptions + + TinyTagException # Base class for exceptions + ParseError # Parsing an audio file failed + UnsupportedFormatError # File format is not supported ## Changelog From 32bf05a3048effe17523dd4caa7c689b7c18cbce Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 17:01:43 +0200 Subject: [PATCH 277/305] init.py: consistent module docstring --- tinytag/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/__init__.py b/tinytag/__init__.py index ab10bac..2cfb27b 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2014-2024 tinytag Contributors # SPDX-License-Identifier: MIT -"""Audio file metadata reader""" +"""Audio file metadata reader.""" from .tinytag import ( TinyTag, Image, Images, OtherFields, OtherImages, From cc786892205f321a139412f830d714432b830f2f Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 17:18:52 +0200 Subject: [PATCH 278/305] Release version 2.0.0 (#233) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f5216b..17838b6 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ TinyTag.get(file_obj=your_file_obj) ## Changelog -### 2.0.0 (Unreleased) +### 2.0.0 (2024-11-03) - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str - **BREAKING:** 'as_dict()' method (previously undocumented) returns tag field values in list form From 79389f3cd80e1d27433d00c3cafaec492bb567c9 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 3 Nov 2024 17:40:33 +0200 Subject: [PATCH 279/305] pyproject.toml: remove author email Otherwise PyPI associates the email with my name. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a5f3f8b..aba3ff5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "tinytag" version = "2.0.0" description = "Read audio file metadata" authors = [ - {name = "Tom Wallroth", email = "tomwallroth@gmail.com"}, + {name = "Tom Wallroth"}, {name = "Mat (mathiascode)"} ] keywords = [ From 617b8329128a1eb513ae3c7e98828d0c8efbd7ff Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 5 Dec 2024 18:38:59 +0200 Subject: [PATCH 280/305] README.md: update outdated deprecation message --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 17838b6..f2d7c77 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ Output: > [!WARNING] > `tag.images.any` has replaced `tag.get_image()` in tinytag 2.0.0. -> `tag.get_image()` will be removed in a future tinytag 2.x release. +> `tag.get_image()` will be removed in the future. To receive a common image, e.g. `front_cover`: From 97061e5979d5232aee9a4f7c6d2116e1fd914c5c Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 16 Dec 2024 13:18:51 +0200 Subject: [PATCH 281/305] Add workaround for Pylint false positive in Python 3.13 --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e9901b3..ee5f5ce 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -36,7 +36,7 @@ # Lazy imports for type checking if False: # pylint: disable=using-constant-test - from collections.abc import Callable, Iterator + from collections.abc import Callable, Iterator # pylint: disable-all from typing import Any, BinaryIO, Dict, List _StringListDict = Dict[str, List[str]] From d60a6490a667f7a76145839e463a79f08cf636fb Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 16 Dec 2024 13:36:24 +0200 Subject: [PATCH 282/305] CI: add separate workflow for REUSE compliance (#235) --- .github/workflows/reuse.yml | 16 ++++++++++++++++ .github/workflows/tests.yml | 9 +-------- 2 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/reuse.yml diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml new file mode 100644 index 0000000..f730f3e --- /dev/null +++ b/.github/workflows/reuse.yml @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2024 tinytag Contributors +# SPDX-License-Identifier: MIT + +name: REUSE Compliance + +on: [push, pull_request] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: REUSE compliance + uses: fsfe/reuse-action@v5 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d5eef9..721a220 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,6 @@ name: Tests on: [push, pull_request] jobs: - tests: runs-on: ${{ matrix.os }} strategy: @@ -27,8 +26,6 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 @@ -37,7 +34,7 @@ jobs: cache: 'pip' - name: Install dependencies - run: python -m pip install build flit reuse .[tests] + run: python -m pip install build flit .[tests] - name: PEP 8 style checks run: python -m pycodestyle . @@ -59,10 +56,6 @@ jobs: - name: Build package without isolation run: python -m build --no-isolation - - name: REUSE compliance - if: matrix.python != '3.7' - run: python -m reuse lint - - name: Coveralls uses: coverallsapp/github-action@v2 with: From 9f9ad388ede12e589acc4934c0b417e41aa0cd2d Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 25 Jan 2025 22:05:55 +0200 Subject: [PATCH 283/305] Fix broken issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 5 ----- .github/ISSUE_TEMPLATE/bug_report.md.license | 2 ++ .github/ISSUE_TEMPLATE/feature_request.md | 5 ----- .github/ISSUE_TEMPLATE/feature_request.md.license | 2 ++ 4 files changed, 4 insertions(+), 10 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md.license create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md.license diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 81a04a0..5a64758 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,8 +1,3 @@ - - --- name: Bug report about: Something is broken or doesn't work as expected diff --git a/.github/ISSUE_TEMPLATE/bug_report.md.license b/.github/ISSUE_TEMPLATE/bug_report.md.license new file mode 100644 index 0000000..96be622 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021-2024 tinytag Contributors +SPDX-License-Identifier: MIT diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 9504c50..9390310 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,8 +1,3 @@ - - --- name: Feature request about: Suggest an idea for this project diff --git a/.github/ISSUE_TEMPLATE/feature_request.md.license b/.github/ISSUE_TEMPLATE/feature_request.md.license new file mode 100644 index 0000000..96be622 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021-2024 tinytag Contributors +SPDX-License-Identifier: MIT From f69cf173bbc8573a1873c8e3ca002f49d77b2c06 Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 25 Jan 2025 22:07:51 +0200 Subject: [PATCH 284/305] Disable blank issues --- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..62e5f9b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Other + url: https://github.com/tinytag/tinytag/discussions + about: Question, discussion or anything else From 2a8484f439b53dae6ffecc55d3d1e95e8cd5fe3f Mon Sep 17 00:00:00 2001 From: Mat Date: Sat, 25 Jan 2025 22:11:25 +0200 Subject: [PATCH 285/305] Bump copyright year --- .github/ISSUE_TEMPLATE/config.yml | 3 +++ LICENSE | 2 +- tinytag/tests/test_all.py | 2 +- tinytag/tinytag.py | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 62e5f9b..294d5f1 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2025 tinytag Contributors +# SPDX-License-Identifier: MIT + blank_issues_enabled: false contact_links: - name: Other diff --git a/LICENSE b/LICENSE index 443b21a..9781aad 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode), et al. +Copyright (c) 2014-2025 Tom Wallroth, Mat (mathiascode), et al. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 0916720..3102a59 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2019-2024 tinytag Contributors +# SPDX-FileCopyrightText: 2019-2025 tinytag Contributors # SPDX-License-Identifier: MIT # pylint: disable=missing-function-docstring,missing-module-docstring diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index ee5f5ce..876ea29 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2014-2024 tinytag Contributors +# SPDX-FileCopyrightText: 2014-2025 tinytag Contributors # SPDX-License-Identifier: MIT # tinytag - an audio file metadata reader From e6696d26f3ef754d4f0a1ec83d91dfd88d84f91a Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 26 Jan 2025 16:57:27 +0200 Subject: [PATCH 286/305] Opus: calculate audio bitrate (#238) And take pre-skip into account when calculating the duration. --- tinytag/tests/test_all.py | 6 +++-- tinytag/tinytag.py | 53 ++++++++++++++++++++++++++++++--------- 2 files changed, 45 insertions(+), 14 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 3102a59..ece3141 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -679,7 +679,8 @@ 'track': 1, 'disc': 1, 'title': 'Bad Apple!!', - 'duration': 2.0, + 'duration': 0.9935, + 'bitrate': 51.5832913940614, 'year': '2008.05.25', 'filesize': 10000, 'artist': 'nomico', @@ -695,7 +696,8 @@ 'filesize': 7251, 'channels': 1, 'samplerate': 48000, - 'duration': 5.0065, + 'duration': 5.0, + 'bitrate': 9.5952 }), ('test_flac.oga', { 'other': { diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 876ea29..1b0a1c0 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1314,14 +1314,21 @@ class _Ogg(TinyTag): def __init__(self) -> None: super().__init__() - self._max_samplenum = 0 # maximum sample position ever read + self._granule_pos = 0 + self._pre_skip = 0 # number of samples to skip in opus stream + self._audio_size: int | None = None # size of opus audio stream def _determine_duration(self, fh: BinaryIO) -> None: if not self._tags_parsed: self._parse_tag(fh) # determine sample rate if self.duration is not None or not self.samplerate: return # either ogg flac or invalid file - self.duration = self._max_samplenum / self.samplerate + self.duration = max( + (self._granule_pos - self._pre_skip) / self.samplerate, 0 + ) + if self._audio_size is None or not self.duration: + return # not an opus file + self.bitrate = self._audio_size * 8 / self.duration / 1000 def _parse_tag(self, fh: BinaryIO) -> None: check_flac_second_packet = False @@ -1341,15 +1348,17 @@ def _parse_tag(self, fh: BinaryIO) -> None: if self._parse_duration: # parse opus header # https://www.videolan.org/developers/vlc/modules/codec/opus_header.c # https://mf4.xiph.org/jenkins/view/opus/job/opusfile-unix/ws/doc/html/structOpusHead.html - version, ch = unpack("BB", packet[8:10]) + version, ch, pre_skip = unpack(" None: check_speex_second_packet = False else: # Optimization: If we need to determine the duration, read - # max_samplenum of remaining pages, but skip contents of + # granule_pos of remaining pages, but skip contents of # segments. If we don't need the duration, stop here. self._tags_parsed = True if not self._parse_duration: @@ -1444,6 +1453,9 @@ def _parse_vorbis_comment(self, def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: # for the spec, see: https://wiki.xiph.org/Ogg packet_data = bytearray() + current_serial = None + last_granule_pos = 0 + last_audio_size = 0 header_len = 27 page_header = fh.read(header_len) # read ogg page header while len(page_header) == header_len: @@ -1451,26 +1463,43 @@ def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: if page_header[:4] != b'OggS' or version != 0: raise ParseError('Invalid OGG header') # https://xiph.org/ogg/doc/framing.html - pos = unpack(' 0: + if eos: + self._granule_pos = granule_pos + else: + self._granule_pos = last_granule_pos + last_granule_pos = granule_pos segments = page_header[26] - # pylint: disable=consider-using-max-builtin - if pos > self._max_samplenum: - self._max_samplenum = pos seg_sizes = unpack('B' * segments, fh.read(segments)) read_size = 0 + audio_size = 0 for seg_size in seg_sizes: # read all segments read_size += seg_size + if self._audio_size is not None: + audio_size += seg_size # less than 255 bytes means end of packet - if seg_size < 255 and not self._tags_parsed: + if seg_size < 255 and serial_match and not self._tags_parsed: packet_data += fh.read(read_size) yield packet_data packet_data.clear() read_size = 0 if read_size: - if self._tags_parsed: + if not serial_match or self._tags_parsed: fh.seek(read_size, SEEK_CUR) else: # packet continues on next page packet_data += fh.read(read_size) + if serial_match and self._audio_size is not None: + if eos: + self._audio_size += last_audio_size + audio_size + else: + self._audio_size += last_audio_size + last_audio_size = audio_size page_header = fh.read(header_len) @@ -1635,7 +1664,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: self.duration = duration = tot_samples / sr self.samplerate = sr if duration > 0: - self.bitrate = self.filesize / duration * 8 / 1000 + self.bitrate = self.filesize * 8 / duration / 1000 elif block_type == self._VORBIS_COMMENT and self._parse_tags: # pylint: disable=protected-access walker = BytesIO(fh.read(size)) From 6fb2b3e118a901901ae5c5641d3c2e1f47677d11 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 23 Feb 2025 16:11:34 +0200 Subject: [PATCH 287/305] tinytag.py: bump copyright year --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 1b0a1c0..2a9cb92 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -6,7 +6,7 @@ # MIT License -# Copyright (c) 2014-2024 Tom Wallroth, Mat (mathiascode), et al. +# Copyright (c) 2014-2025 Tom Wallroth, Mat (mathiascode), et al. # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), From 75cc38b30a14008e2cebd9a0cad06d26df0fc726 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 23 Feb 2025 16:18:38 +0200 Subject: [PATCH 288/305] pyproject.toml: add Python 3.13 to list --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index aba3ff5..2d7ade9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", From 621152c6448d6ce9212d3724ddeb64bf4711230c Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 23 Feb 2025 16:27:56 +0200 Subject: [PATCH 289/305] tests.yml: add PyPy 3.11 (#240) --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 721a220..a5e5f6b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] python: [ '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', - 'pypy-3.8', 'pypy-3.9', 'pypy-3.10' + 'pypy-3.8', 'pypy-3.9', 'pypy-3.10', 'pypy-3.11' ] include: - os: ubuntu-22.04 From 52ae4d7df05cca42e1b56ac60f421d6da801a100 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 23 Feb 2025 16:38:54 +0200 Subject: [PATCH 290/305] Release version 2.1.0 (#239) --- README.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f2d7c77..9263b11 100644 --- a/README.md +++ b/README.md @@ -386,6 +386,11 @@ TinyTag.get(file_obj=your_file_obj) ## Changelog +### 2.1.0 (2025-02-23) + +- Opus: Calculate audio bitrate +- Opus: Take pre-skip into account when calculating the duration + ### 2.0.0 (2024-11-03) - **BREAKING:** Store 'disc', 'disc_total', 'track' and 'track_total' values as int instead of str diff --git a/pyproject.toml b/pyproject.toml index 2d7ade9..c2144fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "flit_core.buildapi" [project] name = "tinytag" -version = "2.0.0" +version = "2.1.0" description = "Read audio file metadata" authors = [ {name = "Tom Wallroth"}, From 4fc0068b0163935d8e85c77e39e1947c4048ab71 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 13 Mar 2025 17:10:48 +0200 Subject: [PATCH 291/305] README.md: info banner about only supporting tag reading --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9263b11..1eef5c4 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ python3 -m pip install tinytag * Pure Python, no dependencies * Supports Python 3.7 or higher +> [!IMPORTANT] +> Support for changing/writing metadata will not be added. Use another library +> such as [Mutagen](https://mutagen.readthedocs.io/) for this. + ## Usage @@ -94,9 +98,6 @@ Alternatively you can use tinytag directly on the command line: Check `python3 -m tinytag --help` for all CLI options, for example other output formats. -Support for changing/writing metadata will not be added. Use another library -such as [Mutagen](https://mutagen.readthedocs.io/) for this. - ### Supported Files To receive a tuple of file extensions tinytag supports, use the From 5731f32d8ed001df54d6b67fce0fb35f9a5e87e0 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 14 Mar 2025 19:21:57 +0200 Subject: [PATCH 292/305] ID3: Stop removing 'b' character from strings Fixes #242 Typo in 1f69743dd659bdde4c0839afd24cedec08160f6d --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 2a9cb92..2cbf7aa 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -326,7 +326,7 @@ def _update(self, other: TinyTag) -> None: @staticmethod def _unpad(s: str) -> str: # certain strings *may* be terminated with a zero byte at the end - return s.strip('b\x00') + return s.strip('\x00') def get_image(self) -> bytes | None: """Deprecated, use 'images.any' instead.""" From fcda1adc6625cd010302af2eceabd555b1a7b961 Mon Sep 17 00:00:00 2001 From: Mat Date: Sun, 6 Apr 2025 15:03:17 +0300 Subject: [PATCH 293/305] Use built-in unittest module instead of pytest (#243) There isn't a good enough reason to prefer a third-party library over the built-in unittest module in this case. Our tests are quite simple. --- .github/workflows/tests.yml | 2 +- pyproject.toml | 3 +- tinytag/tests/test_all.py | 633 ++++++++++++++++++------------------ tinytag/tests/test_cli.py | 225 ++++++------- 4 files changed, 431 insertions(+), 432 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a5e5f6b..9eccc4b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,7 +46,7 @@ jobs: run: python -m mypy -p tinytag - name: Unit tests - run: python -m coverage run -m pytest + run: python -m coverage run -m unittest env: TINYTAG_DEBUG: true diff --git a/pyproject.toml b/pyproject.toml index c2144fa..eecf190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,8 +59,7 @@ tests = [ "coverage", "mypy", "pycodestyle", - "pylint", - "pytest" + "pylint" ] [tool.flit.sdist] diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index ece3141..793d43f 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1,18 +1,20 @@ # SPDX-FileCopyrightText: 2019-2025 tinytag Contributors # SPDX-License-Identifier: MIT -# pylint: disable=missing-function-docstring,missing-module-docstring +# pylint: disable=missing-class-docstring,missing-function-docstring +# pylint: disable=missing-module-docstring,too-many-public-methods from __future__ import annotations import os.path -from io import BytesIO +from io import BytesIO, TextIOWrapper +from math import isclose from pathlib import Path from platform import python_implementation, system +from sys import stdout from typing import Any - -import pytest +from unittest import skipIf, TestCase from tinytag import TinyTag, TinyTagException from tinytag.tinytag import _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff @@ -1319,334 +1321,347 @@ SAMPLE_FOLDER = os.path.join(os.path.dirname(__file__), 'samples') -def compare_tag(results: dict[str, Any], - expected: dict[str, Any], - file: str, prev_path: str | None = None) -> None: - def compare_values(path: str, - result_val: str | float, - expected_val: str | float) -> bool: - # lets not copy *all* the lyrics inside the fixture - if (path == 'other.lyrics' - and isinstance(expected_val, list) - and isinstance(result_val, list)): - return result_val[0].startswith(expected_val[0]) - if isinstance(expected_val, float): - return result_val == pytest.approx(expected_val) - return result_val == expected_val - - def error_fmt(value: str | float) -> str: - return f'{repr(value)} ({type(value)})' - - assert isinstance(results, dict) - missing_keys = set(expected.keys()) - set(results) - assert not missing_keys, f'Missing data in fixture \n{missing_keys}' - - for key, result_val in results.items(): - path = prev_path + '.' + key if prev_path else key - expected_val = expected[key] - # recurse if the result and expected values are a dict: - if isinstance(result_val, dict) and isinstance(expected_val, dict): - compare_tag(result_val, expected_val, file, prev_path=key) - else: - fmt_string = 'field "%s": got %s expected %s in %s!' - fmt_values = (key, error_fmt(result_val), error_fmt(expected_val), - file) - assert compare_values(path, result_val, expected_val), \ - fmt_string % fmt_values - - -@pytest.mark.parametrize("testfile,expected", TEST_FILES.items()) -def test_file_reading_all(testfile: str, - expected: dict[str, dict[str, Any]]) -> None: - filename = os.path.join(SAMPLE_FOLDER, testfile) - tag = TinyTag.get(filename, tags=True, duration=True, image=True) - results = { - key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None - } - for attr_name in ('filename', 'images'): - del results[attr_name] - compare_tag(results, expected, filename) - - -@pytest.mark.parametrize("testfile,expected", TEST_FILES.items()) -def test_file_reading_tags(testfile: str, - expected: dict[str, dict[str, Any]]) -> None: - filename = os.path.join(SAMPLE_FOLDER, testfile) - excluded_attrs = { - 'bitdepth', 'bitrate', 'channels', 'duration', 'samplerate' - } - tag = TinyTag.get(filename, tags=True, duration=False) - results = { - key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None - } - for attr_name in ('filename', 'images'): - del results[attr_name] - expected = { - key: val for key, val in expected.items() if key not in excluded_attrs - } - compare_tag(results, expected, filename) - assert tag.images.any is None - - -@pytest.mark.parametrize("testfile,expected", TEST_FILES.items()) -def test_file_reading_duration(testfile: str, - expected: dict[str, dict[str, Any]]) -> None: - filename = os.path.join(SAMPLE_FOLDER, testfile) - allowed_attrs = { - 'bitdepth', 'bitrate', 'channels', 'duration', - 'filesize', 'samplerate'} - tag = TinyTag.get(filename, tags=False, duration=True) - results = { - key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None - } - for attr_name in ('filename', 'other', 'images'): - del results[attr_name] - expected = { - key: val for key, val in expected.items() if key in allowed_attrs - } - compare_tag(results, expected, filename) - assert tag.images.any is None - - -def test_pathlib_compatibility() -> None: - testfile = next(iter(TEST_FILES.keys())) - filename = Path(SAMPLE_FOLDER) / testfile - TinyTag.get(filename) - assert TinyTag.is_supported(filename) - - -def test_file_obj_compatibility() -> None: - testfile = next(iter(TEST_FILES.keys())) - filename = os.path.join(SAMPLE_FOLDER, testfile) - with open(filename, 'rb') as file_handle: - tag = TinyTag.get(file_obj=file_handle) - file_handle.seek(0) - tag_bytesio = TinyTag.get(file_obj=BytesIO(file_handle.read())) - assert tag.filesize == tag_bytesio.filesize - +class TestAll(TestCase): -@pytest.mark.skipif( - system() == 'Windows' and python_implementation() == 'PyPy', - reason='PyPy on Windows not supported' -) -def test_binary_path_compatibility() -> None: - binary_file_path = os.path.join( - SAMPLE_FOLDER, 'non_ascii_filename_äää.mp3').encode('utf-8') - tag = TinyTag.get(binary_file_path) - assert tag.samplerate == 44100 - assert tag.other['encoder_settings'] == ['Lavf58.20.100'] + @classmethod + def setUpClass(cls) -> None: + # Use utf-8 encoding for debug print() + if isinstance(stdout, TextIOWrapper): + stdout.reconfigure(encoding='utf-8') + def compare_tag(self, results: dict[str, Any], + expected: dict[str, Any], + file: str, prev_path: str | None = None) -> None: + def compare_values(path: str, + result_val: str | float, + expected_val: str | float) -> bool: + # lets not copy *all* the lyrics inside the fixture + if (path == 'other.lyrics' + and isinstance(expected_val, list) + and isinstance(result_val, list)): + return result_val[0].startswith(expected_val[0]) + if (isinstance(result_val, float) + and isinstance(expected_val, float)): + return isclose(result_val, expected_val) + return result_val == expected_val -def test_unsupported_extension() -> None: - bogus_file = os.path.join(SAMPLE_FOLDER, 'there_is_no_such_ext.bogus') - with pytest.raises(TinyTagException): - TinyTag.get(bogus_file) + def error_fmt(value: str | float) -> str: + return f'{repr(value)} ({type(value)})' + self.assertIsInstance(results, dict) + missing_keys = set(expected.keys()) - set(results) + self.assertFalse( + missing_keys, f'Missing data in fixture \n{missing_keys}') -def test_override_encoding() -> None: - chinese_id3 = os.path.join(SAMPLE_FOLDER, 'chinese_id3.mp3') - tag = TinyTag.get(chinese_id3, encoding='gbk') - assert tag.artist == '苏云' - assert tag.album == '角落之歌' + for key, result_val in results.items(): + path = prev_path + '.' + key if prev_path else key + expected_val = expected[key] + # recurse if the result and expected values are a dict: + if isinstance(result_val, dict) and isinstance(expected_val, dict): + self.compare_tag(result_val, expected_val, file, prev_path=key) + else: + fmt_string = 'field "%s": got %s expected %s in %s!' + fmt_values = ( + key, error_fmt(result_val), error_fmt(expected_val), file) + self.assertTrue( + compare_values(path, result_val, expected_val), + fmt_string % fmt_values) + def test_file_reading_all(self) -> None: + for testfile, expected in TEST_FILES.items(): + with self.subTest(testfile=testfile, expected=expected): + filename = os.path.join(SAMPLE_FOLDER, testfile) + tag = TinyTag.get( + filename, tags=True, duration=True, image=True) + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } + for attr_name in ('filename', 'images'): + del results[attr_name] + self.compare_tag(results, expected, filename) -def test_unsubclassed_tinytag_load() -> None: - # pylint: disable=protected-access - tag = TinyTag() - tag._load(tags=True, duration=True) - assert not tag._tags_parsed + def test_file_reading_tags(self) -> None: + for testfile, expected in TEST_FILES.items(): + with self.subTest(testfile=testfile, expected=expected): + filename = os.path.join(SAMPLE_FOLDER, testfile) + excluded_attrs = { + 'bitdepth', 'bitrate', 'channels', 'duration', 'samplerate' + } + tag = TinyTag.get(filename, tags=True, duration=False) + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } + for attr_name in ('filename', 'images'): + del results[attr_name] + filtered_expected = { + key: val for key, val in expected.items() + if key not in excluded_attrs + } + self.compare_tag(results, filtered_expected, filename) + assert tag.images.any is None + def test_file_reading_duration(self) -> None: + for testfile, expected in TEST_FILES.items(): + with self.subTest(testfile=testfile, expected=expected): + filename = os.path.join(SAMPLE_FOLDER, testfile) + allowed_attrs = { + 'bitdepth', 'bitrate', 'channels', 'duration', + 'filesize', 'samplerate'} + tag = TinyTag.get(filename, tags=False, duration=True) + results = { + key: val for key, val in tag.__dict__.items() + if not key.startswith('_') and val is not None + } + for attr_name in ('filename', 'other', 'images'): + del results[attr_name] + filtered_expected = { + key: val for key, val in expected.items() + if key in allowed_attrs + } + self.compare_tag(results, filtered_expected, filename) + assert tag.images.any is None -def test_unsubclassed_tinytag_duration() -> None: - # pylint: disable=protected-access - tag = TinyTag() - with pytest.raises(NotImplementedError): - tag._determine_duration(None) # type: ignore + def test_pathlib_compatibility(self) -> None: + testfile = next(iter(TEST_FILES.keys())) + filename = Path(SAMPLE_FOLDER) / testfile + TinyTag.get(filename) + self.assertTrue(TinyTag.is_supported(filename)) + def test_file_obj_compatibility(self) -> None: + testfile = next(iter(TEST_FILES.keys())) + filename = os.path.join(SAMPLE_FOLDER, testfile) + with open(filename, 'rb') as file_handle: + tag = TinyTag.get(file_obj=file_handle) + file_handle.seek(0) + tag_bytesio = TinyTag.get(file_obj=BytesIO(file_handle.read())) + self.assertEqual(tag.filesize, tag_bytesio.filesize) -def test_unsubclassed_tinytag_parse_tag() -> None: - # pylint: disable=protected-access - tag = TinyTag() - with pytest.raises(NotImplementedError): - tag._parse_tag(None) # type: ignore - - -def test_mp3_length_estimation() -> None: - # pylint: disable=protected-access - _ID3._MAX_ESTIMATION_SEC = 0.7 - tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, 'silence-44-s-v1.mp3')) - assert tag.duration is not None - assert 3.5 < tag.duration < 4.0 - + @skipIf( + system() == 'Windows' and python_implementation() == 'PyPy', + reason='PyPy on Windows not supported' + ) + def test_binary_path_compatibility(self) -> None: + binary_file_path = os.path.join( + SAMPLE_FOLDER, 'non_ascii_filename_äää.mp3').encode('utf-8') + tag = TinyTag.get(binary_file_path) + self.assertEqual(tag.samplerate, 44100) + self.assertEqual(tag.other['encoder_settings'], ['Lavf58.20.100']) -@pytest.mark.parametrize("path,cls", [ - ('silence-44-s-v1.mp3', _Flac), - ('flac1.5sStereo.flac', _Ogg), - ('flac1.5sStereo.flac', _Wave), - ('flac1.5sStereo.flac', _Wma), - ('ilbm.aiff', _Aiff), -]) -def test_invalid_file(path: str, cls: type[TinyTag]) -> None: - with pytest.raises(TinyTagException): - cls.get(os.path.join(SAMPLE_FOLDER, path)) + def test_unsupported_extension(self) -> None: + bogus_file = os.path.join(SAMPLE_FOLDER, 'there_is_no_such_ext.bogus') + with self.assertRaises(TinyTagException): + TinyTag.get(bogus_file) + def test_override_encoding(self) -> None: + chinese_id3 = os.path.join(SAMPLE_FOLDER, 'chinese_id3.mp3') + tag = TinyTag.get(chinese_id3, encoding='gbk') + self.assertEqual(tag.artist, '苏云') + self.assertEqual(tag.album, '角落之歌') -@pytest.mark.parametrize('path,expected_size,desc', [ - ('image-text-encoding.mp3', 5708, 'cover'), - ('id3v22_with_image.mp3', 1220, 'some image ë'), - ('mpeg4_with_image.m4a', 1220, None), - ('flac_with_image.flac', 1220, 'some image ë'), - ('wav_with_image.wav', 4627, 'some image ë'), - ('aiff_with_image.aiff', 1220, 'some image ë'), -]) -def test_image_loading(path: str, expected_size: int, desc: str) -> None: - tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, path), image=True) - image = tag.images.any - manual_image = tag.images.front_cover - if manual_image is None: - manual_image = tag.images.other['generic'][0] - assert image is not None - assert manual_image is not None - assert image.name in {'front_cover', 'generic'} - assert image.data is not None - assert image.data == manual_image.data - with pytest.warns(DeprecationWarning): - assert image.data == tag.get_image() - image_size = len(image.data) - assert image_size == expected_size, \ - f'Image is {image_size} bytes but should be {expected_size} bytes' - assert image.data.startswith(b'\xff\xd8\xff\xe0'), \ - 'The image data must start with a jpeg header' - assert image.mime_type == 'image/jpeg' - assert image.description == desc + def test_unsubclassed_tinytag_load(self) -> None: + # pylint: disable=protected-access + tag = TinyTag() + tag._load(tags=True, duration=True) + self.assertFalse(tag._tags_parsed) + def test_unsubclassed_tinytag_duration(self) -> None: + # pylint: disable=protected-access + tag = TinyTag() + with self.assertRaises(NotImplementedError): + tag._determine_duration(None) # type: ignore -def test_image_loading_other() -> None: - tag = TinyTag.get( - os.path.join(SAMPLE_FOLDER, 'ogg_with_image.ogg'), image=True) - image = tag.images.other['bright_colored_fish'][0] - assert image.data is not None - assert tag.images.any is not None - assert tag.images.any.data == image.data - with pytest.warns(DeprecationWarning): - assert image.data == tag.get_image() - assert image.mime_type == 'image/jpeg' - assert image.name == 'bright_colored_fish' - assert image.description == 'some image ë' - assert len(image.data) == 1220 - assert str(image) == ( - "Image(name='bright_colored_fish', data=b'\\xff\\xd8\\xff\\xe0\\x00" - "\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02" - "\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "mime_type='image/jpeg', description='some image ë')" - ) + def test_unsubclassed_tinytag_parse_tag(self) -> None: + # pylint: disable=protected-access + tag = TinyTag() + with self.assertRaises(NotImplementedError): + tag._parse_tag(None) # type: ignore + def test_mp3_length_estimation(self) -> None: + # pylint: disable=protected-access + _ID3._MAX_ESTIMATION_SEC = 0.7 + tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, 'silence-44-s-v1.mp3')) + assert tag.duration is not None + self.assertGreater(tag.duration, 3.5) + self.assertLess(tag.duration, 4.0) -def test_mp3_utf_8_invalid_string() -> None: - tag = TinyTag.get( - os.path.join(SAMPLE_FOLDER, 'utf-8-id3v2-invalid-string.mp3')) - # the title used to be Gran dia, but I replaced the first byte with 0xFF, - # which should be ignored here - assert tag.title == '�ran día' + def test_invalid_file(self) -> None: + for path, cls in ( + ('silence-44-s-v1.mp3', _Flac), + ('flac1.5sStereo.flac', _Ogg), + ('flac1.5sStereo.flac', _Wave), + ('flac1.5sStereo.flac', _Wma), + ('ilbm.aiff', _Aiff), + ): + with self.subTest(path=path, cls=cls): + with self.assertRaises(TinyTagException): + cls.get(os.path.join(SAMPLE_FOLDER, path)) + def test_image_loading(self) -> None: + for path, expected_size, desc in ( + ('image-text-encoding.mp3', 5708, 'cover'), + ('id3v22_with_image.mp3', 1220, 'some image ë'), + ('mpeg4_with_image.m4a', 1220, None), + ('flac_with_image.flac', 1220, 'some image ë'), + ('wav_with_image.wav', 4627, 'some image ë'), + ('aiff_with_image.aiff', 1220, 'some image ë'), + ): + with self.subTest(path=path, expected_size=expected_size, + desc=desc): + tag = TinyTag.get( + os.path.join(SAMPLE_FOLDER, path), image=True) + image = tag.images.any + manual_image = tag.images.front_cover + if manual_image is None: + manual_image = tag.images.other['generic'][0] + assert image is not None + assert manual_image is not None + self.assertIn(image.name, {'front_cover', 'generic'}) + assert image.data is not None + self.assertEqual(image.data, manual_image.data) + with self.assertWarns(DeprecationWarning): + self.assertEqual(image.data, tag.get_image()) + image_size = len(image.data) + self.assertEqual( + image_size, expected_size, + (f'Image is {image_size} bytes but should be ' + f'{expected_size} bytes') + ) + self.assertTrue( + image.data.startswith(b'\xff\xd8\xff\xe0'), + 'The image data must start with a jpeg header' + ) + self.assertEqual(image.mime_type, 'image/jpeg') + self.assertEqual(image.description, desc) -@pytest.mark.parametrize("testfile,expected", [ - ('detect_mp3_id3.x', _ID3), - ('detect_mp3_fffb.x', _ID3), - ('detect_ogg_flac.x', _Ogg), - ('detect_ogg_opus.x', _Ogg), - ('detect_ogg_vorbis.x', _Ogg), - ('detect_wav.x', _Wave), - ('detect_flac.x', _Flac), - ('detect_wma.x', _Wma), - ('detect_mp4_m4a.x', _MP4), - ('detect_aiff.x', _Aiff), -]) -def test_detect_magic_headers(testfile: str, expected: type[TinyTag]) -> None: - # pylint: disable=protected-access - filename = os.path.join(SAMPLE_FOLDER, testfile) - with open(filename, 'rb') as file_handle: - parser = TinyTag._get_parser_class(filename, file_handle) - assert parser == expected - + def test_image_loading_other(self) -> None: + tag = TinyTag.get( + os.path.join(SAMPLE_FOLDER, 'ogg_with_image.ogg'), image=True) + image = tag.images.other['bright_colored_fish'][0] + assert image.data is not None + assert tag.images.any is not None + self.assertEqual(tag.images.any.data, image.data) + with self.assertWarns(DeprecationWarning): + self.assertEqual(image.data, tag.get_image()) + self.assertEqual(image.mime_type, 'image/jpeg') + self.assertEqual(image.name, 'bright_colored_fish') + self.assertEqual(image.description, 'some image ë') + self.assertEqual(len(image.data), 1220) + self.assertEqual( + str(image), + "Image(name='bright_colored_fish', data=b'\\xff\\xd8\\xff\\xe0" + "\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" + "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02" + "\\xa0lcm..', mime_type='image/jpeg', description='some image ë')" + ) -def test_show_hint_for_wrong_usage() -> None: - with pytest.raises(ValueError) as exc: - TinyTag.get() - assert exc.type == ValueError - assert exc.value.args[0] == ('Either filename or file_obj argument ' - 'is required') + def test_mp3_utf_8_invalid_string(self) -> None: + tag = TinyTag.get( + os.path.join(SAMPLE_FOLDER, 'utf-8-id3v2-invalid-string.mp3')) + # the title used to be Gran dia, but I replaced the first byte with + # 0xFF, which should be ignored here + self.assertEqual(tag.title, '�ran día') + def test_detect_magic_headers(self) -> None: + # pylint: disable=protected-access + for testfile, expected in ( + ('detect_mp3_id3.x', _ID3), + ('detect_mp3_fffb.x', _ID3), + ('detect_ogg_flac.x', _Ogg), + ('detect_ogg_opus.x', _Ogg), + ('detect_ogg_vorbis.x', _Ogg), + ('detect_wav.x', _Wave), + ('detect_flac.x', _Flac), + ('detect_wma.x', _Wma), + ('detect_mp4_m4a.x', _MP4), + ('detect_aiff.x', _Aiff), + ): + with self.subTest(testfile=testfile, expected=expected): + filename = os.path.join(SAMPLE_FOLDER, testfile) + with open(filename, 'rb') as file_handle: + parser = TinyTag._get_parser_class(filename, file_handle) + self.assertEqual(parser, expected) -def test_deprecations() -> None: - file_path = os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac') - with pytest.warns(DeprecationWarning): - tag = TinyTag.get(filename=file_path, image=True, ignore_errors=True) - with pytest.warns(DeprecationWarning): - tag = TinyTag.get(filename=file_path, image=True, ignore_errors=False) - with pytest.warns(DeprecationWarning): - assert tag.audio_offset is None - with pytest.warns(DeprecationWarning): - assert str(tag.extra) == "{'url': 'https://example.com'}" - with pytest.warns(DeprecationWarning): - assert tag.images.any is not None - assert tag.get_image() == tag.images.any.data + def test_show_hint_for_wrong_usage(self) -> None: + with self.assertRaises(ValueError) as exc: + TinyTag.get() + self.assertEqual(type(exc.exception), ValueError) + self.assertEqual( + str(exc.exception), + 'Either filename or file_obj argument is required' + ) + def test_deprecations(self) -> None: + file_path = os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac') + with self.assertWarns(DeprecationWarning): + tag = TinyTag.get( + filename=file_path, image=True, ignore_errors=True) + with self.assertWarns(DeprecationWarning): + tag = TinyTag.get( + filename=file_path, image=True, ignore_errors=False) + with self.assertWarns(DeprecationWarning): + assert tag.audio_offset is None + with self.assertWarns(DeprecationWarning): + self.assertEqual(str(tag.extra), "{'url': 'https://example.com'}") + with self.assertWarns(DeprecationWarning): + assert tag.images.any is not None + self.assertEqual(tag.get_image(), tag.images.any.data) -def test_str_vars() -> None: - tag = TinyTag.get( - os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) - vars_str = str(vars(tag)) - assert ( - "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " - "'channels': 1, 'bitrate': 225.92, " - "'bitdepth': 16, 'samplerate': 44100, 'artist': 'artist 1', " - "'albumartist': None, 'composer': None, 'album': 'album 1', " - "'disc': None, 'disc_total': None, 'title': None, 'track': None, " - "'track_total': None, 'genre': 'genre 1', 'year': None, " - "'comment': None, 'images': None: + tag = TinyTag.get( + os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) + vars_str = str(vars(tag)) + self.assertIn( + "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " + "'channels': 1, 'bitrate': 225.92, " + "'bitdepth': 16, 'samplerate': 44100, 'artist': 'artist 1', " + "'albumartist': None, 'composer': None, 'album': 'album 1', " + "'disc': None, 'disc_total': None, 'title': None, 'track': None, " + "'track_total': None, 'genre': 'genre 1', 'year': None, " + "'comment': None, 'images': None: - tag = TinyTag.get( - os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) - assert str(tag.as_dict()).endswith( - "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " - "'channels': 1, 'bitrate': 225.92, " - "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " - "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], 'genre': " - "['genre 1', 'genre 2'], 'url': ['https://example.com']}" - ) - assert str(tag.images.as_dict()) == ( - "{'front_cover': [Image(name='front_cover', data=b'\\xff\\xd8\\xff" - "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00\\xff" - "\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02\\xa0lcm..', " - "mime_type='image/jpeg', description='some image ë')], " - "'bright_colored_fish': [Image(name='bright_colored_fish', " - "data=b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" - "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01" - "\\x00\\x00\\x02\\xa0lcm..', mime_type='image/jpeg', " - "description='some image ë')]}" - ) + def test_str_flat_dict(self) -> None: + tag = TinyTag.get( + os.path.join(SAMPLE_FOLDER, 'flac_with_image.flac'), image=True) + self.assertTrue(str(tag.as_dict()).endswith( + "flac_with_image.flac', 'filesize': 2824, 'duration': 0.1, " + "'channels': 1, 'bitrate': 225.92, " + "'bitdepth': 16, 'samplerate': 44100, 'artist': ['artist 1', " + "'artist 2', 'artist 3'], 'album': ['album 1', 'album 2'], " + "'genre': ['genre 1', 'genre 2'], 'url': ['https://example.com']}" + )) + self.assertEqual( + str(tag.images.as_dict()), + "{'front_cover': [Image(name='front_cover', data=b'\\xff\\xd8\\xff" + "\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01\\x00H\\x00H\\x00\\x00" + "\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01\\x01\\x00\\x00\\x02" + "\\xa0lcm..', mime_type='image/jpeg', description='some image ë')]" + ", 'bright_colored_fish': [Image(name='bright_colored_fish', " + "data=b'\\xff\\xd8\\xff\\xe0\\x00\\x10JFIF\\x00\\x01\\x01\\x01" + "\\x00H\\x00H\\x00\\x00\\xff\\xe2\\x02\\xb0ICC_PROFILE\\x00\\x01" + "\\x01\\x00\\x00\\x02\\xa0lcm..', mime_type='image/jpeg', " + "description='some image ë')]}" + ) diff --git a/tinytag/tests/test_cli.py b/tinytag/tests/test_cli.py index 77276ba..b8cce31 100644 --- a/tinytag/tests/test_cli.py +++ b/tinytag/tests/test_cli.py @@ -1,24 +1,22 @@ # SPDX-FileCopyrightText: 2020-2024 tinytag Contributors # SPDX-License-Identifier: MIT -# pylint: disable=missing-function-docstring,missing-module-docstring +# pylint: disable=missing-class-docstring,missing-function-docstring +# pylint: disable=missing-module-docstring import json import os -import sys from subprocess import check_output, CalledProcessError, STDOUT +from sys import executable from tempfile import NamedTemporaryFile +from unittest import TestCase -import pytest - -project_folder = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) -sample_folder = os.path.join(project_folder, 'tinytag', 'tests', 'samples') -mp3_with_img = os.path.join(sample_folder, 'image-text-encoding.mp3') -bogus_file = os.path.join(sample_folder, 'there_is_no_such_ext.bogus') -assert os.path.exists(mp3_with_img) - -tinytag_attributes = { +PROJECT_FOLDER = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +SAMPLE_FOLDER = os.path.join(PROJECT_FOLDER, 'tinytag', 'tests', 'samples') +MP3_WITH_IMG = os.path.join(SAMPLE_FOLDER, 'image-text-encoding.mp3') +BOGUS_FILE = os.path.join(SAMPLE_FOLDER, 'there_is_no_such_ext.bogus') +TINYTAG_ATTRIBUTES = { 'album', 'albumartist', 'artist', 'bitdepth', 'bitrate', 'channels', 'comment', 'composer', 'disc', 'disc_total', 'duration', 'filesize', 'filename', 'genre', 'samplerate', 'title', 'track', @@ -26,112 +24,99 @@ } -def run_cli(args: str) -> str: - debug_env = str(os.environ.pop("TINYTAG_DEBUG", None)) - output = check_output( - f'{sys.executable} -m tinytag ' + args, cwd=project_folder, - shell=True, stderr=STDOUT) - if debug_env: - os.environ["TINYTAG_DEBUG"] = debug_env - return output.decode('utf-8') - - -def file_size(filename: str) -> int: - return os.stat(filename).st_size - - -def test_wrong_params() -> None: - with pytest.raises(CalledProcessError) as excinfo: - run_cli('-lol') - output = excinfo.value.stdout.strip() - assert output == b"-lol: [Errno 2] No such file or directory: '-lol'" - - -def test_print_help() -> None: - assert 'tinytag [options] None: - with NamedTemporaryFile() as temp_file: - assert file_size(temp_file.name) == 0 - run_cli(f'--save-image {temp_file.name} {mp3_with_img}') - assert file_size(temp_file.name) > 0 - with open(temp_file.name, 'rb') as file_handle: - image_data = file_handle.read(20) - assert image_data.startswith(b'\xff') - assert b'JFIF' in image_data - - -def test_save_image_short_opt() -> None: - with NamedTemporaryFile() as temp_file: - assert file_size(temp_file.name) == 0 - run_cli(f'-i {temp_file.name} {mp3_with_img}') - assert file_size(temp_file.name) > 0 - - -def test_save_image_bulk() -> None: - temp_name = None - with NamedTemporaryFile(suffix='.jpg') as temp_file: - temp_name = temp_file.name - temp_name_no_ext = temp_name[:-4] - assert file_size(temp_name) == 0 - run_cli(f'-i {temp_name} {mp3_with_img} {mp3_with_img} {mp3_with_img}') - assert not os.path.isfile(temp_name) - assert file_size(temp_name_no_ext + '00000.jpg') > 0 - assert file_size(temp_name_no_ext + '00001.jpg') > 0 - assert file_size(temp_name_no_ext + '00002.jpg') > 0 - - -def test_meta_data_output_default_json() -> None: - output = run_cli(mp3_with_img) - data = json.loads(output) - assert data - assert set(data.keys()).issubset(tinytag_attributes) - - -def test_meta_data_output_format_json() -> None: - output = run_cli('-f json ' + mp3_with_img) - data = json.loads(output) - assert data - assert set(data.keys()).issubset(tinytag_attributes) - - -def test_meta_data_output_format_csv() -> None: - output = run_cli('-f csv ' + mp3_with_img) - lines = [line for line in output.split(os.linesep) if line] - assert all(',' in line for line in lines) - attributes = set(line.split(',')[0] for line in lines) - assert set(attributes).issubset(tinytag_attributes) - - -def test_meta_data_output_format_tsv() -> None: - output = run_cli('-f tsv ' + mp3_with_img) - lines = [line for line in output.split(os.linesep) if line] - assert all('\t' in line for line in lines) - attributes = set(line.split('\t')[0] for line in lines) - assert set(attributes).issubset(tinytag_attributes) - - -def test_meta_data_output_format_tabularcsv() -> None: - output = run_cli('-f tabularcsv ' + mp3_with_img) - header, _line, _rest = output.split(os.linesep) - assert set(header.split(',')).issubset(tinytag_attributes) - - -def test_meta_data_output_format_invalid() -> None: - output = run_cli('-f invalid ' + mp3_with_img) - assert not output - - -def test_fail_on_unsupported_file() -> None: - with pytest.raises(CalledProcessError): - run_cli(bogus_file) - - -def test_fail_skip_unsupported_file_long_opt() -> None: - run_cli('--skip-unsupported ' + bogus_file) - - -def test_fail_skip_unsupported_file_short_opt() -> None: - run_cli('-s ' + bogus_file) +class TestCLI(TestCase): + + @staticmethod + def run_cli(args: str) -> str: + debug_env = str(os.environ.pop("TINYTAG_DEBUG", None)) + output = check_output( + f'{executable} -m tinytag ' + args, cwd=PROJECT_FOLDER, + shell=True, stderr=STDOUT) + if debug_env: + os.environ["TINYTAG_DEBUG"] = debug_env + return output.decode('utf-8') + + def test_wrong_params(self) -> None: + with self.assertRaises(CalledProcessError) as excinfo: + self.run_cli('-lol') + output = excinfo.exception.stdout.strip() + self.assertEqual( + output, b"-lol: [Errno 2] No such file or directory: '-lol'") + + def test_print_help(self) -> None: + self.assertIn('tinytag [options] None: + with NamedTemporaryFile() as temp_file: + self.assertEqual(os.path.getsize(temp_file.name), 0) + self.run_cli(f'--save-image {temp_file.name} {MP3_WITH_IMG}') + self.assertGreater(os.path.getsize(temp_file.name), 0) + with open(temp_file.name, 'rb') as file_handle: + image_data = file_handle.read(20) + self.assertTrue(image_data.startswith(b'\xff')) + self.assertIn(b'JFIF', image_data) + + def test_save_image_short_opt(self) -> None: + with NamedTemporaryFile() as temp_file: + self.assertEqual(os.path.getsize(temp_file.name), 0) + self.run_cli(f'-i {temp_file.name} {MP3_WITH_IMG}') + self.assertGreater(os.path.getsize(temp_file.name), 0) + + def test_save_image_bulk(self) -> None: + temp_name = None + with NamedTemporaryFile(suffix='.jpg') as temp_file: + temp_name = temp_file.name + temp_name_no_ext = temp_name[:-4] + self.assertEqual(os.path.getsize(temp_name), 0) + self.run_cli( + f'-i {temp_name} {MP3_WITH_IMG} {MP3_WITH_IMG} {MP3_WITH_IMG}') + self.assertFalse(os.path.isfile(temp_name)) + self.assertGreater(os.path.getsize(temp_name_no_ext + '00000.jpg'), 0) + self.assertGreater(os.path.getsize(temp_name_no_ext + '00001.jpg'), 0) + self.assertGreater(os.path.getsize(temp_name_no_ext + '00002.jpg'), 0) + + def test_meta_data_output_default_json(self) -> None: + output = self.run_cli(MP3_WITH_IMG) + data = json.loads(output) + self.assertTrue(data) + self.assertTrue(set(data.keys()).issubset(TINYTAG_ATTRIBUTES)) + + def test_meta_data_output_format_json(self) -> None: + output = self.run_cli('-f json ' + MP3_WITH_IMG) + data = json.loads(output) + self.assertTrue(data) + self.assertTrue(set(data.keys()).issubset(TINYTAG_ATTRIBUTES)) + + def test_meta_data_output_format_csv(self) -> None: + output = self.run_cli('-f csv ' + MP3_WITH_IMG) + lines = [line for line in output.split(os.linesep) if line] + self.assertTrue(all(',' in line for line in lines)) + attributes = set(line.split(',')[0] for line in lines) + self.assertTrue(set(attributes).issubset(TINYTAG_ATTRIBUTES)) + + def test_meta_data_output_format_tsv(self) -> None: + output = self.run_cli('-f tsv ' + MP3_WITH_IMG) + lines = [line for line in output.split(os.linesep) if line] + self.assertTrue(all('\t' in line for line in lines)) + attributes = set(line.split('\t')[0] for line in lines) + self.assertTrue(set(attributes).issubset(TINYTAG_ATTRIBUTES)) + + def test_meta_data_output_format_tabularcsv(self) -> None: + output = self.run_cli('-f tabularcsv ' + MP3_WITH_IMG) + header, _line, _rest = output.split(os.linesep) + self.assertTrue(set(header.split(',')).issubset(TINYTAG_ATTRIBUTES)) + + def test_meta_data_output_format_invalid(self) -> None: + output = self.run_cli('-f invalid ' + MP3_WITH_IMG) + self.assertFalse(output) + + def test_fail_on_unsupported_file(self) -> None: + with self.assertRaises(CalledProcessError): + self.run_cli(BOGUS_FILE) + + def test_fail_skip_unsupported_file_long_opt(self) -> None: + self.run_cli('--skip-unsupported ' + BOGUS_FILE) + + def test_fail_skip_unsupported_file_short_opt(self) -> None: + self.run_cli('-s ' + BOGUS_FILE) From 57335010b3bfdc4206b272add386991bd63647f3 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 22 Apr 2025 00:11:13 +0300 Subject: [PATCH 294/305] test_all.py: more strict exception tests --- tinytag/tests/test_all.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 793d43f..486e089 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -16,7 +16,8 @@ from typing import Any from unittest import skipIf, TestCase -from tinytag import TinyTag, TinyTagException +from tinytag import ParseError, TinyTagException, UnsupportedFormatError +from tinytag import TinyTag from tinytag.tinytag import _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff @@ -1451,8 +1452,9 @@ def test_binary_path_compatibility(self) -> None: def test_unsupported_extension(self) -> None: bogus_file = os.path.join(SAMPLE_FOLDER, 'there_is_no_such_ext.bogus') - with self.assertRaises(TinyTagException): + with self.assertRaises(UnsupportedFormatError) as context: TinyTag.get(bogus_file) + self.assertIsInstance(context.exception, TinyTagException) def test_override_encoding(self) -> None: chinese_id3 = os.path.join(SAMPLE_FOLDER, 'chinese_id3.mp3') @@ -1495,8 +1497,9 @@ def test_invalid_file(self) -> None: ('ilbm.aiff', _Aiff), ): with self.subTest(path=path, cls=cls): - with self.assertRaises(TinyTagException): + with self.assertRaises(ParseError) as context: cls.get(os.path.join(SAMPLE_FOLDER, path)) + self.assertIsInstance(context.exception, TinyTagException) def test_image_loading(self) -> None: for path, expected_size, desc in ( @@ -1584,11 +1587,11 @@ def test_detect_magic_headers(self) -> None: self.assertEqual(parser, expected) def test_show_hint_for_wrong_usage(self) -> None: - with self.assertRaises(ValueError) as exc: + with self.assertRaises(ValueError) as context: TinyTag.get() - self.assertEqual(type(exc.exception), ValueError) + self.assertIsInstance(context.exception, ValueError) self.assertEqual( - str(exc.exception), + str(context.exception), 'Either filename or file_obj argument is required' ) From 97045acc1cc4e7632e630bb2f628a3fedaf65522 Mon Sep 17 00:00:00 2001 From: Mat Date: Tue, 22 Apr 2025 16:24:13 +0300 Subject: [PATCH 295/305] test_all.py: avoid accessing private methods when possible (#244) --- tinytag/tests/test_all.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 486e089..610463e 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -2,7 +2,8 @@ # SPDX-License-Identifier: MIT # pylint: disable=missing-class-docstring,missing-function-docstring -# pylint: disable=missing-module-docstring,too-many-public-methods +# pylint: disable=missing-module-docstring,protected-access +# pylint: disable=too-many-public-methods from __future__ import annotations @@ -1463,31 +1464,20 @@ def test_override_encoding(self) -> None: self.assertEqual(tag.album, '角落之歌') def test_unsubclassed_tinytag_load(self) -> None: - # pylint: disable=protected-access tag = TinyTag() tag._load(tags=True, duration=True) self.assertFalse(tag._tags_parsed) def test_unsubclassed_tinytag_duration(self) -> None: - # pylint: disable=protected-access tag = TinyTag() with self.assertRaises(NotImplementedError): tag._determine_duration(None) # type: ignore def test_unsubclassed_tinytag_parse_tag(self) -> None: - # pylint: disable=protected-access tag = TinyTag() with self.assertRaises(NotImplementedError): tag._parse_tag(None) # type: ignore - def test_mp3_length_estimation(self) -> None: - # pylint: disable=protected-access - _ID3._MAX_ESTIMATION_SEC = 0.7 - tag = TinyTag.get(os.path.join(SAMPLE_FOLDER, 'silence-44-s-v1.mp3')) - assert tag.duration is not None - self.assertGreater(tag.duration, 3.5) - self.assertLess(tag.duration, 4.0) - def test_invalid_file(self) -> None: for path, cls in ( ('silence-44-s-v1.mp3', _Flac), @@ -1567,7 +1557,6 @@ def test_mp3_utf_8_invalid_string(self) -> None: self.assertEqual(tag.title, '�ran día') def test_detect_magic_headers(self) -> None: - # pylint: disable=protected-access for testfile, expected in ( ('detect_mp3_id3.x', _ID3), ('detect_mp3_fffb.x', _ID3), @@ -1582,9 +1571,8 @@ def test_detect_magic_headers(self) -> None: ): with self.subTest(testfile=testfile, expected=expected): filename = os.path.join(SAMPLE_FOLDER, testfile) - with open(filename, 'rb') as file_handle: - parser = TinyTag._get_parser_class(filename, file_handle) - self.assertEqual(parser, expected) + tag = TinyTag.get(filename) + self.assertIsInstance(tag, expected) def test_show_hint_for_wrong_usage(self) -> None: with self.assertRaises(ValueError) as context: From f76634ede30198e1154eb1832ef0484ec74e261e Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 23 Apr 2025 17:20:32 +0300 Subject: [PATCH 296/305] Release 2.1.1 (#245) --- README.md | 5 +++++ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1eef5c4..b07fd32 100644 --- a/README.md +++ b/README.md @@ -387,6 +387,11 @@ TinyTag.get(file_obj=your_file_obj) ## Changelog +### 2.1.1 (2025-04-23) + +- ID3: Stop removing 'b' character from strings +- Port unit tests from pytest to built-in unittest module + ### 2.1.0 (2025-02-23) - Opus: Calculate audio bitrate diff --git a/pyproject.toml b/pyproject.toml index eecf190..57f955c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "flit_core.buildapi" [project] name = "tinytag" -version = "2.1.0" +version = "2.1.1" description = "Read audio file metadata" authors = [ {name = "Tom Wallroth"}, From f485fe0049a9ef9cb481ed042ef39ee2e12955e0 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 5 May 2025 08:09:19 +0300 Subject: [PATCH 297/305] CI: disable permissions for workflows by default --- .github/workflows/reuse.yml | 4 ++-- .github/workflows/tests.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/reuse.yml b/.github/workflows/reuse.yml index f730f3e..ec2ee03 100644 --- a/.github/workflows/reuse.yml +++ b/.github/workflows/reuse.yml @@ -1,9 +1,9 @@ -# SPDX-FileCopyrightText: 2024 tinytag Contributors +# SPDX-FileCopyrightText: 2024-2025 tinytag Contributors # SPDX-License-Identifier: MIT name: REUSE Compliance - on: [push, pull_request] +permissions: {} jobs: check: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9eccc4b..08959b5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,9 +1,9 @@ -# SPDX-FileCopyrightText: 2022-2024 tinytag Contributors +# SPDX-FileCopyrightText: 2022-2025 tinytag Contributors # SPDX-License-Identifier: MIT name: Tests - on: [push, pull_request] +permissions: {} jobs: tests: From f396b74d001b364f49bf51295ff9f078cdbd0262 Mon Sep 17 00:00:00 2001 From: Mat Date: Mon, 5 May 2025 08:20:35 +0300 Subject: [PATCH 298/305] CI: test Python 3.14-dev --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 08959b5..8a88c91 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] python: [ - '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', + '3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14-dev', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10', 'pypy-3.11' ] include: From 89212fd92a5f64fa7f7b240c2355431067ef8853 Mon Sep 17 00:00:00 2001 From: Mat Date: Wed, 4 Jun 2025 18:34:51 +0300 Subject: [PATCH 299/305] tinytag.py: fix incorrect return type --- tinytag/tinytag.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index 2cbf7aa..af6ee94 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -1450,7 +1450,7 @@ def _parse_vorbis_comment(self, elif value: self._set_field(fieldname, value) - def _parse_pages(self, fh: BinaryIO) -> Iterator[bytes]: + def _parse_pages(self, fh: BinaryIO) -> Iterator[bytearray]: # for the spec, see: https://wiki.xiph.org/Ogg packet_data = bytearray() current_serial = None From e3fc691515e2eeea5fcd89e63c0ee258b2f38f38 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 5 Jun 2025 02:33:55 +0300 Subject: [PATCH 300/305] Improve a few incomplete type hints (#246) Fixes some errors reported by the Pyright type checker. --- pyproject.toml | 5 + tinytag/tests/test_all.py | 360 ++++++++++++++++++++------------------ tinytag/tinytag.py | 16 +- 3 files changed, 206 insertions(+), 175 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 57f955c..1555533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,3 +114,8 @@ py-version = "3.7" [tool.mypy] strict = true + +[tool.coverage.report] +exclude_lines = [ + "if TYPE_CHECKING:" +] diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index 610463e..0a5aab7 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -14,17 +14,24 @@ from pathlib import Path from platform import python_implementation, system from sys import stdout -from typing import Any from unittest import skipIf, TestCase from tinytag import ParseError, TinyTagException, UnsupportedFormatError -from tinytag import TinyTag +from tinytag import Images, OtherFields, TinyTag from tinytag.tinytag import _ID3, _Ogg, _Wave, _Flac, _Wma, _MP4, _Aiff +TYPE_CHECKING = False -TEST_FILES = dict([ +# Lazy imports for type checking +if TYPE_CHECKING: + from typing import Mapping, Union + ExpectedTag = Mapping[str, Union[str, float, OtherFields]] +else: + ExpectedTag = dict + +TEST_FILES: dict[str, ExpectedTag] = dict([ ('vbri.mp3', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'samplerate': 44100, 'duration': 0.47020408163265304, @@ -39,7 +46,7 @@ 'bitrate': 125.33333333333333, }), ('cbr.mp3', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'samplerate': 44100, 'duration': 0.48866995073891617, @@ -54,7 +61,7 @@ 'comment': 'Ripped by THSLIVE', }), ('vbr_xing_header.mp3', { - 'other': {}, + 'other': OtherFields(), 'bitrate': 186.04383278145696, 'channels': 1, 'samplerate': 44100, @@ -62,12 +69,12 @@ 'filesize': 91731, }), ('vbr_xing_header_2channel.mp3', { - 'other': { + 'other': OtherFields({ 'encoder_settings': [ 'LAME 32bits version 3.99.5 (http://lame.sf.net)' ], 'tlen': ['249976'] - }, + }), 'filesize': 2000, 'album': "The Harpers' Masque", 'artist': 'Knodel and Valencia', @@ -79,7 +86,7 @@ 'year': '1992', }), ('id3v22-test.mp3', { - 'other': { + 'other': OtherFields({ 'encoded_by': ['iTunes v4.6'], 'itunnorm': [ ' 0000044E 00000061 00009B67 000044C3 00022478 00022182' @@ -90,7 +97,7 @@ '113226+132452+146426+163829' ], 'itunes_cddb_tracknumber': ['3'], - }, + }), 'channels': 2, 'samplerate': 44100, 'track_total': 11, @@ -105,7 +112,7 @@ 'comment': 'Waterbug Records, www.anaismitchell.com', }), ('silence-44-s-v1.mp3', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'samplerate': 44100, 'genre': 'Darkwave', @@ -119,7 +126,7 @@ 'bitrate': 32.0, }), ('id3v1-latin1.mp3', { - 'other': {}, + 'other': OtherFields(), 'genre': 'Rock', 'album': 'The Young Americans', 'title': 'Play Dead', @@ -130,7 +137,7 @@ 'comment': ' ', }), ('UTF16.mp3', { - 'other': { + 'other': OtherFields({ 'musicbrainz artist id': ['664c3e0e-42d8-48c1-b209-1efca19c0325'], 'musicbrainz album id': ['25322466-a29b-417b-b560-399687b91ddd'], 'musicbrainz album artist id': [ @@ -156,7 +163,7 @@ 'encoder_settings': [ 'LAME 32bits version 3.98.4 (http://www.mp3dev.org/)' ], - }, + }), 'track_total': 11, 'track': 7, 'artist': 'The National', @@ -168,7 +175,7 @@ 'comment': 'Track 7', }), ('utf-8-id3v2.mp3', { - 'other': {}, + 'other': OtherFields(), 'genre': 'Acustico', 'track_total': 21, 'track': 1, @@ -180,15 +187,15 @@ 'year': '2003', }), ('empty_file.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 0 }), ('incomplete.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 3 }), ('silence-44khz-56k-mono-1s.mp3', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'samplerate': 44100, 'duration': 1.0265261269342902, @@ -196,7 +203,7 @@ 'bitrate': 56.0, }), ('silence-22khz-mono-1s.mp3', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'samplerate': 22050, 'filesize': 4284, @@ -204,12 +211,12 @@ 'duration': 1.0438932496075353, }), ('id3v24-long-title.mp3', { - 'other': { + 'other': OtherFields({ 'copyright': [ '2013 Marathon Artists under exclsuive license from ' 'Courtney Barnett' ] - }, + }), 'track': 1, 'disc_total': 1, 'composer': 'Courtney Barnett', @@ -225,7 +232,7 @@ 'year': '2013', }), ('utf16be.mp3', { - 'other': {}, + 'other': OtherFields(), 'title': '52-girls', 'filesize': 2048, 'track': 6, @@ -235,7 +242,7 @@ 'year': '1981', }), ('id3v22.TCO.genre.mp3', { - 'other': { + 'other': OtherFields({ 'encoded_by': ['iTunes 11.0.4'], 'itunnorm': [ ' 000019F0 00001E2A 00009F9A 0000C689 000312A1 00030C1A' @@ -247,7 +254,7 @@ ' 00000000' ], 'itunpgap': ['0'], - }, + }), 'filesize': 500, 'album': 'ARTPOP', 'artist': 'Lady GaGa', @@ -255,13 +262,13 @@ 'title': 'Applause', }), ('id3_comment_utf_16_with_bom.mp3', { - 'other': { + 'other': OtherFields({ 'copyright': ['(c) 2008 nin'], 'isrc': ['USTC40852229'], 'bpm': ['60'], 'url': ['www.nin.com'], 'encoded_by': ['LAME 3.97'], - }, + }), 'filesize': 19980, 'album': 'Ghosts I-IV', 'albumartist': 'Nine Inch Nails', @@ -275,9 +282,9 @@ 'comment': '3/4 time', }), ('id3_comment_utf_16_double_bom.mp3', { - 'other': { + 'other': OtherFields({ 'label': ['Unclear'] - }, + }), 'filesize': 512, 'album': 'The Embrace', 'artist': 'Johannes Heil & D.Diggler', @@ -286,7 +293,7 @@ 'year': '2012', }), ('id3_genre_id_out_of_bounds.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 512, 'album': 'MECHANICAL ANIMALS', 'artist': 'Manson', @@ -295,7 +302,7 @@ 'year': '0', }), ('image-text-encoding.mp3', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'samplerate': 22050, 'filesize': 11104, @@ -304,11 +311,11 @@ 'duration': 1.0438932496075353, }), ('id3v1_does_not_overwrite_id3v2.mp3', { - 'other': { + 'other': OtherFields({ 'love rating': ['L'], 'publisher': ['Century Media'], 'popm': ['MusicBee\x00Ä'] - }, + }), 'filesize': 1130, 'album': 'Somewhere Far Beyond', 'albumartist': 'Blind Guardian', @@ -319,9 +326,9 @@ 'year': '1992', }), ('non_ascii_filename_äää.mp3', { - 'other': { + 'other': OtherFields({ 'encoder_settings': ['Lavf58.20.100'] - }, + }), 'filesize': 80919, 'channels': 2, 'duration': 5.067755102040817, @@ -329,7 +336,7 @@ 'bitrate': 127.6701030927835, }), ('chinese_id3.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 1000, 'album': '½ÇÂäÖ®¸è', 'albumartist': 'ËÕÔÆ', @@ -343,9 +350,9 @@ 'track': 1, }), ('cut_off_titles.mp3', { - 'other': { + 'other': OtherFields({ 'encoder_settings': ['Lavf54.29.104'] - }, + }), 'filesize': 1000, 'album': 'ERB', 'artist': 'Epic Rap Battles Of History', @@ -356,7 +363,7 @@ 'title': 'Tony Hawk VS Wayne Gretzky', }), ('id3_xxx_lang.mp3', { - 'other': { + 'other': OtherFields({ 'script': ['Latn'], 'acoustid id': ['2dc0b571-a633-45b0-aa5e-f3d25e4e0020'], 'musicbrainz album type': ['album'], @@ -397,7 +404,7 @@ 'producer\x00Maynard James Keenan\x00' 'engineer\x00Billy Howerdel\x00engineer\x00Critter') ], - }, + }), 'filesize': 6943, 'album': 'eMOTIVe', 'albumartist': 'A Perfect Circle', @@ -421,7 +428,7 @@ 'bitrate': 8.25, 'channels': 1, 'duration': 9.216, - 'other': {}, + 'other': OtherFields(), 'samplerate': 8000, }), ('vbr8stereo.mp3', { @@ -429,7 +436,7 @@ 'bitrate': 8.25, 'channels': 2, 'duration': 9.216, - 'other': {}, + 'other': OtherFields(), 'samplerate': 8000, }), ('vbr11.mp3', { @@ -437,7 +444,7 @@ 'bitrate': 8.143465909090908, 'channels': 1, 'duration': 9.195102040816327, - 'other': {}, + 'other': OtherFields(), 'samplerate': 11025, }), ('vbr11stereo.mp3', { @@ -445,7 +452,7 @@ 'bitrate': 8.143465909090908, 'channels': 2, 'duration': 9.195102040816327, - 'other': {}, + 'other': OtherFields(), 'samplerate': 11025, }), ('vbr16.mp3', { @@ -453,7 +460,7 @@ 'bitrate': 8.251968503937007, 'channels': 1, 'duration': 9.144, - 'other': {}, + 'other': OtherFields(), 'samplerate': 16000, }), ('vbr16stereo.mp3', { @@ -461,7 +468,7 @@ 'bitrate': 8.251968503937007, 'channels': 2, 'duration': 9.144, - 'other': {}, + 'other': OtherFields(), 'samplerate': 16000, }), ('vbr22.mp3', { @@ -469,7 +476,7 @@ 'bitrate': 8.145021489971347, 'channels': 1, 'duration': 9.11673469387755, - 'other': {}, + 'other': OtherFields(), 'samplerate': 22050, }), ('vbr22stereo.mp3', { @@ -477,7 +484,7 @@ 'bitrate': 8.145021489971347, 'channels': 2, 'duration': 9.11673469387755, - 'other': {}, + 'other': OtherFields(), 'samplerate': 22050, }), ('vbr32.mp3', { @@ -485,7 +492,7 @@ 'bitrate': 32.50592885375494, 'channels': 1, 'duration': 9.108, - 'other': {}, + 'other': OtherFields(), 'samplerate': 32000, }), ('vbr32stereo.mp3', { @@ -493,7 +500,7 @@ 'bitrate': 32.50592885375494, 'channels': 2, 'duration': 9.108, - 'other': {}, + 'other': OtherFields(), 'samplerate': 32000, }), ('vbr44.mp3', { @@ -501,7 +508,7 @@ 'bitrate': 32.21697198275862, 'channels': 1, 'duration': 9.09061224489796, - 'other': {}, + 'other': OtherFields(), 'samplerate': 44100, }), ('vbr44stereo.mp3', { @@ -509,7 +516,7 @@ 'bitrate': 32.21697198275862, 'channels': 2, 'duration': 9.09061224489796, - 'other': {}, + 'other': OtherFields(), 'samplerate': 44100, }), ('vbr48.mp3', { @@ -517,7 +524,7 @@ 'bitrate': 32.33862433862434, 'channels': 1, 'duration': 9.072, - 'other': {}, + 'other': OtherFields(), 'samplerate': 48000, }), ('vbr48stereo.mp3', { @@ -525,11 +532,11 @@ 'bitrate': 32.33862433862434, 'channels': 2, 'duration': 9.072, - 'other': {}, + 'other': OtherFields(), 'samplerate': 48000, }), ('id3v24_genre_null_byte.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 256, 'album': '\u79d8\u5bc6', 'albumartist': 'aiko', @@ -545,11 +552,11 @@ 'bitrate': 24.0, 'channels': 1, 'duration': 0.144, - 'other': {}, + 'other': OtherFields(), 'samplerate': 8000, }), ('id3_multiple_artists.mp3', { - 'other': { + 'other': OtherFields({ 'artist': [ 'artist2', 'artist3', @@ -558,7 +565,7 @@ 'artist6', 'artist7', ] - }, + }), 'filesize': 2007, 'bitrate': 57.39124999999999, 'channels': 1, @@ -573,21 +580,21 @@ 'channels': 1, 'duration': 3.96, 'samplerate': 16000, - 'other': {}, + 'other': OtherFields(), }), ('id3v22_with_image.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 2311, 'title': 'image', }), ('utf16_no_bom.mp3', { - 'other': {}, + 'other': OtherFields(), 'filesize': 1069, 'title': 'no bom test ë', 'artist': 'no bom test 2 ë', }), ('empty.ogg', { - 'other': {}, + 'other': OtherFields(), 'duration': 3.684716553287982, 'filesize': 4328, 'bitrate': 112.0, @@ -595,13 +602,13 @@ 'channels': 2, }), ('multipage-setup.ogg', { - 'other': { + 'other': OtherFields({ 'transcoded': ['mp3;241'], 'replaygain_album_gain': ['-10.29 dB'], 'replaygain_album_peak': ['1.50579047'], 'replaygain_track_peak': ['1.17979193'], 'replaygain_track_gain': ['-10.02 dB'], - }, + }), 'genre': 'JRock', 'duration': 4.128798185941043, 'album': 'Timeless', @@ -616,7 +623,7 @@ 'channels': 2, }), ('test.ogg', { - 'other': {}, + 'other': OtherFields(), 'duration': 1.0, 'album': 'the boss', 'year': '2006', @@ -630,7 +637,7 @@ 'comment': 'hello!', }), ('corrupt_metadata.ogg', { - 'other': {}, + 'other': OtherFields(), 'filesize': 18648, 'bitrate': 80.0, 'duration': 2.132358276643991, @@ -638,7 +645,7 @@ 'channels': 1, }), ('composer.ogg', { - 'other': {}, + 'other': OtherFields(), 'filesize': 4480, 'album': 'An Album', 'artist': 'An Artist', @@ -654,7 +661,7 @@ 'comment': 'A Comment', }), ('ogg_with_image.ogg', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'duration': 0.1, 'filesize': 5759, @@ -664,7 +671,7 @@ 'title': 'Sample Title', }), ('test.opus', { - 'other': { + 'other': OtherFields({ 'encoder': ['Lavc57.24.102 libopus'], 'arrange': ['\u6771\u65b9'], 'catalogid': ['ARCD0024'], @@ -676,7 +683,7 @@ 'originaltitle': ['Bad Apple!!'], 'performer': ['Masayoshi Minoshima'], 'vocal': ['nomico'], - }, + }), 'albumartist': 'Alstroemeria Records', 'samplerate': 48000, 'channels': 2, @@ -694,9 +701,9 @@ 'track_total': 13, }), ('8khz_5s.opus', { - 'other': { + 'other': OtherFields({ 'encoder': ['opusenc from opus-tools 0.2'] - }, + }), 'filesize': 7251, 'channels': 1, 'samplerate': 48000, @@ -704,11 +711,11 @@ 'bitrate': 9.5952 }), ('test_flac.oga', { - 'other': { + 'other': OtherFields({ 'copyright': ['test3'], 'isrc': ['test4'], 'lyrics': ['test7'] - }, + }), 'filesize': 9273, 'album': 'test2', 'artist': 'test6', @@ -724,7 +731,7 @@ 'year': '2023', }), ('test.spx', { - 'other': {}, + 'other': OtherFields(), 'filesize': 7921, 'channels': 1, 'samplerate': 16000, @@ -735,7 +742,7 @@ 'comment': 'Encoded with Speex 1.2.0', }), ('test.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 1.0, 'filesize': 176444, @@ -744,7 +751,7 @@ 'bitdepth': 16, }), ('test3sMono.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'duration': 3.0, 'filesize': 264644, @@ -753,7 +760,7 @@ 'bitdepth': 16, }), ('test-tagged.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 1.0, 'filesize': 176688, @@ -769,7 +776,7 @@ 'year': '2014', }), ('test-riff-tags.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 1.0, 'filesize': 176540, @@ -783,7 +790,7 @@ 'year': '2014', }), ('silence-22khz-mono-1s.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'duration': 0.9991836734693877, 'filesize': 48160, @@ -792,9 +799,9 @@ 'bitdepth': 16, }), ('id3_header_with_a_zero_byte.wav', { - 'other': { + 'other': OtherFields({ 'title': ['Stacked'] - }, + }), 'channels': 1, 'duration': 1.0, 'filesize': 44280, @@ -807,7 +814,7 @@ 'album': 'prototypes', }), ('adpcm.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'duration': 12.167256235827665, 'filesize': 268686, @@ -823,7 +830,7 @@ 'year': '1990', }), ('riff_extra_zero.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20670, @@ -838,7 +845,7 @@ 'track': 3, }), ('riff_extra_zero_2.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 0.11609977324263039, 'filesize': 20682, @@ -852,7 +859,7 @@ 'track': 7, }), ('wav_invalid_track_number.wav', { - 'other': {}, + 'other': OtherFields(), 'filesize': 8908, 'bitrate': 705.6, 'duration': 0.1, @@ -861,7 +868,7 @@ 'bitdepth': 16, }), ('gsm_6_10.wav', { - 'other': {}, + 'other': OtherFields(), 'bitdepth': 1, 'bitrate': 44.1, 'channels': 1, @@ -877,7 +884,7 @@ 'genre': 'Bass', }), ('wav_with_image.wav', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'duration': 2.14475, 'filesize': 22902, @@ -886,7 +893,7 @@ 'bitdepth': 8, }), ('flac1sMono.flac', { - 'other': {}, + 'other': OtherFields(), 'genre': 'Avantgarde', 'album': 'alb', 'year': '2014', @@ -902,7 +909,7 @@ 'comment': 'hello', }), ('flac453sStereo.flac', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 453.51473922902494, 'filesize': 84236, @@ -911,7 +918,7 @@ 'bitdepth': 16, }), ('flac1.5sStereo.flac', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'album': 'alb', 'year': '2014', @@ -927,7 +934,7 @@ 'comment': 'hello', }), ('flac_application.flac', { - 'other': { + 'other': OtherFields({ 'replaygain_track_peak': ['0.9976'], 'musicbrainz_albumartistid': [ 'e5c7b94f-e264-473c-bb0f-37c85d4d5c70' @@ -939,7 +946,7 @@ 'artistsort': ['Belle and Sebastian'], 'replaygain_track_gain': ['-8.08 dB'], 'replaygain_album_peak': ['1.0000'], - }, + }), 'channels': 2, 'track_total': 11, 'album': 'Belle and Sebastian Write About Love', @@ -954,7 +961,7 @@ 'bitdepth': 16, }), ('no-tags.flac', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 3.684716553287982, 'filesize': 4692, @@ -963,7 +970,7 @@ 'bitdepth': 16, }), ('variable-block.flac', { - 'other': { + 'other': OtherFields({ 'discid': ['AA0B360B'], 'japanese title': ['アップルシード オリジナル・サウンドトラック'], 'organization': ['Sony Music Records (SRCP-371)'], @@ -972,7 +979,7 @@ 'replaygain_album_peak': ['1.000000'], 'replaygain_track_gain': ['-9.61 dB'], 'replaygain_track_peak': ['1.000000'], - }, + }), 'channels': 2, 'album': 'Appleseed Original Soundtrack', 'year': '2004', @@ -992,11 +999,11 @@ 'composer': 'Boom Boom Satellites (Lyrics)', }), ('106-invalid-streaminfo.flac', { - 'other': {}, + 'other': OtherFields(), 'filesize': 4692 }), ('106-short-picture-block-size.flac', { - 'other': {}, + 'other': OtherFields(), 'filesize': 4692, 'bitrate': 10.186943678613627, 'channels': 2, @@ -1005,7 +1012,7 @@ 'bitdepth': 16, }), ('with_padded_id3_header.flac', { - 'other': {}, + 'other': OtherFields(), 'filesize': 16070, 'album': 'album', 'artist': 'artist', @@ -1021,7 +1028,7 @@ 'comment': 'comment', }), ('with_padded_id3_header2.flac', { - 'other': { + 'other': OtherFields({ 'tlen': ['297666'], 'encoded_by': ['Exact Audio Copy (Sicherer Modus)'], 'encoder_settings': [ @@ -1032,7 +1039,7 @@ 'artist': ['Unbekannter Künstler'], 'album': ['Unbekannter Titel'], 'title': ['Track01'], - }, + }), 'filesize': 19522, 'album': 'album', 'artist': 'artist', @@ -1051,7 +1058,7 @@ 'comment': 'comment', }), ('flac_invalid_track_number.flac', { - 'other': {}, + 'other': OtherFields(), 'filesize': 235, 'bitrate': 18.8, 'channels': 1, @@ -1060,12 +1067,12 @@ 'bitdepth': 16, }), ('flac_with_image.flac', { - 'other': { + 'other': OtherFields({ 'artist': ['artist 2', 'artist 3'], 'genre': ['genre 2'], 'album': ['album 2'], 'url': ['https://example.com'], - }, + }), 'filesize': 2824, 'album': 'album 1', 'artist': 'artist 1', @@ -1077,7 +1084,7 @@ 'bitdepth': 16, }), ('test2.wma', { - 'other': { + 'other': OtherFields({ '_track': ['0'], 'mediaprimaryclassid': ['{D1607DBC-E323-4BE2-86A1-48A42A28441E}'], 'encodingtime': ['128861118183900000'], @@ -1086,7 +1093,7 @@ 'isvbr': ['1'], 'peakvalue': ['30369'], 'averagelevel': ['7291'], - }, + }), 'samplerate': 44100, 'album': 'The Colour and the Shape', 'title': 'Doll', @@ -1102,7 +1109,7 @@ 'channels': 2, }), ('lossless.wma', { - 'other': {}, + 'other': OtherFields(), 'samplerate': 44100, 'bitrate': 667.296, 'filesize': 2500, @@ -1111,9 +1118,9 @@ 'channels': 2, }), ('wma_invalid_track_number.wma', { - 'other': { + 'other': OtherFields({ 'encoder_settings': ['Lavf60.16.100'] - }, + }), 'filesize': 3940, 'bitrate': 128.0, 'duration': 2.1409999999999996, @@ -1121,7 +1128,7 @@ 'channels': 1, }), ('test.m4a', { - 'other': { + 'other': OtherFields({ 'itunsmpb': [ ' 00000000 00000840 000001DC 0000000000D3E9E4 00000000' ' 00000000 00000000 00000000 00000000 00000000 00000000' @@ -1137,7 +1144,7 @@ ], 'bpm': ['0'], 'encoded_by': ['iTunes 10.5'], - }, + }), 'samplerate': 44100, 'duration': 314.97868480725623, 'bitrate': 256.0, @@ -1152,11 +1159,11 @@ 'filesize': 61432, }), ('mpeg4_with_image.m4a', { - 'other': { + 'other': OtherFields({ 'publisher': ['test7'], 'bpm': ['1'], 'encoded_by': ['Lavf60.3.100'] - }, + }), 'artist': 'test1', 'composer': 'test8', 'filesize': 7371, @@ -1166,11 +1173,11 @@ 'bitrate': 27.887, }), ('alac_file.m4a', { - 'other': { + 'other': OtherFields({ 'copyright': ['© Hyperion Records Ltd, London'], 'lyrics': ['Album notes:'], 'upc': ['0034571177380'] - }, + }), 'artist': 'Howard Shelley', 'filesize': 20000, 'composer': 'Clementi, Muzio (1752-1832)', @@ -1191,10 +1198,10 @@ 'bitdepth': 16, }), ('mpeg4_desc_cmt.m4a', { - 'other': { + 'other': OtherFields({ 'description': ['test description'], 'encoded_by': ['Lavf59.27.100'] - }, + }), 'filesize': 32006, 'bitrate': 101.038, 'channels': 2, @@ -1203,19 +1210,19 @@ 'samplerate': 44100, }), ('mpeg4_xa9des.m4a', { - 'other': { + 'other': OtherFields({ 'description': ['test description'] - }, + }), 'filesize': 2639, 'comment': 'test comment', 'duration': 727.1066666666667, }), ('test2.m4a', { - 'other': { + 'other': OtherFields({ 'publisher': ['test7'], 'bpm': ['99999'], 'encoded_by': ['Lavf60.3.100'] - }, + }), 'artist': 'test1', 'composer': 'test8', 'filesize': 6260, @@ -1225,7 +1232,7 @@ 'bitrate': 27.887, }), ('mvhd_version_1.m4a', { - 'other': {}, + 'other': OtherFields(), 'title': '64-bit test', 'filesize': 2048, 'samplerate': 44100, @@ -1234,7 +1241,7 @@ 'bitrate': 0.0, }), ('test-tagged.aiff', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 1.0, 'filesize': 177620, @@ -1250,9 +1257,9 @@ 'year': '2014', }), ('test.aiff', { - 'other': { + 'other': OtherFields({ 'copyright': ['℗ 1992 Ace Records'] - }, + }), 'channels': 2, 'duration': 0.0, 'filesize': 164, @@ -1263,7 +1270,7 @@ 'comment': 'Millie Jackson - Get It Out \'cha System - 1978', }), ('pluck-pcm8.aiff', { - 'other': {}, + 'other': OtherFields(), 'channels': 2, 'duration': 0.2999546485260771, 'filesize': 6892, @@ -1277,9 +1284,9 @@ 'year': '2013', }), ('M1F1-mulawC-AFsp.afc', { - 'other': { + 'other': OtherFields({ 'comment': ['user: kabal@CAPELLA', 'program: CopyAudio'] - }, + }), 'channels': 2, 'duration': 2.936625, 'filesize': 47148, @@ -1289,16 +1296,16 @@ 'comment': 'AFspdate: 2003-01-30 03:28:34 UTC', }), ('invalid_sample_rate.aiff', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'filesize': 4096, 'bitdepth': 16, }), ('aiff_extra_tags.aiff', { - 'other': { + 'other': OtherFields({ 'copyright': ['test'], 'isrc': ['CC-XXX-YY-NNNNN'] - }, + }), 'channels': 1, 'duration': 2.176, 'filesize': 18532, @@ -1309,7 +1316,7 @@ 'artist': 'artist 1;artist 2', }), ('aiff_with_image.aiff', { - 'other': {}, + 'other': OtherFields(), 'channels': 1, 'duration': 2.176, 'filesize': 21044, @@ -1331,43 +1338,60 @@ def setUpClass(cls) -> None: if isinstance(stdout, TextIOWrapper): stdout.reconfigure(encoding='utf-8') - def compare_tag(self, results: dict[str, Any], - expected: dict[str, Any], - file: str, prev_path: str | None = None) -> None: - def compare_values(path: str, - result_val: str | float, - expected_val: str | float) -> bool: + def compare_tag(self, + results: ExpectedTag, + expected: ExpectedTag, + file: str) -> None: + def error_fmt(value: str | float | list[str]) -> str: + return f'{repr(value)} ({type(value)})' + + def assert_complete_data(results: ExpectedTag | OtherFields, + expected: ExpectedTag | OtherFields) -> None: + missing_result_fields = set(expected) - set(results) + missing_expected_fields = set(results) - set(expected) + self.assertFalse( + missing_result_fields, + f'Missing fields in tag \n{missing_result_fields}') + self.assertFalse( + missing_expected_fields, + f'Missing fields in test case \n{missing_expected_fields}') + + def assert_values_match(path: str, + result_val: str | float | list[str], + expected_val: str | float | list[str]) -> None: + fmt_string = 'field "%s": got %s expected %s in %s!' + fmt_values = ( + path, error_fmt(result_val), error_fmt(expected_val), file) + values_match = False # lets not copy *all* the lyrics inside the fixture if (path == 'other.lyrics' and isinstance(expected_val, list) and isinstance(result_val, list)): - return result_val[0].startswith(expected_val[0]) - if (isinstance(result_val, float) + values_match = result_val[0].startswith(expected_val[0]) + elif (isinstance(result_val, float) and isinstance(expected_val, float)): - return isclose(result_val, expected_val) - return result_val == expected_val + values_match = isclose(result_val, expected_val) + else: + values_match = result_val == expected_val + self.assertTrue(values_match, fmt_string % fmt_values) - def error_fmt(value: str | float) -> str: - return f'{repr(value)} ({type(value)})' + assert_complete_data(results, expected) - self.assertIsInstance(results, dict) - missing_keys = set(expected.keys()) - set(results) - self.assertFalse( - missing_keys, f'Missing data in fixture \n{missing_keys}') + for path, result_val in results.items(): + expected_val = expected[path] + if (isinstance(result_val, OtherFields) + and isinstance(expected_val, OtherFields)): + assert_complete_data(result_val, expected_val) - for key, result_val in results.items(): - path = prev_path + '.' + key if prev_path else key - expected_val = expected[key] - # recurse if the result and expected values are a dict: - if isinstance(result_val, dict) and isinstance(expected_val, dict): - self.compare_tag(result_val, expected_val, file, prev_path=key) - else: - fmt_string = 'field "%s": got %s expected %s in %s!' - fmt_values = ( - key, error_fmt(result_val), error_fmt(expected_val), file) - self.assertTrue( - compare_values(path, result_val, expected_val), - fmt_string % fmt_values) + for other_key, other_result_val in result_val.items(): + other_path = f"{path}.{other_key}" + assert_values_match( + other_path, other_result_val, + expected_val[other_key] + ) + elif (not isinstance(result_val, OtherFields) + and not isinstance(expected_val, OtherFields)): + assert_values_match(path, result_val, expected_val) def test_file_reading_all(self) -> None: for testfile, expected in TEST_FILES.items(): @@ -1377,10 +1401,9 @@ def test_file_reading_all(self) -> None: filename, tags=True, duration=True, image=True) results = { key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None + if not key.startswith('_') and key != 'filename' + and val is not None and not isinstance(val, Images) } - for attr_name in ('filename', 'images'): - del results[attr_name] self.compare_tag(results, expected, filename) def test_file_reading_tags(self) -> None: @@ -1393,10 +1416,9 @@ def test_file_reading_tags(self) -> None: tag = TinyTag.get(filename, tags=True, duration=False) results = { key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None + if not key.startswith('_') and key != 'filename' + and val is not None and not isinstance(val, Images) } - for attr_name in ('filename', 'images'): - del results[attr_name] filtered_expected = { key: val for key, val in expected.items() if key not in excluded_attrs @@ -1414,10 +1436,10 @@ def test_file_reading_duration(self) -> None: tag = TinyTag.get(filename, tags=False, duration=True) results = { key: val for key, val in tag.__dict__.items() - if not key.startswith('_') and val is not None + if not key.startswith('_') and key != 'filename' + and val is not None and not isinstance(val, Images) + and not isinstance(val, OtherFields) } - for attr_name in ('filename', 'other', 'images'): - del results[attr_name] filtered_expected = { key: val for key, val in expected.items() if key in allowed_attrs diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index af6ee94..0d36299 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -34,15 +34,19 @@ from os import PathLike, SEEK_CUR, SEEK_END, environ, fsdecode from struct import unpack +TYPE_CHECKING = False + # Lazy imports for type checking -if False: # pylint: disable=using-constant-test +if TYPE_CHECKING: from collections.abc import Callable, Iterator # pylint: disable-all - from typing import Any, BinaryIO, Dict, List + from typing import Any, BinaryIO, Dict, List, Union _StringListDict = Dict[str, List[str]] _ImageListDict = Dict[str, List["Image"]] + _DataTreeDict = Dict[ + bytes, Union['_DataTreeDict', Callable[..., Dict[str, Any]]]] else: - _StringListDict = _ImageListDict = dict + _StringListDict = _ImageListDict = _DataTreeDict = dict # some of the parsers can print debug info _DEBUG = bool(environ.get('TINYTAG_DEBUG')) @@ -487,8 +491,8 @@ class _MP4(TinyTag): _VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header _FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header - _audio_data_tree: dict[bytes, Any] | None = None - _meta_data_tree: dict[bytes, Any] | None = None + _audio_data_tree: _DataTreeDict | None = None + _meta_data_tree: _DataTreeDict | None = None def _determine_duration(self, fh: BinaryIO) -> None: # https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html @@ -543,7 +547,7 @@ def _parse_tag(self, fh: BinaryIO) -> None: def _traverse_atoms(self, fh: BinaryIO, - path: dict[bytes, Any], + path: _DataTreeDict, stop_pos: int | None = None, curr_path: list[bytes] | None = None) -> None: header_len = 8 From a8a5cc1185a81f1cc3224d54b8033ff21399eb6a Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 5 Jun 2025 18:50:28 +0300 Subject: [PATCH 301/305] Add missing __version__ attribute (#248) --- pyproject.toml | 2 +- tinytag/__init__.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1555533..fe04c0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ build-backend = "flit_core.buildapi" [project] name = "tinytag" -version = "2.1.1" description = "Read audio file metadata" authors = [ {name = "Tom Wallroth"}, @@ -50,6 +49,7 @@ classifiers = [ license = {file = "LICENSE"} readme = "README.md" requires-python = ">=3.7" +dynamic = ["version"] [project.urls] Homepage = "https://github.com/tinytag/tinytag" diff --git a/tinytag/__init__.py b/tinytag/__init__.py index 2cfb27b..1623ac5 100644 --- a/tinytag/__init__.py +++ b/tinytag/__init__.py @@ -3,6 +3,8 @@ """Audio file metadata reader.""" +__version__ = '2.1.1' + from .tinytag import ( TinyTag, Image, Images, OtherFields, OtherImages, TinyTagException, ParseError, UnsupportedFormatError From c70dafd16d5c9d566fd8b5d2a2edaed6f303c486 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 6 Jun 2025 00:02:01 +0300 Subject: [PATCH 302/305] M4A: Fix reading of multi-value custom fields (#247) Ensure we read all 'data' atoms, not only the last one. --- tinytag/tests/samples/multi_value.m4a | Bin 0 -> 1995 bytes tinytag/tests/test_all.py | 15 ++++++++ tinytag/tinytag.py | 49 +++++++++++++------------- 3 files changed, 39 insertions(+), 25 deletions(-) create mode 100644 tinytag/tests/samples/multi_value.m4a diff --git a/tinytag/tests/samples/multi_value.m4a b/tinytag/tests/samples/multi_value.m4a new file mode 100644 index 0000000000000000000000000000000000000000..9c618c68d8b55665cf0f18fb64b240231886701f GIT binary patch literal 1995 zcmeHH&2G~`5FR_}&!Iw91uZHTGEunEC@H0g15zq+KmsmMI8VkNhQupyD&N?S;---}^?=k**`3*$$v3k*>m?#@V9%0Qdj~#|ODqEuYm8kY zS{5#&=gv@uX~{t*&Z4Q#@(tGVxrk(_IL7&^gJ=L{-RKLX zO{br(K0j#tsvjn{Fw46Oq)b?){(_+KzgX8&jx@q^Vlk7 z0^cX-Q?Q-WF~L=go5uFzG8xzL0At%6Cz2Ow9P-KWo}BY@%%15TCy-N5rSkgIOj>~= zA8Exh$GvKkaWPVnB+NL4^CBp2(#IVDR!=LD--LdwTO6!f!C@9DZo)<+G=I|6&y0;V zludlwoQlh!kq9%@S!m@4;d1OlSo@QM*N2dIs!VuAlrK^n Callable[[bytes], dict[str, int | str | bytes | None]]: - def _parse_data_atom( - data_atom: bytes - ) -> dict[str, int | str | bytes | None]: + def _data_parser(cls, fieldname: str) -> Callable[[bytes], dict[str, str]]: + def _parse_data_atom(data_atom: bytes) -> dict[str, str]: data_type = unpack('>I', data_atom[:4])[0] data = data_atom[8:] value = None @@ -611,7 +607,9 @@ def _parse_data_atom( data_len = len(data) if data_len in fmts: value = str(unpack(fmts[data_len], data)[0]) - return {fieldname: value} + if value: + return {fieldname: value} + return {} return _parse_data_atom @classmethod @@ -649,13 +647,11 @@ def _read_extended_descriptor(cls, esds_atom: BinaryIO) -> None: break @classmethod - def _parse_custom_field( - cls, data: bytes - ) -> dict[str, int | str | bytes | None]: + def _parse_custom_field(cls, data: bytes) -> dict[str, list[str]]: fh = BytesIO(data) header_len = 8 field_name = None - data_atom = b'' + values = [] atom_header = fh.read(header_len) while len(atom_header) == header_len: atom_size = unpack('>I', atom_header[:4])[0] - header_len @@ -666,15 +662,18 @@ def _parse_custom_field( # pylint: disable=protected-access field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get( field_name, TinyTag._OTHER_PREFIX + field_name) - elif atom_type == b'data': + elif atom_type == b'data' and field_name: data_atom = fh.read(atom_size) + parser = cls._data_parser(field_name) + atom_values = parser(data_atom) + if field_name in atom_values: + values.append(atom_values[field_name]) else: fh.seek(atom_size, SEEK_CUR) atom_header = fh.read(header_len) # read next atom - if len(data_atom) < 8 or field_name is None: - return {} - parser = cls._data_parser(field_name) - return parser(data_atom) + if field_name and values: + return {field_name: values} + return {} @classmethod def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]: From 544b7ac283da9908e69a4f43a17d85c3d7da70bb Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 6 Jun 2025 00:19:26 +0300 Subject: [PATCH 303/305] CI: Add Pyright static type checking (#249) We should aim to support multiple static type checkers. --- .github/workflows/tests.yml | 5 ++++- pyproject.toml | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8a88c91..3b4b043 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,9 +42,12 @@ jobs: - name: Linting run: python -m pylint --recursive=y . - - name: Typing + - name: Typing (mypy) run: python -m mypy -p tinytag + - name: Typing (pyright) + run: python -m pyright + - name: Unit tests run: python -m coverage run -m unittest env: diff --git a/pyproject.toml b/pyproject.toml index fe04c0b..1167bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,8 @@ tests = [ "coverage", "mypy", "pycodestyle", - "pylint" + "pylint", + "pyright" ] [tool.flit.sdist] From 04fdddc962132d26824ded8587d08addcf830f47 Mon Sep 17 00:00:00 2001 From: Mat Date: Fri, 6 Jun 2025 00:32:11 +0300 Subject: [PATCH 304/305] tinytag.py: fix __dict__ type hints --- tinytag/tinytag.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index e2e409e..d0eaf2b 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -109,7 +109,7 @@ def __init__(self) -> None: self._parse_tags = True self._load_image = False self._tags_parsed = False - self.__dict__: dict[str, str | float | Images | OtherFields] + self.__dict__: dict[str, str | float | Images | OtherFields | None] @classmethod def get(cls, @@ -368,7 +368,7 @@ def __init__(self) -> None: self.media: Image | None = None self.other: _ImageListDict = OtherImages() - self.__dict__: dict[str, Image | OtherImages] + self.__dict__: dict[str, Image | OtherImages | None] @property def any(self) -> Image | None: From a68ce788da882b25933a22ece88af1f626d2a2f3 Mon Sep 17 00:00:00 2001 From: Mat Date: Thu, 12 Jun 2025 04:44:30 +0300 Subject: [PATCH 305/305] M4A: Add a few missing additonal metadata fields (#251) --- tinytag/tests/test_all.py | 2 ++ tinytag/tinytag.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tinytag/tests/test_all.py b/tinytag/tests/test_all.py index da01e7f..ce8dadf 100644 --- a/tinytag/tests/test_all.py +++ b/tinytag/tests/test_all.py @@ -1144,6 +1144,8 @@ ], 'bpm': ['0'], 'encoded_by': ['iTunes 10.5'], + 'cpil': ['0'], + 'pgap': ['0'], }), 'samplerate': 44100, 'duration': 314.97868480725623, diff --git a/tinytag/tinytag.py b/tinytag/tinytag.py index d0eaf2b..e901d5a 100644 --- a/tinytag/tinytag.py +++ b/tinytag/tinytag.py @@ -487,6 +487,7 @@ class _MP4(TinyTag): } _VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header _FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header + _ILST_PATH = [b'ftyp', b'moov', b'udta', b'meta', b'ilst'] _audio_data_tree: _DataTreeDict | None = None _meta_data_tree: _DataTreeDict | None = None @@ -518,8 +519,6 @@ def _parse_tag(self, fh: BinaryIO) -> None: b'\xa9alb': {b'data': _MP4._data_parser('album')}, b'\xa9cmt': {b'data': _MP4._data_parser('comment')}, b'\xa9con': {b'data': _MP4._data_parser('other.conductor')}, - # need test-data for this - # b'cpil': {b'data': _MP4._data_parser('other.compilation')}, b'\xa9day': {b'data': _MP4._data_parser('year')}, b'\xa9des': {b'data': _MP4._data_parser('other.description')}, b'\xa9dir': {b'data': _MP4._data_parser('other.director')}, @@ -586,6 +585,16 @@ def _traverse_atoms(self, self._set_field(fieldname, subval) else: self._set_field(fieldname, value) + # unknown data atom, try to parse it + elif curr_path == self._ILST_PATH: + atom_end_pos = fh.tell() + atom_size + field_name = self._OTHER_PREFIX + atom_type.decode( + 'utf-8', 'replace') + fh.seek(-header_len, SEEK_CUR) + self._traverse_atoms( + fh, + path={atom_type: {b'data': self._data_parser(field_name)}}, + stop_pos=atom_end_pos, curr_path=curr_path + [atom_type]) # if no action was specified using dict or callable, jump over atom else: fh.seek(atom_size, SEEK_CUR)