eyeD3 0.9.8a1__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.8a1"
26
+ version_info = Version(
27
+ 0, 9, 8,
28
+ None,
29
+ ('a', 1),
30
+ None,
31
+ "Refuse/Resist",
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,457 @@
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
+ ]
272
+ """Valid time stamp formats per ISO 8601 and used by `strptime`."""
273
+
274
+ def __init__(self, year, month=None, day=None,
275
+ hour=None, minute=None, second=None):
276
+ # Validate with datetime
277
+ from datetime import datetime
278
+ _ = datetime(year, month if month is not None else 1,
279
+ day if day is not None else 1,
280
+ hour if hour is not None else 0,
281
+ minute if minute is not None else 0,
282
+ second if second is not None else 0)
283
+
284
+ self._year = year
285
+ self._month = month
286
+ self._day = day
287
+ self._hour = hour
288
+ self._minute = minute
289
+ self._second = second
290
+
291
+ # Python's date classes do a lot more date validation than does not
292
+ # need to be duplicated here. Validate it
293
+ _ = Date._validateFormat(str(self)) # noqa
294
+
295
+ @property
296
+ def year(self):
297
+ return self._year
298
+
299
+ @property
300
+ def month(self):
301
+ return self._month
302
+
303
+ @property
304
+ def day(self):
305
+ return self._day
306
+
307
+ @property
308
+ def hour(self):
309
+ return self._hour
310
+
311
+ @property
312
+ def minute(self):
313
+ return self._minute
314
+
315
+ @property
316
+ def second(self):
317
+ return self._second
318
+
319
+ def __eq__(self, rhs):
320
+ if not rhs:
321
+ return False
322
+
323
+ return (self.year == rhs.year and
324
+ self.month == rhs.month and
325
+ self.day == rhs.day and
326
+ self.hour == rhs.hour and
327
+ self.minute == rhs.minute and
328
+ self.second == rhs.second)
329
+
330
+ def __ne__(self, rhs):
331
+ return not (self == rhs)
332
+
333
+ def __lt__(self, rhs):
334
+ if not rhs:
335
+ return False
336
+
337
+ for left, right in ((self.year, rhs.year),
338
+ (self.month, rhs.month),
339
+ (self.day, rhs.day),
340
+ (self.hour, rhs.hour),
341
+ (self.minute, rhs.minute),
342
+ (self.second, rhs.second)):
343
+
344
+ left = left if left is not None else -1
345
+ right = right if right is not None else -1
346
+
347
+ if left < right:
348
+ return True
349
+ elif left > right:
350
+ return False
351
+
352
+ return False
353
+
354
+ def __hash__(self):
355
+ return hash(str(self))
356
+
357
+ @staticmethod
358
+ def _validateFormat(s):
359
+ pdate, fmt = None, None
360
+ for fmt in Date.TIME_STAMP_FORMATS:
361
+ try:
362
+ pdate = time.strptime(s, fmt)
363
+ break
364
+ except ValueError:
365
+ # date string did not match format.
366
+ continue
367
+
368
+ if pdate is None:
369
+ raise ValueError(f"Invalid date string: {s}")
370
+
371
+ assert pdate
372
+ return pdate, fmt
373
+
374
+ @staticmethod
375
+ def parse(s):
376
+ """Parses date strings that conform to ISO-8601."""
377
+ if not isinstance(s, str):
378
+ s = s.decode("ascii")
379
+ s = s.strip('\x00')
380
+
381
+ pdate, fmt = Date._validateFormat(s)
382
+
383
+ # Here is the difference with Python date/datetime objects, some
384
+ # of the members can be None
385
+ kwargs = {}
386
+ if "%m" in fmt:
387
+ kwargs["month"] = pdate.tm_mon
388
+ if "%d" in fmt:
389
+ kwargs["day"] = pdate.tm_mday
390
+ if "%H" in fmt:
391
+ kwargs["hour"] = pdate.tm_hour
392
+ if "%M" in fmt:
393
+ kwargs["minute"] = pdate.tm_min
394
+ if "%S" in fmt:
395
+ kwargs["second"] = pdate.tm_sec
396
+
397
+ return Date(pdate.tm_year, **kwargs)
398
+
399
+ def __str__(self):
400
+ """Returns date strings that conform to ISO-8601.
401
+ The returned string will be no larger than 17 characters."""
402
+ s = "%d" % self.year
403
+ if self.month:
404
+ s += "-%s" % str(self.month).rjust(2, '0')
405
+ if self.day:
406
+ s += "-%s" % str(self.day).rjust(2, '0')
407
+ if self.hour is not None:
408
+ s += "T%s" % str(self.hour).rjust(2, '0')
409
+ if self.minute is not None:
410
+ s += ":%s" % str(self.minute).rjust(2, '0')
411
+ if self.second is not None:
412
+ s += ":%s" % str(self.second).rjust(2, '0')
413
+ return s
414
+
415
+
416
+ def parseError(ex) -> None:
417
+ """A function that is invoked when non-fatal parse, format, etc. errors
418
+ occur. In most cases the invalid values will be ignored or possibly fixed.
419
+ This function simply logs the error."""
420
+ log.warning(ex)
421
+
422
+
423
+ def load(path, tag_version=None) -> Optional[AudioFile]:
424
+ """Loads the file identified by ``path`` and returns a concrete type of
425
+ :class:`eyed3.core.AudioFile`. If ``path`` is not a file an ``IOError`` is
426
+ raised. ``None`` is returned when the file type (i.e. mime-type) is not
427
+ recognized.
428
+ The following AudioFile types are supported:
429
+
430
+ * :class:`eyed3.mp3.Mp3AudioFile` - For mp3 audio files.
431
+ * :class:`eyed3.id3.TagFile` - For raw ID3 data files.
432
+
433
+ If ``tag_version`` is not None (the default) only a specific version of
434
+ metadata is loaded. This value must be a version constant specific to the
435
+ eventual format of the metadata.
436
+ """
437
+ from . import mimetype, mp3, id3
438
+
439
+ if not isinstance(path, pathlib.Path):
440
+ path = pathlib.Path(path)
441
+ log.debug(f"Loading file: {path}")
442
+
443
+ if path.exists():
444
+ if not path.is_file():
445
+ raise IOError(f"not a file: {path}")
446
+ else:
447
+ raise IOError(f"file not found: {path}")
448
+
449
+ mtype = mimetype.guessMimetype(path)
450
+ log.debug(f"File mime-type: {mtype}")
451
+
452
+ if mtype in mp3.MIME_TYPES:
453
+ return mp3.Mp3AudioFile(path, tag_version)
454
+ elif mtype == id3.ID3_MIME_TYPE:
455
+ return id3.TagFile(path, tag_version)
456
+ else:
457
+ return None