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
eyed3/main.py
ADDED
@@ -0,0 +1,305 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import textwrap
|
4
|
+
import warnings
|
5
|
+
import deprecation
|
6
|
+
|
7
|
+
from io import StringIO
|
8
|
+
from configparser import ConfigParser
|
9
|
+
from configparser import Error as ConfigParserError
|
10
|
+
|
11
|
+
import eyed3
|
12
|
+
import eyed3.utils
|
13
|
+
import eyed3.utils.console
|
14
|
+
import eyed3.plugins
|
15
|
+
import eyed3.__about__
|
16
|
+
|
17
|
+
from eyed3.utils.log import initLogging
|
18
|
+
|
19
|
+
DEFAULT_PLUGIN = "classic"
|
20
|
+
DEFAULT_CONFIG = os.path.expandvars("${HOME}/.config/eyeD3/config.ini")
|
21
|
+
USER_PLUGINS_DIR = os.path.expandvars("${HOME}/.config/eyeD3/plugins")
|
22
|
+
DEFAULT_CONFIG_DEPRECATED = os.path.expandvars("${HOME}/.eyeD3/config.ini")
|
23
|
+
USER_PLUGINS_DIR_DEPRECATED = os.path.expandvars("${HOME}/.eyeD3/plugins")
|
24
|
+
|
25
|
+
|
26
|
+
def main(args, config):
|
27
|
+
if "list_plugins" in args and args.list_plugins:
|
28
|
+
_listPlugins(config)
|
29
|
+
return 0
|
30
|
+
|
31
|
+
args.plugin.start(args, config)
|
32
|
+
|
33
|
+
recursive = False
|
34
|
+
if "non_recursive" in args:
|
35
|
+
recursive = not args.non_recursive
|
36
|
+
elif "recursive" in args:
|
37
|
+
recursive = args.recursive
|
38
|
+
|
39
|
+
# Process paths (files/directories)
|
40
|
+
for p in args.paths:
|
41
|
+
eyed3.utils.walk(args.plugin, p, excludes=args.excludes, fs_encoding=args.fs_encoding,
|
42
|
+
recursive=recursive)
|
43
|
+
|
44
|
+
retval = args.plugin.handleDone()
|
45
|
+
|
46
|
+
return retval or 0
|
47
|
+
|
48
|
+
|
49
|
+
def _listPlugins(config):
|
50
|
+
from eyed3.utils.console import Fore, Style
|
51
|
+
|
52
|
+
def header(name):
|
53
|
+
is_default = name == DEFAULT_PLUGIN
|
54
|
+
return (Style.BRIGHT + (Fore.GREEN if is_default else '') + "* " +
|
55
|
+
name + Style.RESET_ALL)
|
56
|
+
|
57
|
+
all_plugins = eyed3.plugins.load(reload=True, paths=_getPluginPath(config))
|
58
|
+
# Create a new dict for sorted display
|
59
|
+
plugin_names = []
|
60
|
+
for plugin in set(all_plugins.values()):
|
61
|
+
plugin_names.append(plugin.NAMES[0])
|
62
|
+
|
63
|
+
print("\nType 'eyeD3 --plugin=<name> --help' for more help\n")
|
64
|
+
|
65
|
+
plugin_names.sort()
|
66
|
+
for name in plugin_names:
|
67
|
+
plugin = all_plugins[name]
|
68
|
+
|
69
|
+
alt_names = plugin.NAMES[1:]
|
70
|
+
alt_names = f" ({', '.join(alt_names)})" if alt_names else ""
|
71
|
+
|
72
|
+
print(f"{header(name)} {alt_names}:")
|
73
|
+
for txt in textwrap.wrap(plugin.SUMMARY, initial_indent=' ' * 2, subsequent_indent=' ' * 2):
|
74
|
+
print(f"{Fore.YELLOW}{txt}{Style.RESET_ALL}")
|
75
|
+
print("")
|
76
|
+
|
77
|
+
|
78
|
+
@deprecation.deprecated(deprecated_in="0.9a2", removed_in="1.0",
|
79
|
+
current_version=eyed3.__about__.__version__,
|
80
|
+
details=f"Default eyeD3 config moved to {DEFAULT_CONFIG}")
|
81
|
+
def _deprecatedConfigFileCheck(_):
|
82
|
+
"""This here to add deprecation."""
|
83
|
+
|
84
|
+
|
85
|
+
def _loadConfig(args):
|
86
|
+
config_files = []
|
87
|
+
|
88
|
+
if args.config:
|
89
|
+
config_files.append(os.path.abspath(args.config))
|
90
|
+
|
91
|
+
if args.no_config is False:
|
92
|
+
config_files.append(DEFAULT_CONFIG)
|
93
|
+
config_files.append(DEFAULT_CONFIG_DEPRECATED)
|
94
|
+
|
95
|
+
if not config_files:
|
96
|
+
return None
|
97
|
+
|
98
|
+
for config_file in config_files:
|
99
|
+
if os.path.isfile(config_file):
|
100
|
+
_deprecatedConfigFileCheck(config_file)
|
101
|
+
|
102
|
+
try:
|
103
|
+
config = ConfigParser()
|
104
|
+
config.read(config_file)
|
105
|
+
except ConfigParserError as ex:
|
106
|
+
eyed3.log.warning(f"User config error: {ex}")
|
107
|
+
return None
|
108
|
+
else:
|
109
|
+
return config
|
110
|
+
elif config_file != DEFAULT_CONFIG and config_file != DEFAULT_CONFIG_DEPRECATED:
|
111
|
+
raise IOError(f"User config not found: {config_file}")
|
112
|
+
|
113
|
+
|
114
|
+
def _getPluginPath(config):
|
115
|
+
plugin_path = [USER_PLUGINS_DIR]
|
116
|
+
|
117
|
+
if config and config.has_option("default", "plugin_path"):
|
118
|
+
val = config.get("default", "plugin_path")
|
119
|
+
plugin_path += [os.path.expanduser(os.path.expandvars(d)) for d
|
120
|
+
in val.split(':') if val]
|
121
|
+
return plugin_path
|
122
|
+
|
123
|
+
|
124
|
+
def profileMain(args, config): # pragma: no cover
|
125
|
+
"""This is the main function for profiling
|
126
|
+
http://code.google.com/appengine/kb/commontasks.html#profiling
|
127
|
+
"""
|
128
|
+
import cProfile
|
129
|
+
import pstats
|
130
|
+
|
131
|
+
eyed3.log.debug("driver profileMain")
|
132
|
+
prof = cProfile.Profile()
|
133
|
+
prof = prof.runctx("main(args)", globals(), locals())
|
134
|
+
|
135
|
+
stream = StringIO()
|
136
|
+
stats = pstats.Stats(prof, stream=stream)
|
137
|
+
stats.sort_stats("time") # Or cumulative
|
138
|
+
stats.print_stats(100) # 80 = how many to print
|
139
|
+
|
140
|
+
# The rest is optional.
|
141
|
+
stats.print_callees()
|
142
|
+
stats.print_callers()
|
143
|
+
sys.stderr.write("Profile data:\n%s\n" % stream.getvalue())
|
144
|
+
|
145
|
+
return 0
|
146
|
+
|
147
|
+
|
148
|
+
def setFileScannerOpts(arg_parser, default_recursive=False, paths_metavar="PATH",
|
149
|
+
paths_help="Files or directory paths"):
|
150
|
+
|
151
|
+
if default_recursive is False:
|
152
|
+
arg_parser.add_argument("-r", "--recursive", action="store_true", dest="recursive",
|
153
|
+
help="Recurse into subdirectories.")
|
154
|
+
else:
|
155
|
+
arg_parser.add_argument("-R", "--non-recursive", action="store_true", dest="non_recursive",
|
156
|
+
help="Do not recurse into subdirectories.")
|
157
|
+
|
158
|
+
arg_parser.add_argument("--exclude", action="append", metavar="PATTERN", dest="excludes",
|
159
|
+
help="A regular expression for path exclusion. May be specified "
|
160
|
+
"multiple times.")
|
161
|
+
arg_parser.add_argument("--fs-encoding", action="store", dest="fs_encoding",
|
162
|
+
default=eyed3.LOCAL_FS_ENCODING, metavar="ENCODING",
|
163
|
+
help="Use the specified file system encoding for filenames. "
|
164
|
+
f"Default as it was detected is '{eyed3.LOCAL_FS_ENCODING}' but "
|
165
|
+
"this option is still useful when reading from mounted file "
|
166
|
+
"systems.")
|
167
|
+
arg_parser.add_argument("paths", metavar=paths_metavar, nargs="*", help=paths_help)
|
168
|
+
|
169
|
+
|
170
|
+
def makeCmdLineParser(subparser=None):
|
171
|
+
from eyed3.utils import ArgumentParser
|
172
|
+
|
173
|
+
p = ArgumentParser(prog=eyed3.__about__.__project_name__, add_help=True)\
|
174
|
+
if not subparser else subparser
|
175
|
+
|
176
|
+
setFileScannerOpts(p)
|
177
|
+
|
178
|
+
p.add_argument("-L", "--plugins", action="store_true", default=False,
|
179
|
+
dest="list_plugins", help="List all available plugins")
|
180
|
+
p.add_argument("-P", "--plugin", action="store", dest="plugin",
|
181
|
+
default=None, metavar="NAME",
|
182
|
+
help=f"Specify which plugin to use. The default is '{DEFAULT_PLUGIN}'")
|
183
|
+
p.add_argument("-C", "--config", action="store", dest="config",
|
184
|
+
default=None, metavar="FILE",
|
185
|
+
help="Supply a configuration file. The default is "
|
186
|
+
f"'{DEFAULT_CONFIG}', although even that is optional.")
|
187
|
+
p.add_argument("--backup", action="store_true", dest="backup",
|
188
|
+
help="Plugins should honor this option such that "
|
189
|
+
"a backup is made of any file modified. The backup "
|
190
|
+
"is made in same directory with a '.orig' "
|
191
|
+
"extension added.")
|
192
|
+
p.add_argument("-Q", "--quiet", action="store_true", dest="quiet",
|
193
|
+
default=False, help="A hint to plugins to output less.")
|
194
|
+
p.add_argument("--no-color", action="store_true", dest="no_color",
|
195
|
+
help="Suppress color codes in console output. "
|
196
|
+
"This will happen automatically if the output is "
|
197
|
+
"not a TTY (e.g. when redirecting to a file)")
|
198
|
+
p.add_argument("--no-config",
|
199
|
+
action="store_true", dest="no_config",
|
200
|
+
help=f"Do not load the default user config '{DEFAULT_CONFIG}'. "
|
201
|
+
"The -c/--config options are still honored if present.")
|
202
|
+
|
203
|
+
return p
|
204
|
+
|
205
|
+
|
206
|
+
def parseCommandLine(cmd_line_args=None):
|
207
|
+
|
208
|
+
cmd_line_args = list(cmd_line_args) if cmd_line_args else list(sys.argv[1:])
|
209
|
+
|
210
|
+
# Remove any options not related to plugin/config for first parse. These
|
211
|
+
# determine the parser for the next stage.
|
212
|
+
stage_one_args = []
|
213
|
+
idx, auto_append = 0, False
|
214
|
+
while idx < len(cmd_line_args):
|
215
|
+
opt = cmd_line_args[idx]
|
216
|
+
if auto_append:
|
217
|
+
stage_one_args.append(opt)
|
218
|
+
auto_append = False
|
219
|
+
|
220
|
+
if opt in ("-C", "--config", "-P", "--plugin", "--no-config"):
|
221
|
+
stage_one_args.append(opt)
|
222
|
+
if opt != "--no-config":
|
223
|
+
auto_append = True
|
224
|
+
elif (opt.startswith("-C=") or opt.startswith("--config=") or
|
225
|
+
opt.startswith("-P=") or opt.startswith("--plugin=")):
|
226
|
+
stage_one_args.append(opt)
|
227
|
+
idx += 1
|
228
|
+
|
229
|
+
parser = makeCmdLineParser()
|
230
|
+
args = parser.parse_args(stage_one_args)
|
231
|
+
|
232
|
+
config = _loadConfig(args)
|
233
|
+
|
234
|
+
if args.plugin:
|
235
|
+
# Plugin on the command line takes precedence over config.
|
236
|
+
plugin_name = args.plugin
|
237
|
+
elif config and config.has_option("default", "plugin"):
|
238
|
+
# Get default plugin from config or use DEFAULT_CONFIG
|
239
|
+
plugin_name = config.get("default", "plugin")
|
240
|
+
if not plugin_name:
|
241
|
+
plugin_name = DEFAULT_PLUGIN
|
242
|
+
else:
|
243
|
+
plugin_name = DEFAULT_PLUGIN
|
244
|
+
|
245
|
+
PluginClass = eyed3.plugins.load(plugin_name, paths=_getPluginPath(config))
|
246
|
+
if PluginClass is None:
|
247
|
+
eyed3.utils.console.printError("Plugin not found: %s" % plugin_name)
|
248
|
+
parser.exit(1)
|
249
|
+
plugin = PluginClass(parser)
|
250
|
+
|
251
|
+
if config and config.has_option("default", "options"):
|
252
|
+
cmd_line_args.extend(config.get("default", "options").split())
|
253
|
+
if config and config.has_option(plugin_name, "options"):
|
254
|
+
cmd_line_args.extend(config.get(plugin_name, "options").split())
|
255
|
+
|
256
|
+
# Re-parse the command line including options from the config.
|
257
|
+
args = parser.parse_args(args=cmd_line_args)
|
258
|
+
|
259
|
+
args.plugin = plugin
|
260
|
+
eyed3.log.debug("command line args: %s", args)
|
261
|
+
eyed3.log.debug("plugin is: %s", plugin)
|
262
|
+
|
263
|
+
return args, parser, config
|
264
|
+
|
265
|
+
|
266
|
+
def _main():
|
267
|
+
"""Entry point"""
|
268
|
+
initLogging()
|
269
|
+
|
270
|
+
args = None
|
271
|
+
try:
|
272
|
+
args, _, config = parseCommandLine()
|
273
|
+
|
274
|
+
eyed3.utils.console.AnsiCodes.init(not args.no_color)
|
275
|
+
|
276
|
+
mainFunc = main if args.debug_profile is False else profileMain
|
277
|
+
retval = mainFunc(args, config)
|
278
|
+
except KeyboardInterrupt:
|
279
|
+
retval = 0
|
280
|
+
except (StopIteration, IOError) as ex:
|
281
|
+
eyed3.utils.console.printError(str(ex))
|
282
|
+
retval = 1
|
283
|
+
except Exception as ex:
|
284
|
+
eyed3.utils.console.printError(f"Uncaught exception: {ex}\n")
|
285
|
+
eyed3.log.exception(ex)
|
286
|
+
retval = 1
|
287
|
+
|
288
|
+
if args.debug_pdb:
|
289
|
+
try:
|
290
|
+
with warnings.catch_warnings():
|
291
|
+
warnings.simplefilter("ignore", PendingDeprecationWarning)
|
292
|
+
# Must delay the import of ipdb as say as possible because
|
293
|
+
# of https://github.com/gotcha/ipdb/issues/48
|
294
|
+
import ipdb as pdb # noqa
|
295
|
+
except ImportError:
|
296
|
+
import pdb # noqa
|
297
|
+
|
298
|
+
e, m, tb = sys.exc_info()
|
299
|
+
pdb.post_mortem(tb)
|
300
|
+
|
301
|
+
sys.exit(retval)
|
302
|
+
|
303
|
+
|
304
|
+
if __name__ == "__main__": # pragma: no cover
|
305
|
+
_main()
|
eyed3/mimetype.py
ADDED
@@ -0,0 +1,107 @@
|
|
1
|
+
import pathlib
|
2
|
+
import filetype
|
3
|
+
from io import BytesIO
|
4
|
+
from .id3 import ID3_MIME_TYPE, ID3_MIME_TYPE_EXTENSIONS
|
5
|
+
from .mp3 import MIME_TYPES as MP3_MIME_TYPES
|
6
|
+
from .utils.log import getLogger
|
7
|
+
from filetype.utils import _NUM_SIGNATURE_BYTES
|
8
|
+
|
9
|
+
log = getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
def guessMimetype(filename):
|
13
|
+
"""Return the mime-type for `filename`."""
|
14
|
+
|
15
|
+
path = pathlib.Path(filename) if not isinstance(filename, pathlib.Path) else filename
|
16
|
+
|
17
|
+
with path.open("rb") as signature:
|
18
|
+
# Since filetype only reads 262 of file many mp3s starting with null bytes will not find
|
19
|
+
# a header, so ignoring null bytes and using the bytes interface...
|
20
|
+
buf = b""
|
21
|
+
while not buf:
|
22
|
+
data = signature.read(_NUM_SIGNATURE_BYTES)
|
23
|
+
if not data:
|
24
|
+
break
|
25
|
+
|
26
|
+
data = data.lstrip(b"\x00")
|
27
|
+
if data:
|
28
|
+
data_len = len(data)
|
29
|
+
if data_len >= _NUM_SIGNATURE_BYTES:
|
30
|
+
buf = data[:_NUM_SIGNATURE_BYTES]
|
31
|
+
else:
|
32
|
+
buf = data + signature.read(_NUM_SIGNATURE_BYTES - data_len)
|
33
|
+
|
34
|
+
# Special casing .id3/.tag because extended filetype with add_type() prepends, meaning
|
35
|
+
# all mp3 would be labeled mimetype id3, while appending would mean each .id3 would be
|
36
|
+
# mime mpeg.
|
37
|
+
if path.suffix in ID3_MIME_TYPE_EXTENSIONS:
|
38
|
+
if Id3Tag().match(buf) or Id3TagExt().match(buf):
|
39
|
+
return Id3TagExt.MIME
|
40
|
+
|
41
|
+
return filetype.guess_mime(buf)
|
42
|
+
|
43
|
+
|
44
|
+
class Mp2x(filetype.Type):
|
45
|
+
"""Implements the MP2.x audio type matcher."""
|
46
|
+
MIME = MP3_MIME_TYPES[0]
|
47
|
+
EXTENSION = "mp3"
|
48
|
+
|
49
|
+
def __init__(self):
|
50
|
+
super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
|
51
|
+
|
52
|
+
def match(self, buf):
|
53
|
+
from .mp3.headers import findHeader
|
54
|
+
|
55
|
+
return (len(buf) > 2 and
|
56
|
+
buf[0] == 0xff and buf[1] in (0xf3, 0xe3) and
|
57
|
+
findHeader(BytesIO(buf), 0)[1])
|
58
|
+
|
59
|
+
|
60
|
+
class Mp3Invalids(filetype.Type):
|
61
|
+
"""Implements a MP3 audio type matcher this is odd or/corrupt mp3."""
|
62
|
+
MIME = MP3_MIME_TYPES[0]
|
63
|
+
EXTENSION = "mp3"
|
64
|
+
|
65
|
+
def __init__(self):
|
66
|
+
super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
|
67
|
+
|
68
|
+
def match(self, buf):
|
69
|
+
from .mp3.headers import findHeader
|
70
|
+
|
71
|
+
header = findHeader(BytesIO(buf), 0)[1]
|
72
|
+
log.debug(f"Mp3Invalid, found: {header}")
|
73
|
+
return bool(header)
|
74
|
+
|
75
|
+
|
76
|
+
class Id3Tag(filetype.Type):
|
77
|
+
"""Implements a MP3 audio type matcher this is odd or/corrupt mp3."""
|
78
|
+
MIME = ID3_MIME_TYPE
|
79
|
+
EXTENSION = "id3"
|
80
|
+
|
81
|
+
def __init__(self):
|
82
|
+
super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
|
83
|
+
|
84
|
+
def match(self, buf):
|
85
|
+
return buf[:3] in (b"ID3", b"TAG") or len(buf) == 0
|
86
|
+
|
87
|
+
|
88
|
+
class Id3TagExt(Id3Tag):
|
89
|
+
EXTENSION = "tag"
|
90
|
+
|
91
|
+
|
92
|
+
class M3u(filetype.Type):
|
93
|
+
"""Implements the m3u playlist matcher."""
|
94
|
+
MIME = "audio/x-mpegurl"
|
95
|
+
EXTENSION = "m3u"
|
96
|
+
|
97
|
+
def __init__(self):
|
98
|
+
super().__init__(mime=self.__class__.MIME, extension=self.__class__.EXTENSION)
|
99
|
+
|
100
|
+
def match(self, buf):
|
101
|
+
return len(buf) > 6 and buf.startswith(b"#EXTM3U")
|
102
|
+
|
103
|
+
|
104
|
+
# Not using `add_type()`, to append
|
105
|
+
filetype.types.append(Mp2x())
|
106
|
+
filetype.types.append(M3u())
|
107
|
+
filetype.types.append(Mp3Invalids())
|
eyed3/mp3/__init__.py
ADDED
@@ -0,0 +1,188 @@
|
|
1
|
+
import os
|
2
|
+
import re
|
3
|
+
import stat
|
4
|
+
|
5
|
+
from .. import Error
|
6
|
+
from .. import id3
|
7
|
+
from .. import core
|
8
|
+
|
9
|
+
from ..utils.log import getLogger
|
10
|
+
log = getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class Mp3Exception(Error):
|
14
|
+
"""Used to signal mp3-related errors."""
|
15
|
+
pass
|
16
|
+
|
17
|
+
|
18
|
+
NAME = "mpeg"
|
19
|
+
# Mime-types that are recognized at MP3
|
20
|
+
MIME_TYPES = ["audio/mpeg", "audio/mp3", "audio/x-mp3", "audio/x-mpeg",
|
21
|
+
"audio/mpeg3", "audio/x-mpeg3", "audio/mpg", "audio/x-mpg",
|
22
|
+
"audio/x-mpegaudio", "audio/mpegapplication/x-tar",
|
23
|
+
]
|
24
|
+
|
25
|
+
# Mime-types that have been seen to contain mp3 data.
|
26
|
+
OTHER_MIME_TYPES = ['application/octet-stream', # ???
|
27
|
+
'audio/x-hx-aac-adts', # ???
|
28
|
+
'audio/x-wav', # RIFF wrapped mp3s
|
29
|
+
]
|
30
|
+
|
31
|
+
# Valid file extensions.
|
32
|
+
EXTENSIONS = [".mp3"]
|
33
|
+
|
34
|
+
|
35
|
+
class Mp3AudioInfo(core.AudioInfo):
|
36
|
+
def __init__(self, file_obj, start_offset, tag):
|
37
|
+
from . import headers
|
38
|
+
from .headers import timePerFrame
|
39
|
+
|
40
|
+
log.debug("mp3 header search starting @ %x" % start_offset)
|
41
|
+
|
42
|
+
self.mp3_header = None
|
43
|
+
self.xing_header = None
|
44
|
+
self.vbri_header = None
|
45
|
+
# If not ``None``, the Lame header.
|
46
|
+
# See :class:`eyed3.mp3.headers.LameHeader`
|
47
|
+
self.lame_tag = None
|
48
|
+
# 2-tuple, (vrb?:boolean, bitrate:int)
|
49
|
+
self.bit_rate = (None, None)
|
50
|
+
|
51
|
+
header_pos = 0
|
52
|
+
while self.mp3_header is None:
|
53
|
+
# Find first mp3 header
|
54
|
+
(header_pos,
|
55
|
+
header_int,
|
56
|
+
header_bytes) = headers.findHeader(file_obj, start_offset)
|
57
|
+
if not header_int:
|
58
|
+
try:
|
59
|
+
fname = file_obj.name
|
60
|
+
except AttributeError:
|
61
|
+
fname = 'unknown'
|
62
|
+
raise headers.Mp3Exception(
|
63
|
+
"Unable to find a valid mp3 frame in '%s'" % fname)
|
64
|
+
|
65
|
+
try:
|
66
|
+
self.mp3_header = headers.Mp3Header(header_int)
|
67
|
+
log.debug("mp3 header %x found at position: 0x%x" %
|
68
|
+
(header_int, header_pos))
|
69
|
+
except headers.Mp3Exception as ex:
|
70
|
+
log.debug("Invalid mp3 header: %s" % str(ex))
|
71
|
+
# keep looking...
|
72
|
+
start_offset += 4
|
73
|
+
|
74
|
+
file_obj.seek(header_pos)
|
75
|
+
mp3_frame = file_obj.read(self.mp3_header.frame_length)
|
76
|
+
if re.compile(b'Xing|Info').search(mp3_frame):
|
77
|
+
# Check for Xing/Info header information.
|
78
|
+
self.xing_header = headers.XingHeader()
|
79
|
+
if not self.xing_header.decode(mp3_frame):
|
80
|
+
log.debug("Ignoring corrupt Xing header")
|
81
|
+
self.xing_header = None
|
82
|
+
elif mp3_frame.find(b'VBRI') >= 0:
|
83
|
+
# Check for VBRI header information.
|
84
|
+
self.vbri_header = headers.VbriHeader()
|
85
|
+
if not self.vbri_header.decode(mp3_frame):
|
86
|
+
log.debug("Ignoring corrupt VBRI header")
|
87
|
+
self.vbri_header = None
|
88
|
+
|
89
|
+
# Check for LAME Tag
|
90
|
+
self.lame_tag = headers.LameHeader(mp3_frame)
|
91
|
+
|
92
|
+
# Set file size
|
93
|
+
size_bytes = os.stat(file_obj.name)[stat.ST_SIZE]
|
94
|
+
|
95
|
+
# Compute track play time.
|
96
|
+
if self.xing_header and self.xing_header.vbr:
|
97
|
+
tpf = timePerFrame(self.mp3_header, True)
|
98
|
+
time_secs = tpf * self.xing_header.numFrames
|
99
|
+
elif self.vbri_header and self.vbri_header.version == 1:
|
100
|
+
tpf = timePerFrame(self.mp3_header, True)
|
101
|
+
time_secs = tpf * self.vbri_header.num_frames
|
102
|
+
else:
|
103
|
+
tpf = timePerFrame(self.mp3_header, False)
|
104
|
+
length = size_bytes
|
105
|
+
if tag and tag.isV2():
|
106
|
+
length -= tag.header.SIZE + tag.header.tag_size
|
107
|
+
# Handle the case where there is a v2 tag and a v1 tag.
|
108
|
+
file_obj.seek(-128, 2)
|
109
|
+
if file_obj.read(3) == "TAG":
|
110
|
+
length -= 128
|
111
|
+
elif tag and tag.isV1():
|
112
|
+
length -= 128
|
113
|
+
time_secs = (length / self.mp3_header.frame_length) * tpf
|
114
|
+
|
115
|
+
# Compute bitrate
|
116
|
+
if (self.xing_header and self.xing_header.vbr and
|
117
|
+
self.xing_header.numFrames): # if xing_header.numFrames == 0, ZeroDivisionError
|
118
|
+
br = int((self.xing_header.numBytes * 8) /
|
119
|
+
(tpf * self.xing_header.numFrames * 1000))
|
120
|
+
vbr = True
|
121
|
+
else:
|
122
|
+
br = self.mp3_header.bit_rate
|
123
|
+
vbr = False
|
124
|
+
self.bit_rate = (vbr, br)
|
125
|
+
|
126
|
+
self.sample_freq = self.mp3_header.sample_freq
|
127
|
+
self.mode = self.mp3_header.mode
|
128
|
+
|
129
|
+
super().__init__(time_secs, size_bytes)
|
130
|
+
|
131
|
+
##
|
132
|
+
# Helper to get the bitrate as a string. The prefix '~' is used to denote
|
133
|
+
# variable bit rates.
|
134
|
+
@property
|
135
|
+
def bit_rate_str(self):
|
136
|
+
(vbr, bit_rate) = self.bit_rate
|
137
|
+
return f"{'~' if vbr else ''}{bit_rate} kb/s"
|
138
|
+
|
139
|
+
|
140
|
+
class Mp3AudioFile(core.AudioFile):
|
141
|
+
"""Audio file container for mp3 files."""
|
142
|
+
|
143
|
+
def __init__(self, path, version=id3.ID3_ANY_VERSION):
|
144
|
+
self._tag_version = version
|
145
|
+
|
146
|
+
super().__init__(path)
|
147
|
+
assert self.type == core.AUDIO_MP3
|
148
|
+
|
149
|
+
def _read(self):
|
150
|
+
with open(self.path, "rb") as file_obj:
|
151
|
+
self._tag = id3.Tag()
|
152
|
+
tag_found = self._tag.parse(file_obj, self._tag_version)
|
153
|
+
|
154
|
+
# Compute offset for starting mp3 data search
|
155
|
+
if tag_found and self._tag.isV1():
|
156
|
+
mp3_offset = 0
|
157
|
+
elif tag_found and self._tag.isV2():
|
158
|
+
mp3_offset = self._tag.header.SIZE + self._tag.header.tag_size
|
159
|
+
else:
|
160
|
+
mp3_offset = 0
|
161
|
+
self._tag = None
|
162
|
+
|
163
|
+
try:
|
164
|
+
self._info = Mp3AudioInfo(file_obj, mp3_offset, self._tag)
|
165
|
+
except Mp3Exception as ex:
|
166
|
+
# Only logging a warning here since we can still operate on
|
167
|
+
# the tag.
|
168
|
+
log.warning(ex)
|
169
|
+
self._info = None
|
170
|
+
|
171
|
+
self.type = core.AUDIO_MP3
|
172
|
+
|
173
|
+
def initTag(self, version=id3.ID3_DEFAULT_VERSION):
|
174
|
+
"""Add a id3.Tag to the file (removing any existing tag if one exists)."""
|
175
|
+
self.tag = id3.Tag()
|
176
|
+
self.tag.version = version
|
177
|
+
self.tag.file_info = id3.FileInfo(self.path)
|
178
|
+
return self.tag
|
179
|
+
|
180
|
+
@core.AudioFile.tag.setter
|
181
|
+
def tag(self, t):
|
182
|
+
if t:
|
183
|
+
t.file_info = id3.FileInfo(self.path)
|
184
|
+
if self._tag and self._tag.file_info:
|
185
|
+
t.file_info.tag_size = self._tag.file_info.tag_size
|
186
|
+
t.file_info.tag_padding_size = \
|
187
|
+
self._tag.file_info.tag_padding_size
|
188
|
+
self._tag = t
|