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.
@@ -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