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/plugins/stats.py ADDED
@@ -0,0 +1,479 @@
1
+ import os
2
+ import sys
3
+ import operator
4
+ from collections import Counter
5
+
6
+ from eyed3 import id3, mp3
7
+ from eyed3.core import AUDIO_MP3
8
+ from eyed3.mimetype import guessMimetype
9
+ from eyed3.utils.console import Fore, Style, printMsg
10
+ from eyed3.plugins import LoaderPlugin
11
+ from eyed3.id3 import frames
12
+
13
+ ID3_VERSIONS = [id3.ID3_V1_0, id3.ID3_V1_1,
14
+ id3.ID3_V2_2, id3.ID3_V2_3, id3.ID3_V2_4]
15
+
16
+ _OP_STRINGS = {operator.le: "<=",
17
+ operator.lt: "< ",
18
+ operator.ge: ">=",
19
+ operator.gt: "> ",
20
+ operator.eq: "= ",
21
+ operator.ne: "!=",
22
+ }
23
+
24
+
25
+ class Rule:
26
+ def test(self, path, audio_file):
27
+ raise NotImplementedError()
28
+
29
+
30
+ PREFERRED_ID3_VERSIONS = [id3.ID3_V2_3,
31
+ id3.ID3_V2_4,
32
+ ]
33
+
34
+
35
+ class Id3TagRules(Rule):
36
+ def test(self, path, audio_file):
37
+ scores = []
38
+
39
+ if audio_file is None:
40
+ return None
41
+
42
+ if not audio_file.tag:
43
+ return [(-75, "Missing ID3 tag")]
44
+
45
+ tag = audio_file.tag
46
+ if tag.version not in PREFERRED_ID3_VERSIONS:
47
+ scores.append((-30, "ID3 version not in %s" %
48
+ PREFERRED_ID3_VERSIONS))
49
+ if not tag.title:
50
+ scores.append((-30, "Tag missing title"))
51
+ if not tag.artist:
52
+ scores.append((-28, "Tag missing artist"))
53
+ if not tag.album:
54
+ scores.append((-26, "Tag missing album"))
55
+ if not tag.track_num[0]:
56
+ scores.append((-24, "Tag missing track number"))
57
+ if not tag.track_num[1]:
58
+ scores.append((-22, "Tag missing total # of tracks"))
59
+
60
+ if not tag.getBestDate():
61
+ scores.append((-30, "Tag missing any useful dates"))
62
+ else:
63
+ if not tag.original_release_date:
64
+ # Original release date is so rarely used but is almost always
65
+ # what I mean or wanna know.
66
+ scores.append((-10, "No original release date"))
67
+ elif not tag.release_date:
68
+ scores.append((-5, "No release date"))
69
+
70
+ # TLEN, best gotten from audio_file.info.time_secs but having it in
71
+ # the tag is good, I guess.
72
+ if b"TLEN" not in tag.frame_set:
73
+ scores.append((-5, "No TLEN frame"))
74
+
75
+ return scores
76
+
77
+
78
+ class BitrateRule(Rule):
79
+ BITRATE_DEDUCTIONS = [(128, -20), (192, -10)]
80
+
81
+ def test(self, path, audio_file):
82
+ scores = []
83
+
84
+ if not audio_file:
85
+ return None
86
+
87
+ if not audio_file.info:
88
+ # Detected as an audio file but not real audio data found.
89
+ return [(-90, "No audio data found")]
90
+
91
+ is_vbr, bitrate = audio_file.info.bit_rate
92
+ for threshold, score in self.BITRATE_DEDUCTIONS:
93
+ if bitrate < threshold:
94
+ scores.append((score, "Bit rate < %d" % threshold))
95
+ break
96
+
97
+ return scores
98
+
99
+
100
+ VALID_MIME_TYPES = mp3.MIME_TYPES + ["image/png",
101
+ "image/gif",
102
+ "image/jpeg",
103
+ ]
104
+
105
+
106
+ class FileRule(Rule):
107
+ def test(self, path, audio_file):
108
+ mt = guessMimetype(path)
109
+
110
+ for name in os.path.split(path):
111
+ if name.startswith('.'):
112
+ return [(-100, "Hidden file type")]
113
+
114
+ if mt not in VALID_MIME_TYPES:
115
+ return [(-100, "Unsupported file type: %s" % mt)]
116
+ return None
117
+
118
+
119
+ VALID_ARTWORK_NAMES = ("cover", "cover-front", "cover-back")
120
+
121
+
122
+ class ArtworkRule(Rule):
123
+ def test(self, path, audio_file):
124
+ mt = guessMimetype(path)
125
+ if mt and mt.startswith("image/"):
126
+ name, ext = os.path.splitext(os.path.basename(path))
127
+ if name not in VALID_ARTWORK_NAMES:
128
+ return [(-10, "Artwork file not in %s" %
129
+ str(VALID_ARTWORK_NAMES))]
130
+
131
+ return None
132
+
133
+
134
+ BAD_FRAMES = [frames.PRIVATE_FID, frames.OBJECT_FID]
135
+
136
+
137
+ class Id3FrameRules(Rule):
138
+ def test(self, path, audio_file):
139
+ scores = []
140
+ if not audio_file or not audio_file.tag:
141
+ return
142
+
143
+ tag = audio_file.tag
144
+ for fid in tag.frame_set:
145
+ if fid[0] == 'T' and fid != "TXXX" and len(tag.frame_set[fid]) > 1:
146
+ scores.append((-10, "Multiple %s frames" % fid.decode('ascii')))
147
+ elif fid in BAD_FRAMES:
148
+ scores.append((-13, "%s frames are bad, mmmkay?" %
149
+ fid.decode('ascii')))
150
+
151
+ return scores
152
+
153
+
154
+ class Stat(Counter):
155
+ TOTAL = "total"
156
+
157
+ def __init__(self, *args, **kwargs):
158
+ super(Stat, self).__init__(*args, **kwargs)
159
+ self[self.TOTAL] = 0
160
+ self._key_names = {}
161
+
162
+ def compute(self, file, audio_file):
163
+ self[self.TOTAL] += 1
164
+ self._compute(file, audio_file)
165
+
166
+ def _compute(self, file, audio_file):
167
+ pass
168
+
169
+ def report(self):
170
+ self._report()
171
+
172
+ def _sortedKeys(self, most_common=False):
173
+ def keyDisplayName(k):
174
+ return self._key_names[k] if k in self._key_names else k
175
+
176
+ key_map = {}
177
+ for k in list(self.keys()):
178
+ key_map[keyDisplayName(k)] = k
179
+
180
+ if not most_common:
181
+ sorted_names = [k for k in key_map.keys() if k]
182
+ sorted_names.remove(self.TOTAL)
183
+ sorted_names.sort()
184
+ sorted_names.append(self.TOTAL)
185
+ else:
186
+ most_common = self.most_common()
187
+ sorted_names = []
188
+ remainder_names = []
189
+ for k, v in most_common:
190
+ if k != self.TOTAL and v > 0:
191
+ sorted_names.append(keyDisplayName(k))
192
+ elif k != self.TOTAL:
193
+ remainder_names.append(keyDisplayName(k))
194
+
195
+ remainder_names.sort()
196
+ sorted_names = sorted_names + remainder_names
197
+ sorted_names.append(self.TOTAL)
198
+
199
+ return [key_map[name] for name in sorted_names]
200
+
201
+ def _report(self, most_common=False):
202
+ keys = self._sortedKeys(most_common=most_common)
203
+
204
+ key_col_width = 0
205
+ val_col_width = 0
206
+ for key in keys:
207
+ key = self._key_names[key] if key in self._key_names else key
208
+ key_col_width = max(key_col_width, len(str(key)))
209
+ val_col_width = max(val_col_width, len(str(self[key])))
210
+ key_col_width += 1
211
+ val_col_width += 1
212
+
213
+ for k in keys:
214
+ key_name = self._key_names[k] if k in self._key_names else k
215
+ if type(key_name) is bytes:
216
+ key_name = key_name.decode("latin1")
217
+ value = self[k]
218
+ percent = self.percent(k) if value and k != "total" else ""
219
+ print("{padding}{key}:{value}{percent}".format(
220
+ padding=' ' * 4,
221
+ key=str(key_name).ljust(key_col_width),
222
+ value=str(value).rjust(val_col_width),
223
+ percent=" ( %s%.2f%%%s )" % (Fore.GREEN, percent, Fore.RESET)
224
+ if percent else "",
225
+ ))
226
+
227
+ def percent(self, key):
228
+ return (float(self[key]) / float(self["total"])) * 100
229
+
230
+
231
+ class AudioStat(Stat):
232
+ def compute(self, audio_file):
233
+ self["total"] += 1
234
+ self._compute(audio_file)
235
+
236
+ def _compute(self, audio_file):
237
+ pass
238
+
239
+
240
+ class FileCounterStat(Stat):
241
+ SUPPORTED_AUDIO = "audio"
242
+ UNSUPPORTED_AUDIO = "audio (unsupported)"
243
+ HIDDEN_FILES = "hidden"
244
+ OTHER_FILES = "other"
245
+
246
+ def __init__(self):
247
+ super(FileCounterStat, self).__init__()
248
+ for k in ("audio", "hidden", "audio (unsupported)"):
249
+ self[k] = 0
250
+
251
+ def _compute(self, file, audio_file):
252
+ mt = guessMimetype(file)
253
+
254
+ if audio_file:
255
+ self[self.SUPPORTED_AUDIO] += 1
256
+ elif mt and mt.startswith("audio/"):
257
+ self[self.UNSUPPORTED_AUDIO] += 1
258
+ elif os.path.basename(file).startswith('.'):
259
+ self[self.HIDDEN_FILES] += 1
260
+ else:
261
+ self[self.OTHER_FILES] += 1
262
+
263
+ def _report(self):
264
+ print(Style.BRIGHT + Fore.YELLOW + "Files:" + Style.RESET_ALL)
265
+ super(FileCounterStat, self)._report()
266
+
267
+
268
+ class MimeTypeStat(Stat):
269
+ def _compute(self, file, audio_file):
270
+ mt = guessMimetype(file)
271
+ self[mt] += 1
272
+
273
+ def _report(self):
274
+ print(Style.BRIGHT + Fore.YELLOW + "Mime-Types:" + Style.RESET_ALL)
275
+ super(MimeTypeStat, self)._report(most_common=True)
276
+
277
+
278
+ class Id3VersionCounter(AudioStat):
279
+ def __init__(self):
280
+ super(Id3VersionCounter, self).__init__()
281
+ for v in ID3_VERSIONS:
282
+ self[v] = 0
283
+ self._key_names[v] = id3.versionToString(v)
284
+
285
+ def _compute(self, audio_file):
286
+ if audio_file.tag:
287
+ self[audio_file.tag.version] += 1
288
+ else:
289
+ self[None] += 1
290
+
291
+ def _report(self):
292
+ print(Style.BRIGHT + Fore.YELLOW + "ID3 versions:" + Style.RESET_ALL)
293
+ super(Id3VersionCounter, self)._report()
294
+
295
+
296
+ class Id3FrameCounter(AudioStat):
297
+ def _compute(self, audio_file):
298
+ if audio_file.tag:
299
+ for frame_id in audio_file.tag.frame_set:
300
+ self[frame_id] += len(audio_file.tag.frame_set[frame_id])
301
+
302
+ def _report(self):
303
+ print(Style.BRIGHT + Fore.YELLOW + "ID3 frames:" + Style.RESET_ALL)
304
+ super(Id3FrameCounter, self)._report(most_common=True)
305
+
306
+
307
+ class BitrateCounter(AudioStat):
308
+ def __init__(self):
309
+ super(BitrateCounter, self).__init__()
310
+ self["cbr"] = 0
311
+ self["vbr"] = 0
312
+ self.bitrate_keys = [(operator.le, 96),
313
+ (operator.le, 112),
314
+ (operator.le, 128),
315
+ (operator.le, 160),
316
+ (operator.le, 192),
317
+ (operator.le, 256),
318
+ (operator.le, 320),
319
+ (operator.gt, 320),
320
+ ]
321
+ for k in self.bitrate_keys:
322
+ self[k] = 0
323
+ op, bitrate = k
324
+ self._key_names[k] = "%s %d" % (_OP_STRINGS[op], bitrate)
325
+
326
+ def _compute(self, audio_file):
327
+ if audio_file.type != AUDIO_MP3 or audio_file.info is None:
328
+ self["total"] -= 1
329
+ return
330
+
331
+ vbr, br = audio_file.info.bit_rate
332
+ if vbr:
333
+ self["vbr"] += 1
334
+ else:
335
+ self["cbr"] += 1
336
+
337
+ for key in self.bitrate_keys:
338
+ key_op, key_br = key
339
+ if key_op(br, key_br):
340
+ self[key] += 1
341
+ break
342
+
343
+ def _report(self):
344
+ print(Style.BRIGHT + Fore.YELLOW + "MP3 bitrates:" + Style.RESET_ALL)
345
+ super(BitrateCounter, self)._report(most_common=True)
346
+
347
+ def _sortedKeys(self, most_common=False):
348
+ keys = super(BitrateCounter, self)._sortedKeys(most_common=most_common)
349
+ keys.remove("cbr")
350
+ keys.remove("vbr")
351
+ keys.insert(0, "cbr")
352
+ keys.insert(1, "vbr")
353
+ return keys
354
+
355
+
356
+ class RuleViolationStat(Stat):
357
+ def _report(self):
358
+ print(Style.BRIGHT + Fore.YELLOW + "Rule Violations:" + Style.RESET_ALL)
359
+ super(RuleViolationStat, self)._report(most_common=True)
360
+
361
+
362
+ class Id3ImageTypeCounter(AudioStat):
363
+ def __init__(self):
364
+ super(Id3ImageTypeCounter, self).__init__()
365
+
366
+ self._key_names = {}
367
+ for attr in dir(frames.ImageFrame):
368
+ val = getattr(frames.ImageFrame, attr)
369
+ if isinstance(val, int) and not attr.endswith("_TYPE") and not attr.startswith("_"):
370
+ self._key_names[val] = attr
371
+
372
+ for v in self._key_names:
373
+ self[v] = 0
374
+
375
+ def _compute(self, audio_file):
376
+ if audio_file.tag:
377
+ for img in audio_file.tag.images:
378
+ self[img.picture_type] += 1
379
+
380
+ def _report(self):
381
+ print(Style.BRIGHT + Fore.YELLOW + "APIC image types:" + Style.RESET_ALL)
382
+ super(Id3ImageTypeCounter, self)._report()
383
+
384
+
385
+ class StatisticsPlugin(LoaderPlugin):
386
+ NAMES = ['stats']
387
+ SUMMARY = "Computes statistics for all audio files scanned."
388
+
389
+ def __init__(self, arg_parser):
390
+ super(StatisticsPlugin, self).__init__(arg_parser)
391
+
392
+ self.arg_group.add_argument(
393
+ "--verbose", action="store_true", default=False,
394
+ help="Show details for each file with rule violations.")
395
+
396
+ self._stats = []
397
+ self._rules_stat = RuleViolationStat()
398
+
399
+ self._stats.append(FileCounterStat())
400
+ self._stats.append(MimeTypeStat())
401
+ self._stats.append(Id3VersionCounter())
402
+ self._stats.append(Id3FrameCounter())
403
+ self._stats.append(Id3ImageTypeCounter())
404
+ self._stats.append(BitrateCounter())
405
+
406
+ self._score_sum = 0
407
+ self._score_count = 0
408
+ self._rules_log = {}
409
+ self._rules = [Id3TagRules(),
410
+ FileRule(),
411
+ ArtworkRule(),
412
+ BitrateRule(),
413
+ Id3FrameRules(),
414
+ ]
415
+
416
+ def handleFile(self, path):
417
+ super(StatisticsPlugin, self).handleFile(path)
418
+ if not self.args.quiet:
419
+ sys.stdout.write('.')
420
+ sys.stdout.flush()
421
+
422
+ for stat in self._stats:
423
+ if isinstance(stat, AudioStat):
424
+ if self.audio_file:
425
+ stat.compute(self.audio_file)
426
+ else:
427
+ stat.compute(path, self.audio_file)
428
+
429
+ self._score_count += 1
430
+ total_score = 100
431
+ for rule in self._rules:
432
+ scores = rule.test(path, self.audio_file) or []
433
+ if scores:
434
+ if path not in self._rules_log:
435
+ self._rules_log[path] = []
436
+
437
+ for score, text in scores:
438
+ self._rules_stat[text] += 1
439
+ self._rules_log[path].append((score, text))
440
+ # += because negative values are returned
441
+ total_score += score
442
+
443
+ if total_score != 100:
444
+ self._rules_stat[Stat.TOTAL] += 1
445
+
446
+ self._score_sum += total_score
447
+
448
+ def handleDone(self):
449
+ if self._num_loaded == 0:
450
+ super(StatisticsPlugin, self).handleDone()
451
+ return
452
+
453
+ print()
454
+ for stat in self._stats + [self._rules_stat]:
455
+ stat.report()
456
+ print()
457
+
458
+ # Detailed rule violations
459
+ if self.args.verbose:
460
+ for path in self._rules_log:
461
+ printMsg(path) # does the right thing for unicode
462
+ for score, text in self._rules_log[path]:
463
+ print(f"\t{Fore.RED}{str(score).center(3)}{Fore.RESET} ({text})")
464
+
465
+ def prettyScore():
466
+ s = float(self._score_sum) / float(self._score_count)
467
+ if s > 80:
468
+ c = Fore.GREEN
469
+ elif s > 70:
470
+ c = Fore.YELLOW
471
+ else:
472
+ c = Fore.RED
473
+ return s, c
474
+
475
+ score, color = prettyScore()
476
+ print(f"{Style.BRIGHT}Score{Style.RESET_BRIGHT} = {color}{score}%%{Fore.RESET}")
477
+ if not self.args.verbose:
478
+ print("Run with --verbose to see files and their rule violations")
479
+ print()
@@ -0,0 +1,45 @@
1
+ from pathlib import Path
2
+ from xml.sax.saxutils import escape
3
+
4
+ from eyed3.plugins import LoaderPlugin
5
+ from eyed3.utils.console import printMsg
6
+
7
+
8
+ class Xep118Plugin(LoaderPlugin):
9
+ NAMES = ["xep-118"]
10
+ SUMMARY = "Outputs all tags in XEP-118 XML format. "\
11
+ "(see: http://xmpp.org/extensions/xep-0118.html)"
12
+
13
+ def __init__(self, arg_parser):
14
+ super().__init__(arg_parser, cache_files=True, track_images=False)
15
+ g = self.arg_group
16
+ g.add_argument("--no-pretty-print", action="store_true",
17
+ help="Output without new lines or indentation.")
18
+
19
+ def handleFile(self, f, *args, **kwargs):
20
+ super().handleFile(f)
21
+
22
+ if self.audio_file and self.audio_file.tag:
23
+ xml = self.getXML(self.audio_file)
24
+ printMsg(xml)
25
+
26
+ def getXML(self, audio_file):
27
+ tag = audio_file.tag
28
+
29
+ pprint = not self.args.no_pretty_print
30
+ nl = "\n" if pprint else ""
31
+ indent = (" " * 2) if pprint else ""
32
+
33
+ xml = f"<tune xmlns='http://jabber.org/protocol/tune'>{nl}"
34
+ if tag.artist:
35
+ xml += f"{indent}<artist>{escape(tag.artist)}</artist>{nl}"
36
+ if tag.title:
37
+ xml += f"{indent}<title>{escape(tag.title)}</title>{nl}"
38
+ if tag.album:
39
+ xml += f"{indent}<source>{escape(tag.album)}</source>{nl}"
40
+ xml += f"{indent}<track>file://{escape(str(Path(audio_file.path).absolute()))}</track>{nl}"
41
+ if audio_file.info:
42
+ xml += f"{indent}<length>{audio_file.info.time_secs:.2f}</length>{nl}"
43
+ xml += "</tune>"
44
+
45
+ return xml
@@ -0,0 +1,25 @@
1
+ import eyed3.plugins
2
+ from eyed3 import log
3
+ from eyed3.plugins.jsontag import audioFileToJson
4
+
5
+ try:
6
+ import yaml as _yaml
7
+ except ImportError:
8
+ _yaml = None
9
+ log.info("yaml plugin: Install `PyYAML` for YAML plugin support.")
10
+
11
+
12
+ if _yaml:
13
+ class YamlTagPlugin(eyed3.plugins.LoaderPlugin):
14
+ NAMES = ["yaml"]
15
+ SUMMARY = "Outputs all tags as YAML."
16
+
17
+ def __init__(self, arg_parser):
18
+ super().__init__(arg_parser, cache_files=True, track_images=False)
19
+
20
+ def handleFile(self, f, *args, **kwargs):
21
+ super().handleFile(f)
22
+ if self.audio_file and self.audio_file.info and self.audio_file.tag:
23
+ print(_yaml.safe_dump(audioFileToJson(self.audio_file),
24
+ indent=2, default_flow_style=False,
25
+ explicit_start=True))