eyeD3 0.9.8__py3-none-any.whl

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.
eyed3/__about__.py ADDED
@@ -0,0 +1,27 @@
1
+ from .__regarding__ import * # noqa: F403
2
+
3
+ __project_name__ = project_name
4
+ __version__ = version
5
+ __version_info__ = version_info
6
+ __release_name__ = version_info.release_name
7
+ __years__ = years
8
+
9
+ __project_slug__ = "eyed3"
10
+ __pypi_name__ = "eyeD3"
11
+ __author__ = author
12
+ __author_email__ = author_email
13
+ __url__ = homepage
14
+ __description__ = description
15
+ # FIXME: __long_description__ not being used anywhere.
16
+ __long_description__ = """
17
+ eyeD3 is a Python module and command line program for processing ID3 tags.
18
+ Information about mp3 files (i.e bit rate, sample frequency,
19
+ play time, etc.) is also provided. The formats supported are ID3
20
+ v1.0/v1.1 and v2.3/v2.4.
21
+ """
22
+ __license__ = "GNU GPL v3.0"
23
+ __github_url__ = "https://github.com/nicfit/eyeD3",
24
+ __version_txt__ = """%(__project_name__)s %(__version__)s © Copyright %(__years__)s %(__author__)s
25
+ This program comes with ABSOLUTELY NO WARRANTY! See LICENSE for details.
26
+ Run with --help/-h for usage information or read the docs at
27
+ %(__url__)s""" % (locals())
eyed3/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ import sys
2
+ import codecs
3
+ import locale
4
+ from .__about__ import __version__ as version
5
+
6
+ _DEFAULT_ENCODING = "latin1"
7
+ # The local encoding, used when parsing command line options, console output,
8
+ # etc. The default is always ``latin1`` if it cannot be determined, it is NOT
9
+ # the value shown.
10
+ LOCAL_ENCODING = locale.getpreferredencoding(do_setlocale=True)
11
+ if not LOCAL_ENCODING or LOCAL_ENCODING == "ANSI_X3.4-1968": # pragma: no cover
12
+ LOCAL_ENCODING = _DEFAULT_ENCODING
13
+
14
+ # The local file system encoding, the default is ``latin1`` if it cannot be determined.
15
+ LOCAL_FS_ENCODING = sys.getfilesystemencoding()
16
+ if not LOCAL_FS_ENCODING: # pragma: no cover
17
+ LOCAL_FS_ENCODING = _DEFAULT_ENCODING
18
+
19
+
20
+ class Error(Exception):
21
+ """Base exception type for all eyed3 errors."""
22
+ def __init__(self, *args):
23
+ super().__init__(*args)
24
+ if args:
25
+ # The base class will do exactly this if len(args) == 1,
26
+ # but not when > 1. Note, the 2.7 base class will, 3 will not.
27
+ # Make it so.
28
+ self.message = args[0]
29
+
30
+
31
+ from .utils.log import log # noqa: E402
32
+ from .core import load, AudioFile # noqa: E402
33
+
34
+ del sys
35
+ del codecs
36
+ del locale
37
+
38
+ __all__ = ["AudioFile", "load", "log", "version", "LOCAL_ENCODING", "LOCAL_FS_ENCODING", "Error"]
eyed3/__regarding__.py ADDED
@@ -0,0 +1,48 @@
1
+ """
2
+ ~~~~~~~~~~ DO NOT EDIT THIS FILE! Autogenerated by `regarding` ~~~~~~~~~~
3
+ https://github.com/nicfit/regarding
4
+ """
5
+ import dataclasses
6
+ from typing import Optional
7
+
8
+
9
+ __all__ = ["project_name", "version", "version_info",
10
+ "author", "author_email", "years", "description", "homepage"]
11
+
12
+
13
+ @dataclasses.dataclass
14
+ class Version:
15
+ major: int
16
+ minor: int
17
+ micro: int
18
+ dev: Optional[int]
19
+ pre: Optional[tuple[str, int]]
20
+ post: Optional[int]
21
+ release_name: Optional[str]
22
+
23
+
24
+ project_name = "eyeD3"
25
+ version = "0.9.8"
26
+ version_info = Version(
27
+ 0, 9, 8,
28
+ None,
29
+ None,
30
+ None,
31
+ "Armed & Dangerous",
32
+ )
33
+
34
+ author = "Travis Shirk"
35
+ author_email = "travis@pobox.com"
36
+ years = "2002-2025"
37
+ description = "Python audio data toolkit (ID3 and MP3)"
38
+ homepage = "https://eyeD3.nicfit.net/"
39
+
40
+
41
+ def versionBanner() -> str:
42
+ """Return the version string including release name."""
43
+ v = ""
44
+ if version:
45
+ v = version
46
+ if version_info is not None and version_info.release_name:
47
+ v = "%s (%s)" % (v, version_info.release_name)
48
+ return v
eyed3/core.py ADDED
@@ -0,0 +1,484 @@
1
+ """Basic core types and utilities."""
2
+ import os
3
+ import time
4
+ import functools
5
+ import pathlib
6
+ import dataclasses
7
+ from collections import namedtuple
8
+ from typing import Optional
9
+
10
+ from . import LOCAL_FS_ENCODING
11
+ from .utils.log import getLogger
12
+ log = getLogger(__name__)
13
+
14
+ # Audio type selector for no audio.
15
+ AUDIO_NONE = 0
16
+ # Audio type selector for MPEG (mp3) audio.
17
+ AUDIO_MP3 = 1
18
+
19
+ AUDIO_TYPES = (AUDIO_NONE, AUDIO_MP3)
20
+
21
+ LP_TYPE = "lp"
22
+ EP_TYPE = "ep"
23
+ EP_MAX_SIZE_HINT = 6
24
+ COMP_TYPE = "compilation"
25
+ LIVE_TYPE = "live"
26
+ VARIOUS_TYPE = "various"
27
+ DEMO_TYPE = "demo"
28
+ SINGLE_TYPE = "single"
29
+ ALBUM_TYPE_IDS = [LP_TYPE, EP_TYPE, COMP_TYPE, LIVE_TYPE, VARIOUS_TYPE,
30
+ DEMO_TYPE, SINGLE_TYPE]
31
+ VARIOUS_ARTISTS = "Various Artists"
32
+
33
+ # A key that can be used in a TXXX frame to specify the type of collection
34
+ # (or album) a file belongs. See :class:`eyed3.core.ALBUM_TYPE_IDS`.
35
+ TXXX_ALBUM_TYPE = "eyeD3#album_type"
36
+
37
+ # A key that can be used in a TXXX frame to specify the origin of an
38
+ # artist/band. i.e. where they are from.
39
+ # The format is: city<tab>state<tab>country
40
+ TXXX_ARTIST_ORIGIN = "eyeD3#artist_origin"
41
+
42
+ # A 2-tuple for count and a total count. e.g. track 3 of 10, count of total.
43
+ CountAndTotalTuple = namedtuple("CountAndTotalTuple", "count, total")
44
+
45
+
46
+ @dataclasses.dataclass
47
+ class ArtistOrigin:
48
+ city: str
49
+ state: str
50
+ country: str
51
+
52
+ def __bool__(self):
53
+ return bool(self.city or self.state or self.country)
54
+
55
+ def id3Encode(self):
56
+ return "\t".join([(o if o else "") for o in dataclasses.astuple(self)])
57
+
58
+
59
+ @dataclasses.dataclass
60
+ class AudioInfo:
61
+ """A base container for common audio details."""
62
+
63
+ # The number of seconds of audio data (i.e., the playtime)
64
+ time_secs: float
65
+ # The number of bytes of audio data.
66
+ size_bytes: int
67
+
68
+ def __post_init__(self):
69
+ self.time_secs = int(self.time_secs * 100.0) / 100.0
70
+
71
+
72
+ class Tag:
73
+ """An abstract interface for audio tag (meta) data (e.g. artist, title,
74
+ etc.)
75
+ """
76
+
77
+ read_only: bool = False
78
+
79
+ def _setArtist(self, val):
80
+ raise NotImplementedError() # pragma: nocover
81
+
82
+ def _getArtist(self):
83
+ raise NotImplementedError() # pragma: nocover
84
+
85
+ def _getAlbumArtist(self):
86
+ raise NotImplementedError() # pragma: nocover
87
+
88
+ def _setAlbumArtist(self, val):
89
+ raise NotImplementedError() # pragma: nocover
90
+
91
+ def _setAlbum(self, val):
92
+ raise NotImplementedError() # pragma: nocover
93
+
94
+ def _getAlbum(self):
95
+ raise NotImplementedError() # pragma: nocover
96
+
97
+ def _setTitle(self, val):
98
+ raise NotImplementedError() # pragma: nocover
99
+
100
+ def _getTitle(self):
101
+ raise NotImplementedError() # pragma: nocover
102
+
103
+ def _setTrackNum(self, val):
104
+ raise NotImplementedError() # pragma: nocover
105
+
106
+ def _getTrackNum(self) -> CountAndTotalTuple:
107
+ raise NotImplementedError() # pragma: nocover
108
+
109
+ @property
110
+ def artist(self):
111
+ return self._getArtist()
112
+
113
+ @artist.setter
114
+ def artist(self, v):
115
+ self._setArtist(v)
116
+
117
+ @property
118
+ def album_artist(self):
119
+ return self._getAlbumArtist()
120
+
121
+ @album_artist.setter
122
+ def album_artist(self, v):
123
+ self._setAlbumArtist(v)
124
+
125
+ @property
126
+ def album(self):
127
+ return self._getAlbum()
128
+
129
+ @album.setter
130
+ def album(self, v):
131
+ self._setAlbum(v)
132
+
133
+ @property
134
+ def title(self):
135
+ return self._getTitle()
136
+
137
+ @title.setter
138
+ def title(self, v):
139
+ self._setTitle(v)
140
+
141
+ @property
142
+ def track_num(self) -> CountAndTotalTuple:
143
+ """Track number property.
144
+ Must return a 2-tuple of (track-number, total-number-of-tracks).
145
+ Either tuple value may be ``None``.
146
+ """
147
+ return self._getTrackNum()
148
+
149
+ @track_num.setter
150
+ def track_num(self, v):
151
+ self._setTrackNum(v)
152
+
153
+ def __init__(self, title=None, artist=None, album=None, album_artist=None, track_num=None):
154
+ self.title = title
155
+ self.artist = artist
156
+ self.album = album
157
+ self.album_artist = album_artist
158
+ self.track_num = track_num
159
+
160
+
161
+ class AudioFile:
162
+ """Abstract base class for audio file types (AudioInfo + Tag)"""
163
+ _tag: Tag = None
164
+ _info: AudioInfo = None
165
+
166
+ def _read(self):
167
+ """Subclasses MUST override this method and set ``self._info``,
168
+ ``self._tag`` and ``self.type``.
169
+ """
170
+ raise NotImplementedError()
171
+
172
+ def initTag(self, version=None):
173
+ raise NotImplementedError()
174
+
175
+ def rename(self, name, fsencoding=LOCAL_FS_ENCODING,
176
+ preserve_file_time=False):
177
+ """Rename the file to ``name``.
178
+ The encoding used for the file name is :attr:`eyed3.LOCAL_FS_ENCODING`
179
+ unless overridden by ``fsencoding``. Note, if the target file already
180
+ exists, or the full path contains non-existent directories the
181
+ operation will fail with :class:`IOError`.
182
+ File times are not modified when ``preserve_file_time`` is ``True``,
183
+ ``False`` is the default.
184
+ """
185
+ curr_path = pathlib.Path(self.path)
186
+ ext = curr_path.suffix
187
+
188
+ new_path = curr_path.parent / "{name}{ext}".format(**locals())
189
+ if new_path.exists():
190
+ raise IOError(f"File '{new_path}' exists, will not overwrite")
191
+ elif not new_path.parent.exists():
192
+ raise IOError("Target directory '%s' does not exists, will not "
193
+ "create" % new_path.parent)
194
+
195
+ os.rename(self.path, str(new_path))
196
+ if self.tag:
197
+ self.tag.file_info.name = str(new_path)
198
+ if preserve_file_time:
199
+ self.tag.file_info.touch((self.tag.file_info.atime,
200
+ self.tag.file_info.mtime))
201
+
202
+ self.path = str(new_path)
203
+
204
+ @property
205
+ def path(self):
206
+ """The absolute path of this file."""
207
+ return self._path
208
+
209
+ @path.setter
210
+ def path(self, path):
211
+ """Set the path"""
212
+ if isinstance(path, pathlib.Path):
213
+ path = str(path)
214
+ self._path = path
215
+
216
+ @property
217
+ def info(self) -> AudioInfo:
218
+ """Returns a concrete implementation of :class:`eyed3.core.AudioInfo`"""
219
+ return self._info
220
+
221
+ @property
222
+ def tag(self):
223
+ """Returns a concrete implementation of :class:`eyed3.core.Tag`"""
224
+ return self._tag
225
+
226
+ @tag.setter
227
+ def tag(self, t):
228
+ self._tag = t
229
+
230
+ def __init__(self, path):
231
+ """Construct with a path and invoke ``_read``.
232
+ All other members are set to None."""
233
+ if isinstance(path, pathlib.Path):
234
+ path = str(path)
235
+ self.path = path
236
+
237
+ self.type = None
238
+ self._info = None
239
+ self._tag = None
240
+ self._read()
241
+
242
+ def __str__(self):
243
+ return str(self.path)
244
+
245
+
246
+ @functools.total_ordering
247
+ class Date:
248
+ """
249
+ A class for representing a date and time (optional). This class differs
250
+ from ``datetime.datetime`` in that the default values for month, day,
251
+ hour, minute, and second is ``None`` and not 'January 1, 00:00:00'.
252
+ This allows for an object that is simply 1987, and not January 1 12AM,
253
+ for example. But when more resolution is required those vales can be set
254
+ as well.
255
+ """
256
+
257
+ TIME_STAMP_FORMATS = ["%Y",
258
+ "%Y-%m",
259
+ "%Y-%m-%d",
260
+ "%Y-%m-%dT%H",
261
+ "%Y-%m-%dT%H:%M",
262
+ "%Y-%m-%dT%H:%M:%S",
263
+ # The following end with 'Z' signally time is UTC
264
+ "%Y-%m-%dT%HZ",
265
+ "%Y-%m-%dT%H:%MZ",
266
+ "%Y-%m-%dT%H:%M:%SZ",
267
+ # The following are wrong per the specs, but ...
268
+ "%Y-%m-%d %H:%M:%S",
269
+ "%Y-00-00",
270
+ "%Y%m%d",
271
+ # Special formats to support ID3v2.3 TDAT and TIME frames.
272
+ # See https://github.com/nicfit/eyeD3/pull/623 and frames.Date.date setter
273
+ "D%d-%m",
274
+ "T%H:%M",
275
+ ]
276
+ """Valid time stamp formats per ISO 8601 and used by `strptime`."""
277
+
278
+ @classmethod
279
+ def __new__(cls, *args, **kwargs):
280
+ if ([arg for arg in args[1:] if arg is not None] or
281
+ [kwarg for kwarg in kwargs.values() if kwarg is not None]):
282
+ return super().__new__(cls)
283
+ else:
284
+ return
285
+
286
+ def __init__(self, year=None, month=None, day=None,
287
+ hour=None, minute=None, second=None):
288
+ # Validate with datetime
289
+ from datetime import datetime
290
+ _ = datetime(year if year is not None else 1899,
291
+ month if month is not None else 1,
292
+ day if day is not None else 1,
293
+ hour if hour is not None else 0,
294
+ minute if minute is not None else 0,
295
+ second if second is not None else 0)
296
+
297
+ self._year = year
298
+ self._month = month
299
+ self._day = day
300
+ self._hour = hour
301
+ self._minute = minute
302
+ self._second = second
303
+
304
+ # Python's date classes do a lot more date validation than does not
305
+ # need to be duplicated here. Validate it
306
+ _ = Date._validateFormat(str(self)) # noqa
307
+
308
+ @property
309
+ def year(self):
310
+ return self._year
311
+
312
+ @property
313
+ def month(self):
314
+ return self._month
315
+
316
+ @property
317
+ def day(self):
318
+ return self._day
319
+
320
+ @property
321
+ def hour(self):
322
+ return self._hour
323
+
324
+ @property
325
+ def minute(self):
326
+ return self._minute
327
+
328
+ @property
329
+ def second(self):
330
+ return self._second
331
+
332
+ def __eq__(self, rhs):
333
+ if not rhs:
334
+ return False
335
+
336
+ return (self.year == rhs.year and
337
+ self.month == rhs.month and
338
+ self.day == rhs.day and
339
+ self.hour == rhs.hour and
340
+ self.minute == rhs.minute and
341
+ self.second == rhs.second)
342
+
343
+ def __ne__(self, rhs):
344
+ return not (self == rhs)
345
+
346
+ def __lt__(self, rhs):
347
+ if not rhs:
348
+ return False
349
+
350
+ for left, right in ((self.year, rhs.year),
351
+ (self.month, rhs.month),
352
+ (self.day, rhs.day),
353
+ (self.hour, rhs.hour),
354
+ (self.minute, rhs.minute),
355
+ (self.second, rhs.second)):
356
+
357
+ left = left if left is not None else -1
358
+ right = right if right is not None else -1
359
+
360
+ if left < right:
361
+ return True
362
+ elif left > right:
363
+ return False
364
+
365
+ return False
366
+
367
+ def __hash__(self):
368
+ return hash(str(self))
369
+
370
+ @staticmethod
371
+ def _validateFormat(s):
372
+ pdate, fmt = None, None
373
+ for fmt in Date.TIME_STAMP_FORMATS:
374
+ try:
375
+ pdate = time.strptime(s, fmt)
376
+ break
377
+ except ValueError:
378
+ # date string did not match format.
379
+ continue
380
+
381
+ if pdate is None:
382
+ raise ValueError(f"Invalid date string: {s}")
383
+
384
+ assert pdate
385
+ return pdate, fmt
386
+
387
+ @staticmethod
388
+ def parse(s):
389
+ """Parses date strings that conform to ISO-8601."""
390
+ if not isinstance(s, str):
391
+ s = s.decode("ascii")
392
+ s = s.strip('\x00')
393
+
394
+ pdate, fmt = Date._validateFormat(s)
395
+
396
+ # Here is the difference with Python date/datetime objects, some
397
+ # of the members can be None
398
+ kwargs = {}
399
+ if "%Y" in fmt:
400
+ kwargs["year"] = pdate.tm_year
401
+ if "%m" in fmt:
402
+ kwargs["month"] = pdate.tm_mon
403
+ if "%d" in fmt:
404
+ kwargs["day"] = pdate.tm_mday
405
+ if "%H" in fmt:
406
+ kwargs["hour"] = pdate.tm_hour
407
+ if "%M" in fmt:
408
+ kwargs["minute"] = pdate.tm_min
409
+ if "%S" in fmt:
410
+ kwargs["second"] = pdate.tm_sec
411
+
412
+ return Date(**kwargs)
413
+
414
+ def __str__(self):
415
+ """Returns date strings that conform to ISO-8601.
416
+ The returned string will be no larger than 17 characters."""
417
+ s = "" # the string
418
+ c = "" # the separator character
419
+ if self.year is not None: # branch 1, aka "there is a year, maybe more"
420
+ s += "%d" % self.year
421
+ c = "-"
422
+ if self.month is not None: # there is a month
423
+ s += c + "%s" % str(self.month).rjust(2, '0')
424
+ if self.day is not None: # there is a day
425
+ s += c + "%s" % str(self.day).rjust(2, '0')
426
+ else: # branch 2, aka "we start without a year" aka "D%d-%m" format
427
+ c = "D"
428
+ if (self.day is not None) and (self.month is not None): # checking both
429
+ s += c + "%s" % str(self.day).rjust(2, '0')
430
+ c = "-"
431
+ s += c + "%s" % str(self.month).rjust(2, '0')
432
+ return s # We send a "Ddd-mm" string for 'TDAT'
433
+ # Here is the 'TIME' part, which starts or continues the string from branch 1
434
+ c = "T"
435
+ if self.hour is not None:
436
+ s += c + "%s" % str(self.hour).rjust(2, '0')
437
+ c = ":"
438
+ if self.minute is not None:
439
+ s += c + "%s" % str(self.minute).rjust(2, '0')
440
+ return s # We send either a YYYY-mm-ddTHH:MM, or just a THH:MM (for 'TIME')
441
+
442
+
443
+ def parseError(ex) -> None:
444
+ """A function that is invoked when non-fatal parse, format, etc. errors
445
+ occur. In most cases the invalid values will be ignored or possibly fixed.
446
+ This function simply logs the error."""
447
+ log.warning(ex)
448
+
449
+
450
+ def load(path, tag_version=None) -> Optional[AudioFile]:
451
+ """Loads the file identified by ``path`` and returns a concrete type of
452
+ :class:`eyed3.core.AudioFile`. If ``path`` is not a file an ``IOError`` is
453
+ raised. ``None`` is returned when the file type (i.e. mime-type) is not
454
+ recognized.
455
+ The following AudioFile types are supported:
456
+
457
+ * :class:`eyed3.mp3.Mp3AudioFile` - For mp3 audio files.
458
+ * :class:`eyed3.id3.TagFile` - For raw ID3 data files.
459
+
460
+ If ``tag_version`` is not None (the default) only a specific version of
461
+ metadata is loaded. This value must be a version constant specific to the
462
+ eventual format of the metadata.
463
+ """
464
+ from . import mimetype, mp3, id3
465
+
466
+ if not isinstance(path, pathlib.Path):
467
+ path = pathlib.Path(path)
468
+ log.debug(f"Loading file: {path}")
469
+
470
+ if path.exists():
471
+ if not path.is_file():
472
+ raise IOError(f"not a file: {path}")
473
+ else:
474
+ raise IOError(f"file not found: {path}")
475
+
476
+ mtype = mimetype.guessMimetype(path)
477
+ log.debug(f"File mime-type: {mtype}")
478
+
479
+ if mtype in mp3.MIME_TYPES:
480
+ return mp3.Mp3AudioFile(path, tag_version)
481
+ elif mtype == id3.ID3_MIME_TYPE:
482
+ return id3.TagFile(path, tag_version)
483
+ else:
484
+ return None