-
Notifications
You must be signed in to change notification settings - Fork 60
/
Copy pathcore.py
456 lines (362 loc) · 13.3 KB
/
core.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
"""Basic core types and utilities."""
import os
import time
import functools
import pathlib
import dataclasses
from collections import namedtuple
from typing import Optional
from . import LOCAL_FS_ENCODING
from .utils.log import getLogger
log = getLogger(__name__)
# Audio type selector for no audio.
AUDIO_NONE = 0
# Audio type selector for MPEG (mp3) audio.
AUDIO_MP3 = 1
AUDIO_TYPES = (AUDIO_NONE, AUDIO_MP3)
LP_TYPE = "lp"
EP_TYPE = "ep"
EP_MAX_SIZE_HINT = 6
COMP_TYPE = "compilation"
LIVE_TYPE = "live"
VARIOUS_TYPE = "various"
DEMO_TYPE = "demo"
SINGLE_TYPE = "single"
ALBUM_TYPE_IDS = [LP_TYPE, EP_TYPE, COMP_TYPE, LIVE_TYPE, VARIOUS_TYPE,
DEMO_TYPE, SINGLE_TYPE]
VARIOUS_ARTISTS = "Various Artists"
# A key that can be used in a TXXX frame to specify the type of collection
# (or album) a file belongs. See :class:`eyed3.core.ALBUM_TYPE_IDS`.
TXXX_ALBUM_TYPE = "eyeD3#album_type"
# A key that can be used in a TXXX frame to specify the origin of an
# artist/band. i.e. where they are from.
# The format is: city<tab>state<tab>country
TXXX_ARTIST_ORIGIN = "eyeD3#artist_origin"
# A 2-tuple for count and a total count. e.g. track 3 of 10, count of total.
CountAndTotalTuple = namedtuple("CountAndTotalTuple", "count, total")
@dataclasses.dataclass
class ArtistOrigin:
city: str
state: str
country: str
def __bool__(self):
return bool(self.city or self.state or self.country)
def id3Encode(self):
return "\t".join([(o if o else "") for o in dataclasses.astuple(self)])
@dataclasses.dataclass
class AudioInfo:
"""A base container for common audio details."""
# The number of seconds of audio data (i.e., the playtime)
time_secs: float
# The number of bytes of audio data.
size_bytes: int
def __post_init__(self):
self.time_secs = int(self.time_secs * 100.0) / 100.0
class Tag:
"""An abstract interface for audio tag (meta) data (e.g. artist, title,
etc.)
"""
read_only: bool = False
def _setArtist(self, val):
raise NotImplementedError() # pragma: nocover
def _getArtist(self):
raise NotImplementedError() # pragma: nocover
def _getAlbumArtist(self):
raise NotImplementedError() # pragma: nocover
def _setAlbumArtist(self, val):
raise NotImplementedError() # pragma: nocover
def _setAlbum(self, val):
raise NotImplementedError() # pragma: nocover
def _getAlbum(self):
raise NotImplementedError() # pragma: nocover
def _setTitle(self, val):
raise NotImplementedError() # pragma: nocover
def _getTitle(self):
raise NotImplementedError() # pragma: nocover
def _setTrackNum(self, val):
raise NotImplementedError() # pragma: nocover
def _getTrackNum(self) -> CountAndTotalTuple:
raise NotImplementedError() # pragma: nocover
@property
def artist(self):
return self._getArtist()
@artist.setter
def artist(self, v):
self._setArtist(v)
@property
def album_artist(self):
return self._getAlbumArtist()
@album_artist.setter
def album_artist(self, v):
self._setAlbumArtist(v)
@property
def album(self):
return self._getAlbum()
@album.setter
def album(self, v):
self._setAlbum(v)
@property
def title(self):
return self._getTitle()
@title.setter
def title(self, v):
self._setTitle(v)
@property
def track_num(self) -> CountAndTotalTuple:
"""Track number property.
Must return a 2-tuple of (track-number, total-number-of-tracks).
Either tuple value may be ``None``.
"""
return self._getTrackNum()
@track_num.setter
def track_num(self, v):
self._setTrackNum(v)
def __init__(self, title=None, artist=None, album=None, album_artist=None, track_num=None):
self.title = title
self.artist = artist
self.album = album
self.album_artist = album_artist
self.track_num = track_num
class AudioFile:
"""Abstract base class for audio file types (AudioInfo + Tag)"""
tag: Tag = None
def _read(self):
"""Subclasses MUST override this method and set ``self._info``,
``self._tag`` and ``self.type``.
"""
raise NotImplementedError()
def initTag(self, version=None):
raise NotImplementedError()
def rename(self, name, fsencoding=LOCAL_FS_ENCODING,
preserve_file_time=False):
"""Rename the file to ``name``.
The encoding used for the file name is :attr:`eyed3.LOCAL_FS_ENCODING`
unless overridden by ``fsencoding``. Note, if the target file already
exists, or the full path contains non-existent directories the
operation will fail with :class:`IOError`.
File times are not modified when ``preserve_file_time`` is ``True``,
``False`` is the default.
"""
curr_path = pathlib.Path(self.path)
ext = curr_path.suffix
new_path = curr_path.parent / "{name}{ext}".format(**locals())
if new_path.exists():
raise IOError(f"File '{new_path}' exists, will not overwrite")
elif not new_path.parent.exists():
raise IOError("Target directory '%s' does not exists, will not "
"create" % new_path.parent)
os.rename(self.path, str(new_path))
if self.tag:
self.tag.file_info.name = str(new_path)
if preserve_file_time:
self.tag.file_info.touch((self.tag.file_info.atime,
self.tag.file_info.mtime))
self.path = str(new_path)
@property
def path(self):
"""The absolute path of this file."""
return self._path
@path.setter
def path(self, path):
"""Set the path"""
if isinstance(path, pathlib.Path):
path = str(path)
self._path = path
@property
def info(self) -> AudioInfo:
"""Returns a concrete implementation of :class:`eyed3.core.AudioInfo`"""
return self._info
@property
def tag(self):
"""Returns a concrete implementation of :class:`eyed3.core.Tag`"""
return self._tag
@tag.setter
def tag(self, t):
self._tag = t
def __init__(self, path):
"""Construct with a path and invoke ``_read``.
All other members are set to None."""
if isinstance(path, pathlib.Path):
path = str(path)
self.path = path
self.type = None
self._info = None
self._tag = None
self._read()
def __str__(self):
return str(self.path)
@functools.total_ordering
class Date:
"""
A class for representing a date and time (optional). This class differs
from ``datetime.datetime`` in that the default values for month, day,
hour, minute, and second is ``None`` and not 'January 1, 00:00:00'.
This allows for an object that is simply 1987, and not January 1 12AM,
for example. But when more resolution is required those vales can be set
as well.
"""
TIME_STAMP_FORMATS = ["%Y",
"%Y-%m",
"%Y-%m-%d",
"%Y-%m-%dT%H",
"%Y-%m-%dT%H:%M",
"%Y-%m-%dT%H:%M:%S",
# The following end with 'Z' signally time is UTC
"%Y-%m-%dT%HZ",
"%Y-%m-%dT%H:%MZ",
"%Y-%m-%dT%H:%M:%SZ",
# The following are wrong per the specs, but ...
"%Y-%m-%d %H:%M:%S",
"%Y-00-00",
"%Y%m%d",
]
"""Valid time stamp formats per ISO 8601 and used by `strptime`."""
def __init__(self, year, month=None, day=None,
hour=None, minute=None, second=None):
# Validate with datetime
from datetime import datetime
_ = datetime(year, month if month is not None else 1,
day if day is not None else 1,
hour if hour is not None else 0,
minute if minute is not None else 0,
second if second is not None else 0)
self._year = year
self._month = month
self._day = day
self._hour = hour
self._minute = minute
self._second = second
# Python's date classes do a lot more date validation than does not
# need to be duplicated here. Validate it
_ = Date._validateFormat(str(self)) # noqa
@property
def year(self):
return self._year
@property
def month(self):
return self._month
@property
def day(self):
return self._day
@property
def hour(self):
return self._hour
@property
def minute(self):
return self._minute
@property
def second(self):
return self._second
def __eq__(self, rhs):
if not rhs:
return False
return (self.year == rhs.year and
self.month == rhs.month and
self.day == rhs.day and
self.hour == rhs.hour and
self.minute == rhs.minute and
self.second == rhs.second)
def __ne__(self, rhs):
return not(self == rhs)
def __lt__(self, rhs):
if not rhs:
return False
for left, right in ((self.year, rhs.year),
(self.month, rhs.month),
(self.day, rhs.day),
(self.hour, rhs.hour),
(self.minute, rhs.minute),
(self.second, rhs.second)):
left = left if left is not None else -1
right = right if right is not None else -1
if left < right:
return True
elif left > right:
return False
return False
def __hash__(self):
return hash(str(self))
@staticmethod
def _validateFormat(s):
pdate, fmt = None, None
for fmt in Date.TIME_STAMP_FORMATS:
try:
pdate = time.strptime(s, fmt)
break
except ValueError:
# date string did not match format.
continue
if pdate is None:
raise ValueError(f"Invalid date string: {s}")
assert pdate
return pdate, fmt
@staticmethod
def parse(s):
"""Parses date strings that conform to ISO-8601."""
if not isinstance(s, str):
s = s.decode("ascii")
s = s.strip('\x00')
pdate, fmt = Date._validateFormat(s)
# Here is the difference with Python date/datetime objects, some
# of the members can be None
kwargs = {}
if "%m" in fmt:
kwargs["month"] = pdate.tm_mon
if "%d" in fmt:
kwargs["day"] = pdate.tm_mday
if "%H" in fmt:
kwargs["hour"] = pdate.tm_hour
if "%M" in fmt:
kwargs["minute"] = pdate.tm_min
if "%S" in fmt:
kwargs["second"] = pdate.tm_sec
return Date(pdate.tm_year, **kwargs)
def __str__(self):
"""Returns date strings that conform to ISO-8601.
The returned string will be no larger than 17 characters."""
s = "%d" % self.year
if self.month:
s += "-%s" % str(self.month).rjust(2, '0')
if self.day:
s += "-%s" % str(self.day).rjust(2, '0')
if self.hour is not None:
s += "T%s" % str(self.hour).rjust(2, '0')
if self.minute is not None:
s += ":%s" % str(self.minute).rjust(2, '0')
if self.second is not None:
s += ":%s" % str(self.second).rjust(2, '0')
return s
def parseError(ex) -> None:
"""A function that is invoked when non-fatal parse, format, etc. errors
occur. In most cases the invalid values will be ignored or possibly fixed.
This function simply logs the error."""
log.warning(ex)
def load(path, tag_version=None) -> Optional[AudioFile]:
"""Loads the file identified by ``path`` and returns a concrete type of
:class:`eyed3.core.AudioFile`. If ``path`` is not a file an ``IOError`` is
raised. ``None`` is returned when the file type (i.e. mime-type) is not
recognized.
The following AudioFile types are supported:
* :class:`eyed3.mp3.Mp3AudioFile` - For mp3 audio files.
* :class:`eyed3.id3.TagFile` - For raw ID3 data files.
If ``tag_version`` is not None (the default) only a specific version of
metadata is loaded. This value must be a version constant specific to the
eventual format of the metadata.
"""
from . import mimetype, mp3, id3
if not isinstance(path, pathlib.Path):
path = pathlib.Path(path)
log.debug(f"Loading file: {path}")
if path.exists():
if not path.is_file():
raise IOError(f"not a file: {path}")
else:
raise IOError(f"file not found: {path}")
mtype = mimetype.guessMimetype(path)
log.debug(f"File mime-type: {mtype}")
if mtype in mp3.MIME_TYPES:
return mp3.Mp3AudioFile(path, tag_version)
elif mtype == id3.ID3_MIME_TYPE:
return id3.TagFile(path, tag_version)
else:
return None