tinytag 2.1.0__tar.gz → 2.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of tinytag might be problematic. Click here for more details.
- {tinytag-2.1.0 → tinytag-2.1.2}/PKG-INFO +23 -7
- {tinytag-2.1.0 → tinytag-2.1.2}/README.md +21 -5
- {tinytag-2.1.0 → tinytag-2.1.2}/pyproject.toml +12 -2
- {tinytag-2.1.0 → tinytag-2.1.2}/tinytag/__init__.py +2 -0
- {tinytag-2.1.0 → tinytag-2.1.2}/tinytag/tinytag.py +90 -73
- {tinytag-2.1.0 → tinytag-2.1.2}/LICENSE +0 -0
- {tinytag-2.1.0 → tinytag-2.1.2}/tinytag/__main__.py +0 -0
- {tinytag-2.1.0 → tinytag-2.1.2}/tinytag/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinytag
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: Read audio file metadata
|
|
5
5
|
Keywords: metadata,audio,music,mp3,m4a,wav,ogg,opus,flac,wma,aiff
|
|
6
6
|
Author: Tom Wallroth, Mat (mathiascode)
|
|
@@ -30,7 +30,7 @@ Requires-Dist: coverage ; extra == "tests"
|
|
|
30
30
|
Requires-Dist: mypy ; extra == "tests"
|
|
31
31
|
Requires-Dist: pycodestyle ; extra == "tests"
|
|
32
32
|
Requires-Dist: pylint ; extra == "tests"
|
|
33
|
-
Requires-Dist:
|
|
33
|
+
Requires-Dist: pyright ; extra == "tests"
|
|
34
34
|
Project-URL: Homepage, https://github.com/tinytag/tinytag
|
|
35
35
|
Provides-Extra: tests
|
|
36
36
|
|
|
@@ -45,8 +45,6 @@ tinytag is a Python library for reading audio file metadata
|
|
|
45
45
|
|
|
46
46
|
[](https://github.com/tinytag/tinytag/actions?query=workflow:%22Tests%22)
|
|
48
|
-
[](https://coveralls.io/r/tinytag/tinytag)
|
|
50
48
|
[](https://pypi.org/project/tinytag/)
|
|
52
50
|
[ for this.
|
|
81
|
+
|
|
80
82
|
|
|
81
83
|
## Usage
|
|
82
84
|
|
|
@@ -130,9 +132,6 @@ Alternatively you can use tinytag directly on the command line:
|
|
|
130
132
|
Check `python3 -m tinytag --help` for all CLI options, for example other
|
|
131
133
|
output formats.
|
|
132
134
|
|
|
133
|
-
Support for changing/writing metadata will not be added. Use another library
|
|
134
|
-
such as [Mutagen](https://mutagen.readthedocs.io/) for this.
|
|
135
|
-
|
|
136
135
|
### Supported Files
|
|
137
136
|
|
|
138
137
|
To receive a tuple of file extensions tinytag supports, use the
|
|
@@ -422,6 +421,23 @@ TinyTag.get(file_obj=your_file_obj)
|
|
|
422
421
|
|
|
423
422
|
## Changelog
|
|
424
423
|
|
|
424
|
+
### 2.1.2 (2025-08-14)
|
|
425
|
+
|
|
426
|
+
- M4A: Add a few missing additional metadata fields
|
|
427
|
+
- M4A: Support '©com' composer atom
|
|
428
|
+
- M4A: Fix reading of multi-value custom fields
|
|
429
|
+
- M4A: Use correct encoding when reading data names
|
|
430
|
+
- ID3: Don't read entire file to determine duration
|
|
431
|
+
- ID3: Skip stray null byte before image data
|
|
432
|
+
- Add missing `__version__` attribute
|
|
433
|
+
- Avoid some unnecessary work in hot code paths
|
|
434
|
+
- Improve a few incomplete type hints
|
|
435
|
+
|
|
436
|
+
### 2.1.1 (2025-04-23)
|
|
437
|
+
|
|
438
|
+
- ID3: Stop removing 'b' character from strings
|
|
439
|
+
- Port unit tests from pytest to built-in unittest module
|
|
440
|
+
|
|
425
441
|
### 2.1.0 (2025-02-23)
|
|
426
442
|
|
|
427
443
|
- Opus: Calculate audio bitrate
|
|
@@ -9,8 +9,6 @@ tinytag is a Python library for reading audio file metadata
|
|
|
9
9
|
|
|
10
10
|
[](https://github.com/tinytag/tinytag/actions?query=workflow:%22Tests%22)
|
|
12
|
-
[](https://coveralls.io/r/tinytag/tinytag)
|
|
14
12
|
[](https://pypi.org/project/tinytag/)
|
|
16
14
|
[ for this.
|
|
45
|
+
|
|
44
46
|
|
|
45
47
|
## Usage
|
|
46
48
|
|
|
@@ -94,9 +96,6 @@ Alternatively you can use tinytag directly on the command line:
|
|
|
94
96
|
Check `python3 -m tinytag --help` for all CLI options, for example other
|
|
95
97
|
output formats.
|
|
96
98
|
|
|
97
|
-
Support for changing/writing metadata will not be added. Use another library
|
|
98
|
-
such as [Mutagen](https://mutagen.readthedocs.io/) for this.
|
|
99
|
-
|
|
100
99
|
### Supported Files
|
|
101
100
|
|
|
102
101
|
To receive a tuple of file extensions tinytag supports, use the
|
|
@@ -386,6 +385,23 @@ TinyTag.get(file_obj=your_file_obj)
|
|
|
386
385
|
|
|
387
386
|
## Changelog
|
|
388
387
|
|
|
388
|
+
### 2.1.2 (2025-08-14)
|
|
389
|
+
|
|
390
|
+
- M4A: Add a few missing additional metadata fields
|
|
391
|
+
- M4A: Support '©com' composer atom
|
|
392
|
+
- M4A: Fix reading of multi-value custom fields
|
|
393
|
+
- M4A: Use correct encoding when reading data names
|
|
394
|
+
- ID3: Don't read entire file to determine duration
|
|
395
|
+
- ID3: Skip stray null byte before image data
|
|
396
|
+
- Add missing `__version__` attribute
|
|
397
|
+
- Avoid some unnecessary work in hot code paths
|
|
398
|
+
- Improve a few incomplete type hints
|
|
399
|
+
|
|
400
|
+
### 2.1.1 (2025-04-23)
|
|
401
|
+
|
|
402
|
+
- ID3: Stop removing 'b' character from strings
|
|
403
|
+
- Port unit tests from pytest to built-in unittest module
|
|
404
|
+
|
|
389
405
|
### 2.1.0 (2025-02-23)
|
|
390
406
|
|
|
391
407
|
- Opus: Calculate audio bitrate
|
|
@@ -7,7 +7,6 @@ build-backend = "flit_core.buildapi"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "tinytag"
|
|
10
|
-
version = "2.1.0"
|
|
11
10
|
description = "Read audio file metadata"
|
|
12
11
|
authors = [
|
|
13
12
|
{name = "Tom Wallroth"},
|
|
@@ -50,6 +49,7 @@ classifiers = [
|
|
|
50
49
|
license = {file = "LICENSE"}
|
|
51
50
|
readme = "README.md"
|
|
52
51
|
requires-python = ">=3.7"
|
|
52
|
+
dynamic = ["version"]
|
|
53
53
|
|
|
54
54
|
[project.urls]
|
|
55
55
|
Homepage = "https://github.com/tinytag/tinytag"
|
|
@@ -60,7 +60,7 @@ tests = [
|
|
|
60
60
|
"mypy",
|
|
61
61
|
"pycodestyle",
|
|
62
62
|
"pylint",
|
|
63
|
-
"
|
|
63
|
+
"pyright"
|
|
64
64
|
]
|
|
65
65
|
|
|
66
66
|
[tool.flit.sdist]
|
|
@@ -115,3 +115,13 @@ py-version = "3.7"
|
|
|
115
115
|
|
|
116
116
|
[tool.mypy]
|
|
117
117
|
strict = true
|
|
118
|
+
|
|
119
|
+
[tool.coverage.report]
|
|
120
|
+
exclude_lines = [
|
|
121
|
+
"if TYPE_CHECKING:"
|
|
122
|
+
]
|
|
123
|
+
precision = 2
|
|
124
|
+
show_missing = true
|
|
125
|
+
|
|
126
|
+
[tool.coverage.run]
|
|
127
|
+
relative_files = true
|
|
@@ -34,15 +34,19 @@ from io import BytesIO
|
|
|
34
34
|
from os import PathLike, SEEK_CUR, SEEK_END, environ, fsdecode
|
|
35
35
|
from struct import unpack
|
|
36
36
|
|
|
37
|
+
TYPE_CHECKING = False
|
|
38
|
+
|
|
37
39
|
# Lazy imports for type checking
|
|
38
|
-
if
|
|
40
|
+
if TYPE_CHECKING:
|
|
39
41
|
from collections.abc import Callable, Iterator # pylint: disable-all
|
|
40
|
-
from typing import Any, BinaryIO, Dict, List
|
|
42
|
+
from typing import Any, BinaryIO, Dict, List, Union
|
|
41
43
|
|
|
42
44
|
_StringListDict = Dict[str, List[str]]
|
|
43
45
|
_ImageListDict = Dict[str, List["Image"]]
|
|
46
|
+
_DataTreeDict = Dict[
|
|
47
|
+
bytes, Union['_DataTreeDict', Callable[..., Dict[str, Any]]]]
|
|
44
48
|
else:
|
|
45
|
-
_StringListDict = _ImageListDict = dict
|
|
49
|
+
_StringListDict = _ImageListDict = _DataTreeDict = dict
|
|
46
50
|
|
|
47
51
|
# some of the parsers can print debug info
|
|
48
52
|
_DEBUG = bool(environ.get('TINYTAG_DEBUG'))
|
|
@@ -105,7 +109,7 @@ class TinyTag:
|
|
|
105
109
|
self._parse_tags = True
|
|
106
110
|
self._load_image = False
|
|
107
111
|
self._tags_parsed = False
|
|
108
|
-
self.__dict__: dict[str, str | float | Images | OtherFields]
|
|
112
|
+
self.__dict__: dict[str, str | float | Images | OtherFields | None]
|
|
109
113
|
|
|
110
114
|
@classmethod
|
|
111
115
|
def get(cls,
|
|
@@ -257,7 +261,7 @@ class TinyTag:
|
|
|
257
261
|
self._parse_duration = duration
|
|
258
262
|
self._load_image = image
|
|
259
263
|
if self._filehandler is None:
|
|
260
|
-
|
|
264
|
+
raise ValueError("File handle is required")
|
|
261
265
|
if tags:
|
|
262
266
|
self._parse_tag(self._filehandler)
|
|
263
267
|
if duration:
|
|
@@ -271,14 +275,11 @@ class TinyTag:
|
|
|
271
275
|
fieldname = fieldname[len(self._OTHER_PREFIX):]
|
|
272
276
|
if check_conflict and fieldname in self.__dict__:
|
|
273
277
|
fieldname = '_' + fieldname
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
other_values.append(value)
|
|
278
|
+
if fieldname not in self.other:
|
|
279
|
+
self.other[fieldname] = []
|
|
280
|
+
self.other[fieldname].append(str(value))
|
|
278
281
|
if _DEBUG:
|
|
279
|
-
print(
|
|
280
|
-
f'Setting other field "{fieldname}" to "{other_values!r}"')
|
|
281
|
-
self.other[fieldname] = other_values
|
|
282
|
+
print(f'Adding value "{value} to field "{fieldname}"')
|
|
282
283
|
return
|
|
283
284
|
old_value = self.__dict__.get(fieldname)
|
|
284
285
|
new_value = value
|
|
@@ -326,7 +327,7 @@ class TinyTag:
|
|
|
326
327
|
@staticmethod
|
|
327
328
|
def _unpad(s: str) -> str:
|
|
328
329
|
# certain strings *may* be terminated with a zero byte at the end
|
|
329
|
-
return s.strip('
|
|
330
|
+
return s.strip('\x00')
|
|
330
331
|
|
|
331
332
|
def get_image(self) -> bytes | None:
|
|
332
333
|
"""Deprecated, use 'images.any' instead."""
|
|
@@ -367,7 +368,7 @@ class Images:
|
|
|
367
368
|
self.media: Image | None = None
|
|
368
369
|
|
|
369
370
|
self.other: _ImageListDict = OtherImages()
|
|
370
|
-
self.__dict__: dict[str, Image | OtherImages]
|
|
371
|
+
self.__dict__: dict[str, Image | OtherImages | None]
|
|
371
372
|
|
|
372
373
|
@property
|
|
373
374
|
def any(self) -> Image | None:
|
|
@@ -486,9 +487,10 @@ class _MP4(TinyTag):
|
|
|
486
487
|
}
|
|
487
488
|
_VERSIONED_ATOMS = {b'meta', b'stsd'} # those have an extra 4 byte header
|
|
488
489
|
_FLAGGED_ATOMS = {b'stsd'} # these also have an extra 4 byte header
|
|
490
|
+
_ILST_PATH = [b'ftyp', b'moov', b'udta', b'meta', b'ilst']
|
|
489
491
|
|
|
490
|
-
_audio_data_tree:
|
|
491
|
-
_meta_data_tree:
|
|
492
|
+
_audio_data_tree: _DataTreeDict | None = None
|
|
493
|
+
_meta_data_tree: _DataTreeDict | None = None
|
|
492
494
|
|
|
493
495
|
def _determine_duration(self, fh: BinaryIO) -> None:
|
|
494
496
|
# https://developer.apple.com/library/mac/documentation/QuickTime/QTFF/QTFFChap3/qtff3.html
|
|
@@ -516,9 +518,8 @@ class _MP4(TinyTag):
|
|
|
516
518
|
b'\xa9ART': {b'data': _MP4._data_parser('artist')},
|
|
517
519
|
b'\xa9alb': {b'data': _MP4._data_parser('album')},
|
|
518
520
|
b'\xa9cmt': {b'data': _MP4._data_parser('comment')},
|
|
521
|
+
b'\xa9com': {b'data': _MP4._data_parser('composer')},
|
|
519
522
|
b'\xa9con': {b'data': _MP4._data_parser('other.conductor')},
|
|
520
|
-
# need test-data for this
|
|
521
|
-
# b'cpil': {b'data': _MP4._data_parser('other.compilation')},
|
|
522
523
|
b'\xa9day': {b'data': _MP4._data_parser('year')},
|
|
523
524
|
b'\xa9des': {b'data': _MP4._data_parser('other.description')},
|
|
524
525
|
b'\xa9dir': {b'data': _MP4._data_parser('other.director')},
|
|
@@ -543,7 +544,7 @@ class _MP4(TinyTag):
|
|
|
543
544
|
|
|
544
545
|
def _traverse_atoms(self,
|
|
545
546
|
fh: BinaryIO,
|
|
546
|
-
path:
|
|
547
|
+
path: _DataTreeDict,
|
|
547
548
|
stop_pos: int | None = None,
|
|
548
549
|
curr_path: list[bytes] | None = None) -> None:
|
|
549
550
|
header_len = 8
|
|
@@ -575,13 +576,25 @@ class _MP4(TinyTag):
|
|
|
575
576
|
for fieldname, value in sub_path(fh.read(atom_size)).items():
|
|
576
577
|
if _DEBUG:
|
|
577
578
|
print(' ' * 4 * len(curr_path), 'FIELD: ', fieldname)
|
|
578
|
-
if
|
|
579
|
+
if isinstance(value, Image):
|
|
579
580
|
if self._load_image:
|
|
580
581
|
# pylint: disable=protected-access
|
|
581
582
|
self.images._set_field(
|
|
582
583
|
fieldname[len('images.'):], value)
|
|
583
|
-
elif
|
|
584
|
+
elif isinstance(value, list):
|
|
585
|
+
for subval in value:
|
|
586
|
+
self._set_field(fieldname, subval)
|
|
587
|
+
else:
|
|
584
588
|
self._set_field(fieldname, value)
|
|
589
|
+
# unknown data atom, try to parse it
|
|
590
|
+
elif curr_path == self._ILST_PATH:
|
|
591
|
+
atom_end_pos = fh.tell() + atom_size
|
|
592
|
+
field_name = self._OTHER_PREFIX + atom_type.decode('latin-1')
|
|
593
|
+
fh.seek(-header_len, SEEK_CUR)
|
|
594
|
+
self._traverse_atoms(
|
|
595
|
+
fh,
|
|
596
|
+
path={atom_type: {b'data': self._data_parser(field_name)}},
|
|
597
|
+
stop_pos=atom_end_pos, curr_path=curr_path + [atom_type])
|
|
585
598
|
# if no action was specified using dict or callable, jump over atom
|
|
586
599
|
else:
|
|
587
600
|
fh.seek(atom_size, SEEK_CUR)
|
|
@@ -591,12 +604,8 @@ class _MP4(TinyTag):
|
|
|
591
604
|
atom_header = fh.read(header_len) # read next atom
|
|
592
605
|
|
|
593
606
|
@classmethod
|
|
594
|
-
def _data_parser(
|
|
595
|
-
|
|
596
|
-
) -> Callable[[bytes], dict[str, int | str | bytes | None]]:
|
|
597
|
-
def _parse_data_atom(
|
|
598
|
-
data_atom: bytes
|
|
599
|
-
) -> dict[str, int | str | bytes | None]:
|
|
607
|
+
def _data_parser(cls, fieldname: str) -> Callable[[bytes], dict[str, str]]:
|
|
608
|
+
def _parse_data_atom(data_atom: bytes) -> dict[str, str]:
|
|
600
609
|
data_type = unpack('>I', data_atom[:4])[0]
|
|
601
610
|
data = data_atom[8:]
|
|
602
611
|
value = None
|
|
@@ -607,7 +616,9 @@ class _MP4(TinyTag):
|
|
|
607
616
|
data_len = len(data)
|
|
608
617
|
if data_len in fmts:
|
|
609
618
|
value = str(unpack(fmts[data_len], data)[0])
|
|
610
|
-
|
|
619
|
+
if value:
|
|
620
|
+
return {fieldname: value}
|
|
621
|
+
return {}
|
|
611
622
|
return _parse_data_atom
|
|
612
623
|
|
|
613
624
|
@classmethod
|
|
@@ -645,13 +656,11 @@ class _MP4(TinyTag):
|
|
|
645
656
|
break
|
|
646
657
|
|
|
647
658
|
@classmethod
|
|
648
|
-
def _parse_custom_field(
|
|
649
|
-
cls, data: bytes
|
|
650
|
-
) -> dict[str, int | str | bytes | None]:
|
|
659
|
+
def _parse_custom_field(cls, data: bytes) -> dict[str, list[str]]:
|
|
651
660
|
fh = BytesIO(data)
|
|
652
661
|
header_len = 8
|
|
653
662
|
field_name = None
|
|
654
|
-
|
|
663
|
+
values = []
|
|
655
664
|
atom_header = fh.read(header_len)
|
|
656
665
|
while len(atom_header) == header_len:
|
|
657
666
|
atom_size = unpack('>I', atom_header[:4])[0] - header_len
|
|
@@ -662,15 +671,18 @@ class _MP4(TinyTag):
|
|
|
662
671
|
# pylint: disable=protected-access
|
|
663
672
|
field_name = cls._CUSTOM_FIELD_NAME_MAPPING.get(
|
|
664
673
|
field_name, TinyTag._OTHER_PREFIX + field_name)
|
|
665
|
-
elif atom_type == b'data':
|
|
674
|
+
elif atom_type == b'data' and field_name:
|
|
666
675
|
data_atom = fh.read(atom_size)
|
|
676
|
+
parser = cls._data_parser(field_name)
|
|
677
|
+
atom_values = parser(data_atom)
|
|
678
|
+
if field_name in atom_values:
|
|
679
|
+
values.append(atom_values[field_name])
|
|
667
680
|
else:
|
|
668
681
|
fh.seek(atom_size, SEEK_CUR)
|
|
669
682
|
atom_header = fh.read(header_len) # read next atom
|
|
670
|
-
if
|
|
671
|
-
return {}
|
|
672
|
-
|
|
673
|
-
return parser(data_atom)
|
|
683
|
+
if field_name and values:
|
|
684
|
+
return {field_name: values}
|
|
685
|
+
return {}
|
|
674
686
|
|
|
675
687
|
@classmethod
|
|
676
688
|
def _parse_audio_sample_entry_mp4a(cls, data: bytes) -> dict[str, int]:
|
|
@@ -917,20 +929,17 @@ class _ID3(TinyTag):
|
|
|
917
929
|
max_estimation_frames = (
|
|
918
930
|
(self._MAX_ESTIMATION_SEC * 44100) // self._SAMPLES_PER_FRAME)
|
|
919
931
|
frame_size_accu = 0
|
|
920
|
-
audio_offset =
|
|
932
|
+
audio_offset = self._bytepos_after_id3v2
|
|
921
933
|
frames = 0 # count frames for determining mp3 duration
|
|
922
934
|
bitrate_accu = 0 # add up bitrates to find average bitrate to detect
|
|
923
935
|
last_bitrates = set() # CBR mp3s (multiple frames with same bitrates)
|
|
924
936
|
# seek to first position after id3 tag (speedup for large header)
|
|
925
937
|
first_mpeg_id = None
|
|
926
938
|
fh.seek(self._bytepos_after_id3v2)
|
|
927
|
-
file_offset = fh.tell()
|
|
928
|
-
walker = BytesIO(fh.read())
|
|
929
939
|
while True:
|
|
930
940
|
# reading through garbage until 11 '1' sync-bits are found
|
|
931
|
-
header =
|
|
941
|
+
header = fh.read(4)
|
|
932
942
|
header_len = len(header)
|
|
933
|
-
walker.seek(-header_len, SEEK_CUR)
|
|
934
943
|
if header_len < 4:
|
|
935
944
|
if frames:
|
|
936
945
|
self.bitrate = bitrate_accu / frames
|
|
@@ -949,10 +958,12 @@ class _ID3(TinyTag):
|
|
|
949
958
|
or mpeg_id == 1):
|
|
950
959
|
# invalid frame, find next sync header
|
|
951
960
|
idx = header.find(b'\xFF', 1)
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
961
|
+
next_offset = header_len
|
|
962
|
+
if idx != -1:
|
|
963
|
+
next_offset -= idx
|
|
964
|
+
fh.seek(idx - header_len, SEEK_CUR)
|
|
965
|
+
if frames == 0:
|
|
966
|
+
audio_offset += next_offset
|
|
956
967
|
continue
|
|
957
968
|
if first_mpeg_id is None:
|
|
958
969
|
first_mpeg_id = mpeg_id
|
|
@@ -964,12 +975,12 @@ class _ID3(TinyTag):
|
|
|
964
975
|
# all the info we need, otherwise parse multiple frames to find the
|
|
965
976
|
# accurate average bitrate
|
|
966
977
|
if frames == 0 and self._USE_XING_HEADER:
|
|
967
|
-
|
|
968
|
-
frame_content =
|
|
978
|
+
prev_offset = header_len + audio_offset
|
|
979
|
+
frame_content = fh.read(frame_length)
|
|
969
980
|
xing_header_offset = frame_content.find(b'Xing')
|
|
970
981
|
if xing_header_offset != -1:
|
|
971
|
-
|
|
972
|
-
xframes, byte_count = self._parse_xing_header(
|
|
982
|
+
fh.seek(prev_offset + xing_header_offset)
|
|
983
|
+
xframes, byte_count = self._parse_xing_header(fh)
|
|
973
984
|
if xframes > 0 and byte_count > 0:
|
|
974
985
|
# MPEG-2 Audio Layer III uses 576 samples per frame
|
|
975
986
|
samples_pf = self._SAMPLES_PER_FRAME
|
|
@@ -978,12 +989,10 @@ class _ID3(TinyTag):
|
|
|
978
989
|
self.duration = dur = xframes * samples_pf / samplerate
|
|
979
990
|
self.bitrate = byte_count * 8 / dur / 1000
|
|
980
991
|
return
|
|
981
|
-
|
|
992
|
+
fh.seek(prev_offset)
|
|
982
993
|
|
|
983
994
|
frames += 1 # it's most probably a mp3 frame
|
|
984
995
|
bitrate_accu += frame_br
|
|
985
|
-
if frames == 1:
|
|
986
|
-
audio_offset = file_offset + walker.tell()
|
|
987
996
|
if frames <= self._CBR_DETECTION_FRAME_COUNT:
|
|
988
997
|
last_bitrates.add(frame_br)
|
|
989
998
|
|
|
@@ -1002,7 +1011,7 @@ class _ID3(TinyTag):
|
|
|
1002
1011
|
return
|
|
1003
1012
|
|
|
1004
1013
|
if frame_length > 1: # jump over current frame body
|
|
1005
|
-
|
|
1014
|
+
fh.seek(frame_length - header_len, SEEK_CUR)
|
|
1006
1015
|
if self.samplerate:
|
|
1007
1016
|
self.duration = frames * self._SAMPLES_PER_FRAME / self.samplerate
|
|
1008
1017
|
|
|
@@ -1045,7 +1054,8 @@ class _ID3(TinyTag):
|
|
|
1045
1054
|
fh.seek(end_pos)
|
|
1046
1055
|
|
|
1047
1056
|
def _parse_id3v1(self, fh: BinaryIO) -> None:
|
|
1048
|
-
|
|
1057
|
+
content = fh.read(3 + 30 + 30 + 30 + 4 + 30 + 1)
|
|
1058
|
+
if content[:3] != b'TAG': # check if this is an ID3 v1 tag
|
|
1049
1059
|
return
|
|
1050
1060
|
|
|
1051
1061
|
def asciidecode(x: bytes) -> str:
|
|
@@ -1053,24 +1063,23 @@ class _ID3(TinyTag):
|
|
|
1053
1063
|
x.decode(self._default_encoding or 'latin1', 'replace'))
|
|
1054
1064
|
# Only set fields that were not set by ID3v2 tags, as ID3v1
|
|
1055
1065
|
# tags are more likely to be outdated or have encoding issues
|
|
1056
|
-
fields = fh.read(30 + 30 + 30 + 4 + 30 + 1)
|
|
1057
1066
|
if not self.title:
|
|
1058
|
-
value = asciidecode(
|
|
1067
|
+
value = asciidecode(content[3:33])
|
|
1059
1068
|
if value:
|
|
1060
1069
|
self._set_field('title', value)
|
|
1061
1070
|
if not self.artist:
|
|
1062
|
-
value = asciidecode(
|
|
1071
|
+
value = asciidecode(content[33:63])
|
|
1063
1072
|
if value:
|
|
1064
1073
|
self._set_field('artist', value)
|
|
1065
1074
|
if not self.album:
|
|
1066
|
-
value = asciidecode(
|
|
1075
|
+
value = asciidecode(content[63:93])
|
|
1067
1076
|
if value:
|
|
1068
1077
|
self._set_field('album', value)
|
|
1069
1078
|
if not self.year:
|
|
1070
|
-
value = asciidecode(
|
|
1079
|
+
value = asciidecode(content[93:97])
|
|
1071
1080
|
if value:
|
|
1072
1081
|
self._set_field('year', value)
|
|
1073
|
-
comment =
|
|
1082
|
+
comment = content[97:127]
|
|
1074
1083
|
if b'\x00\x00' < comment[-2:] < b'\x01\x00':
|
|
1075
1084
|
if self.track is None:
|
|
1076
1085
|
self._set_field('track', ord(comment[-1:]))
|
|
@@ -1080,7 +1089,7 @@ class _ID3(TinyTag):
|
|
|
1080
1089
|
if value:
|
|
1081
1090
|
self._set_field('comment', value)
|
|
1082
1091
|
if not self.genre:
|
|
1083
|
-
genre_id = ord(
|
|
1092
|
+
genre_id = ord(content[127:128])
|
|
1084
1093
|
if genre_id < len(self._ID3V1_GENRES):
|
|
1085
1094
|
self._set_field('genre', self._ID3V1_GENRES[genre_id])
|
|
1086
1095
|
|
|
@@ -1140,14 +1149,13 @@ class _ID3(TinyTag):
|
|
|
1140
1149
|
if frame_size > total_size:
|
|
1141
1150
|
# invalid frame size, stop here
|
|
1142
1151
|
return 0
|
|
1143
|
-
content = fh.read(frame_size)
|
|
1144
|
-
fieldname = self._ID3_MAPPING.get(frame_id)
|
|
1145
1152
|
should_set_field = True
|
|
1146
|
-
if
|
|
1153
|
+
if frame_id in self._ID3_MAPPING:
|
|
1147
1154
|
if not self._parse_tags:
|
|
1148
1155
|
return frame_size
|
|
1156
|
+
fieldname = self._ID3_MAPPING[frame_id]
|
|
1149
1157
|
language = fieldname in {'comment', 'other.lyrics'}
|
|
1150
|
-
value = self._decode_string(
|
|
1158
|
+
value = self._decode_string(fh.read(frame_size), language)
|
|
1151
1159
|
if not value:
|
|
1152
1160
|
return frame_size
|
|
1153
1161
|
if fieldname == "comment":
|
|
@@ -1179,12 +1187,13 @@ class _ID3(TinyTag):
|
|
|
1179
1187
|
elif frame_id in self._CUSTOM_FRAME_IDS:
|
|
1180
1188
|
# custom fields
|
|
1181
1189
|
if self._parse_tags:
|
|
1182
|
-
value = self._decode_string(
|
|
1190
|
+
value = self._decode_string(fh.read(frame_size))
|
|
1183
1191
|
if value:
|
|
1184
1192
|
self.__parse_custom_field(value)
|
|
1185
1193
|
elif frame_id in self._IMAGE_FRAME_IDS:
|
|
1186
1194
|
if self._load_image:
|
|
1187
1195
|
# See section 4.14: http://id3.org/id3v2.4.0-frames
|
|
1196
|
+
content = fh.read(frame_size)
|
|
1188
1197
|
encoding = content[:1]
|
|
1189
1198
|
if frame_id == 'PIC': # ID3 v2.2:
|
|
1190
1199
|
imgformat = self._decode_string(content[1:4]).lower()
|
|
@@ -1207,6 +1216,11 @@ class _ID3(TinyTag):
|
|
|
1207
1216
|
if content[i:i + 2] == b'\x00\x00':
|
|
1208
1217
|
desc_end_pos = i + 2
|
|
1209
1218
|
break
|
|
1219
|
+
# skip stray null byte in broken file
|
|
1220
|
+
if (desc_end_pos + 1 < len(content)
|
|
1221
|
+
and content[desc_end_pos] == 0
|
|
1222
|
+
and content[desc_end_pos + 1] != 0):
|
|
1223
|
+
desc_end_pos += 1
|
|
1210
1224
|
desc = self._decode_string(
|
|
1211
1225
|
encoding + content[desc_start_pos:desc_end_pos])
|
|
1212
1226
|
field_name, image = self._create_tag_image(
|
|
@@ -1216,10 +1230,12 @@ class _ID3(TinyTag):
|
|
|
1216
1230
|
elif frame_id not in self._IGNORED_FRAME_IDS:
|
|
1217
1231
|
# unknown, try to add to other dict
|
|
1218
1232
|
if self._parse_tags:
|
|
1219
|
-
value = self._decode_string(
|
|
1233
|
+
value = self._decode_string(fh.read(frame_size))
|
|
1220
1234
|
if value:
|
|
1221
1235
|
self._set_field(
|
|
1222
1236
|
self._OTHER_PREFIX + frame_id.lower(), value)
|
|
1237
|
+
else: # skip frame
|
|
1238
|
+
fh.seek(frame_size, SEEK_CUR)
|
|
1223
1239
|
return frame_size
|
|
1224
1240
|
|
|
1225
1241
|
def _decode_string(self, value: bytes, language: bool = False) -> str:
|
|
@@ -1450,7 +1466,7 @@ class _Ogg(TinyTag):
|
|
|
1450
1466
|
elif value:
|
|
1451
1467
|
self._set_field(fieldname, value)
|
|
1452
1468
|
|
|
1453
|
-
def _parse_pages(self, fh: BinaryIO) -> Iterator[
|
|
1469
|
+
def _parse_pages(self, fh: BinaryIO) -> Iterator[bytearray]:
|
|
1454
1470
|
# for the spec, see: https://wiki.xiph.org/Ogg
|
|
1455
1471
|
packet_data = bytearray()
|
|
1456
1472
|
current_serial = None
|
|
@@ -1582,8 +1598,8 @@ class _Wave(TinyTag):
|
|
|
1582
1598
|
data_length += data_length % 2
|
|
1583
1599
|
# strip zero-byte
|
|
1584
1600
|
data = walker.read(data_length).split(b'\x00', 1)[0]
|
|
1585
|
-
|
|
1586
|
-
|
|
1601
|
+
if field in self._RIFF_MAPPING:
|
|
1602
|
+
fieldname = self._RIFF_MAPPING[field]
|
|
1587
1603
|
value = data.decode('utf-8', 'replace')
|
|
1588
1604
|
if fieldname == 'track':
|
|
1589
1605
|
if value.isdecimal():
|
|
@@ -1804,8 +1820,9 @@ class _Wma(TinyTag):
|
|
|
1804
1820
|
walker.seek(value_len, SEEK_CUR) # skip other values
|
|
1805
1821
|
continue
|
|
1806
1822
|
# try to get normalized field name
|
|
1807
|
-
|
|
1808
|
-
|
|
1823
|
+
if name in self._ASF_MAPPING:
|
|
1824
|
+
field_name = self._ASF_MAPPING[name]
|
|
1825
|
+
else: # custom field
|
|
1809
1826
|
if name.startswith('WM/'):
|
|
1810
1827
|
name = name[3:]
|
|
1811
1828
|
field_name = self._OTHER_PREFIX + name.lower()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|