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 +27 -0
- eyed3/__init__.py +38 -0
- eyed3/__regarding__.py +48 -0
- eyed3/core.py +457 -0
- eyed3/id3/__init__.py +544 -0
- eyed3/id3/apple.py +58 -0
- eyed3/id3/frames.py +2261 -0
- eyed3/id3/headers.py +696 -0
- eyed3/id3/tag.py +2047 -0
- eyed3/main.py +305 -0
- eyed3/mimetype.py +107 -0
- eyed3/mp3/__init__.py +188 -0
- eyed3/mp3/headers.py +866 -0
- eyed3/plugins/__init__.py +200 -0
- eyed3/plugins/art.py +266 -0
- eyed3/plugins/classic.py +1173 -0
- eyed3/plugins/extract.py +61 -0
- eyed3/plugins/fixup.py +631 -0
- eyed3/plugins/genres.py +48 -0
- eyed3/plugins/itunes.py +64 -0
- eyed3/plugins/jsontag.py +133 -0
- eyed3/plugins/lameinfo.py +86 -0
- eyed3/plugins/lastfm.py +50 -0
- eyed3/plugins/mimetype.py +93 -0
- eyed3/plugins/nfo.py +123 -0
- eyed3/plugins/pymod.py +72 -0
- eyed3/plugins/stats.py +479 -0
- eyed3/plugins/xep_118.py +45 -0
- eyed3/plugins/yamltag.py +25 -0
- eyed3/utils/__init__.py +443 -0
- eyed3/utils/art.py +79 -0
- eyed3/utils/binfuncs.py +153 -0
- eyed3/utils/console.py +553 -0
- eyed3/utils/log.py +59 -0
- eyed3/utils/prompt.py +90 -0
- eyed3-0.9.8a1.dist-info/METADATA +163 -0
- eyed3-0.9.8a1.dist-info/RECORD +42 -0
- eyed3-0.9.8a1.dist-info/WHEEL +5 -0
- eyed3-0.9.8a1.dist-info/entry_points.txt +2 -0
- eyed3-0.9.8a1.dist-info/licenses/AUTHORS.rst +39 -0
- eyed3-0.9.8a1.dist-info/licenses/LICENSE +675 -0
- eyed3-0.9.8a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,200 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import pathlib
|
4
|
+
|
5
|
+
from eyed3 import core, utils
|
6
|
+
from eyed3.utils.log import getLogger
|
7
|
+
from eyed3.utils import guessMimetype, formatSize
|
8
|
+
from eyed3.utils.console import printMsg, printError, HEADER_COLOR, boldText, Fore
|
9
|
+
|
10
|
+
_PLUGINS = {}
|
11
|
+
|
12
|
+
log = getLogger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
def load(name=None, reload=False, paths=None):
|
16
|
+
"""Returns the eyed3.plugins.Plugin *class* identified by ``name``.
|
17
|
+
If ``name`` is ``None`` then the full list of plugins is returned.
|
18
|
+
Once a plugin is loaded its class object is cached, and future calls to
|
19
|
+
this function will returned the cached version. Use ``reload=True`` to
|
20
|
+
refresh the cache."""
|
21
|
+
global _PLUGINS
|
22
|
+
|
23
|
+
if len(list(_PLUGINS.keys())) and not reload:
|
24
|
+
# Return from the cache if possible
|
25
|
+
try:
|
26
|
+
return _PLUGINS[name] if name else _PLUGINS
|
27
|
+
except KeyError:
|
28
|
+
# It's not in the cache, look again and refresh cash
|
29
|
+
_PLUGINS = {}
|
30
|
+
else:
|
31
|
+
_PLUGINS = {}
|
32
|
+
|
33
|
+
def _isValidModule(f, d):
|
34
|
+
"""Determine if file `f` is a valid module file name."""
|
35
|
+
# 1) tis a file
|
36
|
+
# 2) does not start with '_', or '.'
|
37
|
+
# 3) avoid the .pyc dup
|
38
|
+
return bool(os.path.isfile(os.path.join(d, f)) and
|
39
|
+
f[0] not in ('_', '.') and f.endswith(".py"))
|
40
|
+
|
41
|
+
log.debug(f"Extra plugin paths: {paths}")
|
42
|
+
for d in [os.path.dirname(__file__)] + (paths if paths else []):
|
43
|
+
log.debug(f"Searching '{d}' for plugins")
|
44
|
+
if not os.path.isdir(d):
|
45
|
+
continue
|
46
|
+
|
47
|
+
if d not in sys.path:
|
48
|
+
sys.path.append(d)
|
49
|
+
try:
|
50
|
+
for f in os.listdir(d):
|
51
|
+
if not _isValidModule(f, d):
|
52
|
+
continue
|
53
|
+
|
54
|
+
mod_name = os.path.splitext(f)[0]
|
55
|
+
try:
|
56
|
+
mod = __import__(mod_name, globals=globals(), locals=locals())
|
57
|
+
except ImportError as ex:
|
58
|
+
log.verbose(f"Plugin {(f, d)} requires packages that are not installed: {ex}")
|
59
|
+
continue
|
60
|
+
except Exception:
|
61
|
+
log.exception(f"Bad plugin {(f, d)}")
|
62
|
+
continue
|
63
|
+
|
64
|
+
for attr in [getattr(mod, a) for a in dir(mod)]:
|
65
|
+
if type(attr) is type and issubclass(attr, Plugin):
|
66
|
+
# This is a eyed3.plugins.Plugin
|
67
|
+
PluginClass = attr
|
68
|
+
if (PluginClass not in list(_PLUGINS.values()) and
|
69
|
+
len(PluginClass.NAMES)):
|
70
|
+
log.debug(f"loading plugin '{mod}' from '{d}{os.path.sep}{f}'")
|
71
|
+
# Setting the main name outside the loop to ensure
|
72
|
+
# there is at least one, otherwise a KeyError is
|
73
|
+
# thrown.
|
74
|
+
main_name = PluginClass.NAMES[0]
|
75
|
+
_PLUGINS[main_name] = PluginClass
|
76
|
+
for alias in PluginClass.NAMES[1:]:
|
77
|
+
# Add alternate names
|
78
|
+
_PLUGINS[alias] = PluginClass
|
79
|
+
|
80
|
+
# If 'plugin' is found return it immediately
|
81
|
+
if name and name in PluginClass.NAMES:
|
82
|
+
return PluginClass
|
83
|
+
|
84
|
+
finally:
|
85
|
+
if d in sys.path:
|
86
|
+
sys.path.remove(d)
|
87
|
+
|
88
|
+
log.debug(f"Plugins loaded: {_PLUGINS}")
|
89
|
+
if name:
|
90
|
+
# If a specific plugin was requested and we've not returned yet...
|
91
|
+
return None
|
92
|
+
return _PLUGINS
|
93
|
+
|
94
|
+
|
95
|
+
class Plugin(utils.FileHandler):
|
96
|
+
"""Base class for all eyeD3 plugins"""
|
97
|
+
|
98
|
+
# One line about the plugin
|
99
|
+
SUMMARY = "eyeD3 plugin"
|
100
|
+
|
101
|
+
# Detailed info about the plugin
|
102
|
+
DESCRIPTION = ""
|
103
|
+
|
104
|
+
# A list of **at least** one name for invoking the plugin, values [1:] are treated as alias
|
105
|
+
NAMES = []
|
106
|
+
|
107
|
+
def __init__(self, arg_parser):
|
108
|
+
self.arg_parser = arg_parser
|
109
|
+
self.arg_group = arg_parser.add_argument_group("Plugin options",
|
110
|
+
f"{self.SUMMARY}\n{self.DESCRIPTION}")
|
111
|
+
|
112
|
+
def start(self, args, config):
|
113
|
+
"""Called after command line parsing but before any paths are
|
114
|
+
processed. The ``self.args`` argument (the parsed command line) and
|
115
|
+
``self.config`` (the user config, if any) is set here."""
|
116
|
+
self.args = args
|
117
|
+
self.config = config
|
118
|
+
|
119
|
+
def handleFile(self, f):
|
120
|
+
pass
|
121
|
+
|
122
|
+
def handleDone(self):
|
123
|
+
"""Called after all file/directory processing; before program exit.
|
124
|
+
The return value is passed to sys.exit (None results in 0)."""
|
125
|
+
pass
|
126
|
+
|
127
|
+
@staticmethod
|
128
|
+
def _getHardRule(width):
|
129
|
+
return "-" * width
|
130
|
+
|
131
|
+
@staticmethod
|
132
|
+
def _getFileHeader(path, width):
|
133
|
+
path = pathlib.Path(path)
|
134
|
+
file_size = path.stat().st_size
|
135
|
+
path_str = str(path)
|
136
|
+
size_str = formatSize(file_size)
|
137
|
+
size_len = len(size_str) + 5
|
138
|
+
if len(path_str) + size_len >= width:
|
139
|
+
path_str = "..." + str(path)[-(75 - size_len):]
|
140
|
+
padding_len = width - len(path_str) - size_len
|
141
|
+
|
142
|
+
return "{path}{color}{padding}[ {size} ]{reset}"\
|
143
|
+
.format(path=boldText(path_str, c=HEADER_COLOR()),
|
144
|
+
color=HEADER_COLOR(),
|
145
|
+
padding=" " * padding_len,
|
146
|
+
size=size_str,
|
147
|
+
reset=Fore.RESET)
|
148
|
+
|
149
|
+
|
150
|
+
class LoaderPlugin(Plugin):
|
151
|
+
"""A base class that provides auto loading of audio files"""
|
152
|
+
|
153
|
+
def __init__(self, arg_parser, cache_files=False, track_images=False):
|
154
|
+
"""Constructor. If ``cache_files`` is True (off by default) then each
|
155
|
+
AudioFile is appended to ``_file_cache`` during ``handleFile`` and
|
156
|
+
the list is cleared by ``handleDirectory``."""
|
157
|
+
super().__init__(arg_parser)
|
158
|
+
self._num_loaded = 0
|
159
|
+
self._file_cache = [] if cache_files else None
|
160
|
+
self._dir_images = [] if track_images else None
|
161
|
+
self.audio_file = None
|
162
|
+
|
163
|
+
def handleFile(self, f, *args, **kwargs):
|
164
|
+
"""Loads ``f`` and sets ``self.audio_file`` to an instance of
|
165
|
+
:class:`eyed3.core.AudioFile` or ``None`` if an error occurred or the
|
166
|
+
file is not a recognized type.
|
167
|
+
|
168
|
+
The ``*args`` and ``**kwargs`` are passed to :func:`eyed3.core.load`.
|
169
|
+
"""
|
170
|
+
|
171
|
+
try:
|
172
|
+
self.audio_file = core.load(f, *args, **kwargs)
|
173
|
+
except NotImplementedError as ex:
|
174
|
+
# Frame decryption, for instance...
|
175
|
+
printError(str(ex))
|
176
|
+
return
|
177
|
+
|
178
|
+
if self.audio_file:
|
179
|
+
self._num_loaded += 1
|
180
|
+
if self._file_cache is not None:
|
181
|
+
self._file_cache.append(self.audio_file)
|
182
|
+
elif self._dir_images is not None:
|
183
|
+
mt = guessMimetype(f)
|
184
|
+
if mt and mt.startswith("image/"):
|
185
|
+
self._dir_images.append(f)
|
186
|
+
|
187
|
+
def handleDirectory(self, d, _):
|
188
|
+
"""Override to make use of ``self._file_cache``. By default the list
|
189
|
+
is cleared, subclasses should consider doing the same otherwise every
|
190
|
+
AudioFile will be cached."""
|
191
|
+
if self._file_cache is not None:
|
192
|
+
self._file_cache = []
|
193
|
+
|
194
|
+
if self._dir_images is not None:
|
195
|
+
self._dir_images = []
|
196
|
+
|
197
|
+
def handleDone(self):
|
198
|
+
"""If no audio files were loaded this simply prints 'Nothing to do'."""
|
199
|
+
if self._num_loaded == 0:
|
200
|
+
printMsg("No audio files found.")
|
eyed3/plugins/art.py
ADDED
@@ -0,0 +1,266 @@
|
|
1
|
+
import io
|
2
|
+
import os
|
3
|
+
import hashlib
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
from eyed3.utils import art
|
7
|
+
from eyed3 import log
|
8
|
+
from eyed3.mimetype import guessMimetype
|
9
|
+
from eyed3.plugins import LoaderPlugin
|
10
|
+
from eyed3.core import VARIOUS_ARTISTS
|
11
|
+
from eyed3.id3.frames import ImageFrame
|
12
|
+
from eyed3.utils import makeUniqueFileName
|
13
|
+
from eyed3.utils.console import printMsg, printWarning, cformat, Fore
|
14
|
+
|
15
|
+
DESCR_FNAME_PREFIX = "filename: "
|
16
|
+
md5_file_cache = {}
|
17
|
+
|
18
|
+
|
19
|
+
def _importMessage(missing):
|
20
|
+
return f"Missing dependencies {missing}. Install with `pip install eyeD3[art-plugin]`"
|
21
|
+
|
22
|
+
|
23
|
+
try:
|
24
|
+
import PIL # noqa
|
25
|
+
import requests
|
26
|
+
from eyed3.plugins.lastfm import getAlbumArt
|
27
|
+
_PLUGIN_ACTIVE = True
|
28
|
+
_IMPORT_ERROR = None
|
29
|
+
except ImportError as ex:
|
30
|
+
_PLUGIN_ACTIVE = False
|
31
|
+
_IMPORT_ERROR = ex
|
32
|
+
|
33
|
+
|
34
|
+
class ArtFile(object):
|
35
|
+
def __init__(self, file_path):
|
36
|
+
self.art_type = art.matchArtFile(file_path)
|
37
|
+
self.file_path = file_path
|
38
|
+
self.id3_art_type = (art.TO_ID3_ART_TYPES[self.art_type][0]
|
39
|
+
if self.art_type else None)
|
40
|
+
self._img_data = None
|
41
|
+
self._mime_type = None
|
42
|
+
|
43
|
+
@property
|
44
|
+
def image_data(self):
|
45
|
+
if self._img_data:
|
46
|
+
return self._img_data
|
47
|
+
with open(self.file_path, "rb") as f:
|
48
|
+
self._img_data = f.read()
|
49
|
+
return self._img_data
|
50
|
+
|
51
|
+
@property
|
52
|
+
def mime_type(self):
|
53
|
+
if self._mime_type:
|
54
|
+
return self._mime_type
|
55
|
+
self._mime_type = guessMimetype(self.file_path)
|
56
|
+
return self._mime_type
|
57
|
+
|
58
|
+
|
59
|
+
class ArtPlugin(LoaderPlugin):
|
60
|
+
SUMMARY = "Art for albums, artists, etc."
|
61
|
+
DESCRIPTION = ""
|
62
|
+
NAMES = ["art"]
|
63
|
+
|
64
|
+
def __init__(self, arg_parser):
|
65
|
+
super(ArtPlugin, self).__init__(arg_parser, cache_files=True,
|
66
|
+
track_images=True)
|
67
|
+
self._retval = 0
|
68
|
+
|
69
|
+
g = self.arg_group
|
70
|
+
g.add_argument("-F", "--update-files", action="store_true",
|
71
|
+
help="Write art files from tag images.")
|
72
|
+
g.add_argument("-T", "--update-tags", action="store_true",
|
73
|
+
help="Write tag image from art files.")
|
74
|
+
dl_help = "Attempt to download album art if missing."
|
75
|
+
g.add_argument("-D", "--download", action="store_true", help=dl_help)
|
76
|
+
g.add_argument("-v", "--verbose", action="store_true",
|
77
|
+
help="Show detailed information for all art found.")
|
78
|
+
|
79
|
+
def start(self, args, config):
|
80
|
+
if not _PLUGIN_ACTIVE:
|
81
|
+
err_msg = _importMessage([_IMPORT_ERROR.name])
|
82
|
+
log.critical(err_msg)
|
83
|
+
raise RuntimeError(err_msg)
|
84
|
+
if args.update_files and args.update_tags:
|
85
|
+
# Not using add_mutually_exclusive_group from argparse because
|
86
|
+
# the options belong to the plugin opts group (self.arg_group)
|
87
|
+
raise StopIteration("The --update-tags and --update-files options "
|
88
|
+
"are mutually exclusive, use only one at a "
|
89
|
+
"time.")
|
90
|
+
super(ArtPlugin, self).start(args, config)
|
91
|
+
|
92
|
+
def _verbose(self, s):
|
93
|
+
if self.args.verbose:
|
94
|
+
printMsg(s)
|
95
|
+
|
96
|
+
def handleDirectory(self, d, _):
|
97
|
+
md5_file_cache.clear()
|
98
|
+
|
99
|
+
if not self._file_cache:
|
100
|
+
log.debug(f"{d}: nothing to do.")
|
101
|
+
return
|
102
|
+
|
103
|
+
try:
|
104
|
+
all_tags = sorted([f.tag for f in self._file_cache if f.tag],
|
105
|
+
key=lambda x: x.file_info.name)
|
106
|
+
|
107
|
+
# If not deemed an album, move on.
|
108
|
+
if len(set([t.album for t in all_tags])) > 1:
|
109
|
+
log.debug(f"Skipping directory '{d}', non-album.")
|
110
|
+
return
|
111
|
+
|
112
|
+
printMsg(cformat("\nChecking: ", Fore.BLUE) + d)
|
113
|
+
|
114
|
+
# File images
|
115
|
+
dir_art = []
|
116
|
+
for img_file in self._dir_images:
|
117
|
+
img_base = os.path.basename(img_file)
|
118
|
+
art_file = ArtFile(img_file)
|
119
|
+
try:
|
120
|
+
pil_img = pilImage(img_file)
|
121
|
+
except IOError as ex:
|
122
|
+
printWarning(str(ex))
|
123
|
+
continue
|
124
|
+
|
125
|
+
if art_file.art_type:
|
126
|
+
self._verbose(
|
127
|
+
f"file {img_base}: {art_file.art_type}\n\t{pilImageDetails(pil_img)}")
|
128
|
+
dir_art.append(art_file)
|
129
|
+
else:
|
130
|
+
self._verbose(f"file {img_base}: unknown (ignored)")
|
131
|
+
|
132
|
+
if not dir_art:
|
133
|
+
print(cformat("NONE", Fore.RED))
|
134
|
+
self._retval += 1
|
135
|
+
else:
|
136
|
+
print(cformat("OK", Fore.GREEN))
|
137
|
+
|
138
|
+
# --download handling
|
139
|
+
if not dir_art and self.args.download:
|
140
|
+
tag = all_tags[0]
|
141
|
+
artists = set([t.artist for t in all_tags])
|
142
|
+
if len(artists) > 1:
|
143
|
+
artist_query = VARIOUS_ARTISTS
|
144
|
+
else:
|
145
|
+
artist_query = tag.album_artist or tag.artist
|
146
|
+
|
147
|
+
try:
|
148
|
+
url = getAlbumArt(artist_query, tag.album)
|
149
|
+
print("Downloading album art...")
|
150
|
+
resp = requests.get(url)
|
151
|
+
if resp.status_code != 200:
|
152
|
+
raise ValueError()
|
153
|
+
except ValueError:
|
154
|
+
print("Album art download not found")
|
155
|
+
else:
|
156
|
+
img = pilImage(io.BytesIO(resp.content))
|
157
|
+
cover = Path(d) / "cover.{}".format(img.format.lower())
|
158
|
+
assert not cover.exists()
|
159
|
+
img.save(str(cover))
|
160
|
+
print("Save {cover}".format(cover=cover))
|
161
|
+
|
162
|
+
# Tag images
|
163
|
+
for tag in all_tags:
|
164
|
+
file_base = os.path.basename(tag.file_info.name)
|
165
|
+
for img in tag.images:
|
166
|
+
try:
|
167
|
+
pil_img = pilImage(img)
|
168
|
+
pil_img_details = pilImageDetails(pil_img)
|
169
|
+
except OSError as ex:
|
170
|
+
printWarning(str(ex))
|
171
|
+
continue
|
172
|
+
|
173
|
+
if img.picture_type in art.FROM_ID3_ART_TYPES:
|
174
|
+
img_type = art.FROM_ID3_ART_TYPES[img.picture_type]
|
175
|
+
self._verbose("tag %s: %s (Description: %s)\n\t%s" %
|
176
|
+
(file_base, img_type, img.description,
|
177
|
+
pil_img_details))
|
178
|
+
if self.args.update_files:
|
179
|
+
assert not self.args.update_tags
|
180
|
+
path = os.path.dirname(tag.file_info.name)
|
181
|
+
if img.description.startswith(DESCR_FNAME_PREFIX):
|
182
|
+
# Use filename from Image description
|
183
|
+
fname = img.description[
|
184
|
+
len(DESCR_FNAME_PREFIX):].strip()
|
185
|
+
fname = os.path.splitext(fname)[0]
|
186
|
+
else:
|
187
|
+
fname = art.FILENAMES[img_type][0].strip("*")
|
188
|
+
fname = img.makeFileName(name=fname)
|
189
|
+
|
190
|
+
if (md5File(os.path.join(path, fname)) ==
|
191
|
+
md5Data(img.image_data)):
|
192
|
+
printMsg("Skipping writing of %s, file "
|
193
|
+
"exists and is exactly the same." %
|
194
|
+
fname)
|
195
|
+
else:
|
196
|
+
img_file = makeUniqueFileName(
|
197
|
+
os.path.join(path, fname),
|
198
|
+
uniq=img.description)
|
199
|
+
printWarning("Writing %s..." % img_file)
|
200
|
+
with open(img_file, "wb") as fp:
|
201
|
+
fp.write(img.image_data)
|
202
|
+
else:
|
203
|
+
self._verbose(
|
204
|
+
"tag %s: unhandled image type %d (ignored)" %
|
205
|
+
(file_base, img.picture_type)
|
206
|
+
)
|
207
|
+
|
208
|
+
# Copy file art to tags.
|
209
|
+
if self.args.update_tags:
|
210
|
+
assert not self.args.update_files
|
211
|
+
for tag in all_tags:
|
212
|
+
for art_file in dir_art:
|
213
|
+
art_path = os.path.basename(art_file.file_path)
|
214
|
+
printMsg("Copying %s to tag '%s' image" %
|
215
|
+
(art_path, art_file.id3_art_type))
|
216
|
+
|
217
|
+
descr = "filename: %s" % os.path.splitext(art_path)[0]
|
218
|
+
tag.images.set(art_file.id3_art_type,
|
219
|
+
art_file.image_data, art_file.mime_type,
|
220
|
+
description=descr)
|
221
|
+
tag.save()
|
222
|
+
|
223
|
+
finally:
|
224
|
+
# Cleans up...
|
225
|
+
super(ArtPlugin, self).handleDirectory(d, _)
|
226
|
+
|
227
|
+
def handleDone(self):
|
228
|
+
return self._retval
|
229
|
+
|
230
|
+
|
231
|
+
def pilImage(source):
|
232
|
+
from PIL import Image
|
233
|
+
|
234
|
+
if isinstance(source, ImageFrame):
|
235
|
+
return Image.open(io.BytesIO(source.image_data))
|
236
|
+
else:
|
237
|
+
return Image.open(source)
|
238
|
+
|
239
|
+
|
240
|
+
def pilImageDetails(img):
|
241
|
+
return "[%dx%d %s md5:%s]" % (img.size[0], img.size[1],
|
242
|
+
img.format.lower(),
|
243
|
+
md5Data(img.tobytes())) if img else ""
|
244
|
+
|
245
|
+
|
246
|
+
def md5Data(data):
|
247
|
+
md5 = hashlib.md5()
|
248
|
+
md5.update(data)
|
249
|
+
return md5.hexdigest()
|
250
|
+
|
251
|
+
|
252
|
+
def md5File(file_name):
|
253
|
+
"""Compute md5 hash for contents of ``file_name``."""
|
254
|
+
|
255
|
+
if file_name in md5_file_cache:
|
256
|
+
return md5_file_cache[file_name]
|
257
|
+
|
258
|
+
md5 = hashlib.md5()
|
259
|
+
try:
|
260
|
+
with open(file_name, "rb") as f:
|
261
|
+
md5.update(f.read())
|
262
|
+
|
263
|
+
md5_file_cache[file_name] = md5.hexdigest()
|
264
|
+
return md5_file_cache[file_name]
|
265
|
+
except IOError:
|
266
|
+
return None
|