deflacue 2.1.0__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.
- deflacue/__init__.py +1 -0
- deflacue/cli.py +63 -0
- deflacue/deflacue.py +393 -0
- deflacue/exceptions.py +7 -0
- deflacue/parser.py +243 -0
- deflacue-2.1.0.dist-info/METADATA +71 -0
- deflacue-2.1.0.dist-info/RECORD +10 -0
- deflacue-2.1.0.dist-info/WHEEL +4 -0
- deflacue-2.1.0.dist-info/entry_points.txt +2 -0
- deflacue-2.1.0.dist-info/licenses/LICENSE +27 -0
deflacue/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = '2.1.0'
|
deflacue/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from .deflacue import Deflacue, DeflacueError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
|
|
9
|
+
argparser = argparse.ArgumentParser('deflacue')
|
|
10
|
+
|
|
11
|
+
argparser.add_argument(
|
|
12
|
+
'source_path', help='Absolute or relative source path with .cue file(s).')
|
|
13
|
+
argparser.add_argument(
|
|
14
|
+
'-r', help='Recursion flag to search directories under the source_path.', action='store_true')
|
|
15
|
+
argparser.add_argument(
|
|
16
|
+
'-d', help='Absolute or relative destination path for output audio file(s).')
|
|
17
|
+
argparser.add_argument(
|
|
18
|
+
'-e', help='Cue Sheet file(s) encoding.')
|
|
19
|
+
argparser.add_argument(
|
|
20
|
+
'--dry', help='Perform the dry run with no changes done to filesystem.', action='store_true')
|
|
21
|
+
argparser.add_argument(
|
|
22
|
+
'--delete-source', help='Delete source audio and .cue file(s) after successful extraction.',
|
|
23
|
+
action='store_true')
|
|
24
|
+
argparser.add_argument(
|
|
25
|
+
'--no-bundle-path', help='Write output files directly to the target path, '
|
|
26
|
+
'without the Artist/Album directory structure.',
|
|
27
|
+
action='store_true')
|
|
28
|
+
argparser.add_argument(
|
|
29
|
+
'--debug', help='Show debug messages while processing.', action='store_true')
|
|
30
|
+
|
|
31
|
+
parsed = argparser.parse_args()
|
|
32
|
+
kwargs = {'source_path': parsed.source_path}
|
|
33
|
+
|
|
34
|
+
if parsed.e is not None:
|
|
35
|
+
kwargs['encoding'] = parsed.e
|
|
36
|
+
|
|
37
|
+
if parsed.d is not None:
|
|
38
|
+
kwargs['dest_path'] = parsed.d
|
|
39
|
+
|
|
40
|
+
if parsed.debug:
|
|
41
|
+
kwargs['use_logging'] = logging.DEBUG
|
|
42
|
+
|
|
43
|
+
if parsed.delete_source:
|
|
44
|
+
kwargs['delete_source'] = True
|
|
45
|
+
|
|
46
|
+
if parsed.no_bundle_path:
|
|
47
|
+
kwargs['use_bundle_path'] = False
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
deflacue = Deflacue(**kwargs)
|
|
51
|
+
|
|
52
|
+
if not deflacue.sox_check_is_available():
|
|
53
|
+
raise DeflacueError(
|
|
54
|
+
'SoX seems not available. Please install it (e.g. `sudo apt-get install sox libsox-fmt-all`).'
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if parsed.dry:
|
|
58
|
+
deflacue.set_dry_run()
|
|
59
|
+
|
|
60
|
+
deflacue.do(recursive=parsed.r)
|
|
61
|
+
|
|
62
|
+
except DeflacueError as e:
|
|
63
|
+
logging.error(e)
|
deflacue/deflacue.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
deflacue is a Cue Sheet parser and a wrapper for mighty SoX utility - http://sox.sourceforge.net/.
|
|
3
|
+
|
|
4
|
+
SoX with appropriate plugins should be installed for deflacue to function.
|
|
5
|
+
Ubuntu users may install the following SoX packages: `sox`, `libsox-fmt-all`.
|
|
6
|
+
|
|
7
|
+
deflacue can function both as a Python module and in command line mode.
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from subprocess import Popen, PIPE
|
|
17
|
+
from typing import List, Dict, Union
|
|
18
|
+
|
|
19
|
+
from .exceptions import DeflacueError
|
|
20
|
+
from .parser import CueParser
|
|
21
|
+
|
|
22
|
+
LOGGER = logging.getLogger(__name__)
|
|
23
|
+
TypePath = Union[str, Path]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
COMMENTS_CUE_TO_VORBIS = {
|
|
27
|
+
'TRACK_NUM': 'TRACKNUMBER',
|
|
28
|
+
'TITLE': 'TITLE',
|
|
29
|
+
'PERFORMER': 'ARTIST',
|
|
30
|
+
'ALBUM': 'ALBUM',
|
|
31
|
+
'GENRE': 'GENRE',
|
|
32
|
+
'DATE': 'DATE',
|
|
33
|
+
'ISRC': 'ISRC',
|
|
34
|
+
'COMMENT': 'DESCRIPTION',
|
|
35
|
+
}
|
|
36
|
+
"""Cue REM commands to Vorbis tags."""
|
|
37
|
+
|
|
38
|
+
RE_NAME_INVALID_WIN = re.compile(r'[<>:"/\\|?*]')
|
|
39
|
+
"""Characters invalid in file and directory names on Windows."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def sanitize_name(value: str) -> str:
|
|
43
|
+
"""Removes characters invalid for the target filesystem from a given file or directory name.
|
|
44
|
+
|
|
45
|
+
On Windows also strips characters invalid there, as well as trailing dots
|
|
46
|
+
and spaces, which are silently pruned by the OS, leading to a mismatch
|
|
47
|
+
between computed and actually created paths.
|
|
48
|
+
|
|
49
|
+
:param value:
|
|
50
|
+
|
|
51
|
+
"""
|
|
52
|
+
value = value.replace('/', '')
|
|
53
|
+
|
|
54
|
+
if sys.platform == 'win32':
|
|
55
|
+
value = RE_NAME_INVALID_WIN.sub('', value).rstrip('. ')
|
|
56
|
+
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Deflacue:
|
|
61
|
+
"""deflacue functionality is encapsulated in this class.
|
|
62
|
+
|
|
63
|
+
Usage example:
|
|
64
|
+
deflacue = Deflacue('/home/idle/cues_to_process/')
|
|
65
|
+
deflacue.do()
|
|
66
|
+
|
|
67
|
+
This will search `/home/idle/cues_to_process/` and subdirectories
|
|
68
|
+
for .cue files, parse them and extract separate tracks.
|
|
69
|
+
Extracted tracks are stored in Artist - Album hierarchy within
|
|
70
|
+
`deflacue` directory under each source directory.
|
|
71
|
+
|
|
72
|
+
"""
|
|
73
|
+
_dry_run = False # Some lengthy shell command won't be executed on dry run.
|
|
74
|
+
_target_default = 'deflacue'
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
source_path: TypePath,
|
|
79
|
+
*,
|
|
80
|
+
dest_path: TypePath | None = None,
|
|
81
|
+
encoding: str | None = None,
|
|
82
|
+
use_logging: int = logging.INFO,
|
|
83
|
+
delete_source: bool = False,
|
|
84
|
+
use_bundle_path: bool = True
|
|
85
|
+
):
|
|
86
|
+
"""Prepares deflacue to for audio processing.
|
|
87
|
+
|
|
88
|
+
:param source_path: Absolute or relative to the current directory path,
|
|
89
|
+
containing .cue file(s) or subdirectories with .cue file(s) to process.
|
|
90
|
+
|
|
91
|
+
:param dest_path: Absolute or relative to the current directory path
|
|
92
|
+
to store output files in. If None, output files are saved in `deflacue` directory
|
|
93
|
+
in the same directory as input file(s).
|
|
94
|
+
|
|
95
|
+
:param encoding: Encoding used for .cue file(s).
|
|
96
|
+
|
|
97
|
+
:param use_logging: Defines the verbosity level of deflacue. All messages
|
|
98
|
+
produced by the application are logged with `logging` module.
|
|
99
|
+
Examples: logging.INFO, logging.DEBUG.
|
|
100
|
+
|
|
101
|
+
:param delete_source: If True, source audio file(s) and .cue file are deleted
|
|
102
|
+
after all tracks were extracted successfully.
|
|
103
|
+
|
|
104
|
+
:param use_bundle_path: If False, output files are written directly to the target
|
|
105
|
+
path, without the Artist/Album directory structure.
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
src = Path(source_path).absolute()
|
|
109
|
+
self.path_source: Path = src
|
|
110
|
+
self.path_target: Path | None = dest_path
|
|
111
|
+
self.encoding = encoding
|
|
112
|
+
self._delete_source = delete_source
|
|
113
|
+
self._use_bundle_path = use_bundle_path
|
|
114
|
+
|
|
115
|
+
if use_logging:
|
|
116
|
+
self._configure_logging(use_logging)
|
|
117
|
+
|
|
118
|
+
LOGGER.info('Source path: %s', src)
|
|
119
|
+
|
|
120
|
+
if not src.exists():
|
|
121
|
+
raise DeflacueError(f'Path `{src}` is not found.')
|
|
122
|
+
|
|
123
|
+
if dest_path is not None:
|
|
124
|
+
self.path_target = Path(dest_path).absolute()
|
|
125
|
+
os.chdir(src)
|
|
126
|
+
|
|
127
|
+
def _process_command(
|
|
128
|
+
self,
|
|
129
|
+
command: str,
|
|
130
|
+
*,
|
|
131
|
+
stdout=None,
|
|
132
|
+
suppress_dry_run: bool = False
|
|
133
|
+
) -> int:
|
|
134
|
+
"""Executes shell command with subprocess.Popen.
|
|
135
|
+
Returns status code.
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
LOGGER.debug('Executing shell command: %s', command)
|
|
139
|
+
|
|
140
|
+
if not self._dry_run or suppress_dry_run:
|
|
141
|
+
prc = Popen(command, shell=True, stdout=stdout)
|
|
142
|
+
prc.communicate()
|
|
143
|
+
return prc.returncode
|
|
144
|
+
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def _configure_logging(cls, verbosity_lvl: int = logging.INFO):
|
|
149
|
+
"""Switches on logging at given level."""
|
|
150
|
+
logging.basicConfig(level=verbosity_lvl, format='%(levelname)s: %(message)s')
|
|
151
|
+
|
|
152
|
+
def _create_target_path(self, path: Path | None):
|
|
153
|
+
"""Creates a directory for target files."""
|
|
154
|
+
if self._dry_run or not path:
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
LOGGER.debug('Creating target path: %s ...', path)
|
|
158
|
+
Path(path).mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
def set_dry_run(self):
|
|
161
|
+
"""Sets deflacue into dry run mode, when all requested actions
|
|
162
|
+
are only simulated, and no changes are written to filesystem.
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
self._dry_run = True
|
|
166
|
+
|
|
167
|
+
def get_dir_files(self, *, recursive: bool = False) -> Dict[Path, List[Path]]:
|
|
168
|
+
"""Creates and returns dictionary of files in source directory.
|
|
169
|
+
|
|
170
|
+
:param recursive: if True search is also performed within subdirectories.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
LOGGER.info('Enumerating files under the source path (recursive=%s) ...', recursive)
|
|
174
|
+
|
|
175
|
+
files = {}
|
|
176
|
+
if recursive:
|
|
177
|
+
for current_dir, _, dir_files in os.walk(self.path_source):
|
|
178
|
+
path = self.path_source / current_dir
|
|
179
|
+
files[path] = [path / f for f in dir_files]
|
|
180
|
+
|
|
181
|
+
else:
|
|
182
|
+
files[self.path_source] = [
|
|
183
|
+
f for f in self.path_source.iterdir()
|
|
184
|
+
if f.is_file()
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
return files
|
|
188
|
+
|
|
189
|
+
def filter_target_extensions(self, files_dict: Dict[Path, List[Path]]) -> Dict[Path, List[Path]]:
|
|
190
|
+
"""Takes file dictionary created with `get_dir_files` and returns
|
|
191
|
+
dictionary of the same kind containing only files of supported
|
|
192
|
+
types.
|
|
193
|
+
|
|
194
|
+
:param files_dict:
|
|
195
|
+
|
|
196
|
+
"""
|
|
197
|
+
files_filtered = defaultdict(list)
|
|
198
|
+
LOGGER.info('Filtering .cue files ...')
|
|
199
|
+
paths = files_dict.keys()
|
|
200
|
+
|
|
201
|
+
for path in paths:
|
|
202
|
+
|
|
203
|
+
if path.name == self._target_default:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
for f in sorted(files_dict[path]):
|
|
207
|
+
if f.suffix == '.cue':
|
|
208
|
+
files_filtered[path].append(f)
|
|
209
|
+
|
|
210
|
+
return files_filtered
|
|
211
|
+
|
|
212
|
+
def sox_check_is_available(self) -> bool:
|
|
213
|
+
"""Checks whether SoX is available."""
|
|
214
|
+
result = self._process_command('sox -h', stdout=PIPE, suppress_dry_run=True)
|
|
215
|
+
return result == 0
|
|
216
|
+
|
|
217
|
+
def sox_extract_audio(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
source_file: Path,
|
|
221
|
+
pos_start_samples: int,
|
|
222
|
+
pos_end_samples: int,
|
|
223
|
+
target_file: Path,
|
|
224
|
+
metadata: Dict[str, str] | None = None
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Using SoX extracts a chunk from source audio file into target.
|
|
227
|
+
|
|
228
|
+
:param source_file: Source audio file path
|
|
229
|
+
|
|
230
|
+
:param pos_start_samples: Trim position start (samples)
|
|
231
|
+
|
|
232
|
+
:param pos_end_samples: Trim position end (samples)
|
|
233
|
+
|
|
234
|
+
:param target_file: Trimmed audio file path
|
|
235
|
+
|
|
236
|
+
:param metadata: Additional data (tags) dict.
|
|
237
|
+
|
|
238
|
+
Returns whether extraction succeeded.
|
|
239
|
+
|
|
240
|
+
"""
|
|
241
|
+
LOGGER.info(' Extracting `%s` ...', target_file.name)
|
|
242
|
+
|
|
243
|
+
chunk_length_samples = ''
|
|
244
|
+
if pos_end_samples:
|
|
245
|
+
chunk_length_samples = f'{pos_end_samples - pos_start_samples}s'
|
|
246
|
+
|
|
247
|
+
add_comment = ''
|
|
248
|
+
if metadata is not None:
|
|
249
|
+
LOGGER.debug('Metadata: %s\n', metadata)
|
|
250
|
+
|
|
251
|
+
for key, val in COMMENTS_CUE_TO_VORBIS.items():
|
|
252
|
+
val_meta = metadata.get(key)
|
|
253
|
+
if val_meta:
|
|
254
|
+
add_comment = f'--add-comment="{val}={val_meta}" {add_comment}'
|
|
255
|
+
|
|
256
|
+
LOGGER.debug(
|
|
257
|
+
'Extraction information:\n'
|
|
258
|
+
' Source file: %s\n'
|
|
259
|
+
' Start position: %s samples\n'
|
|
260
|
+
' End position: %s samples\n'
|
|
261
|
+
' Length: %s sample(s)',
|
|
262
|
+
source_file, pos_start_samples, pos_end_samples, chunk_length_samples)
|
|
263
|
+
|
|
264
|
+
command = (
|
|
265
|
+
f'sox -V1 "{source_file}" '
|
|
266
|
+
f'--comment="" {add_comment} "{target_file}" '
|
|
267
|
+
f'trim {pos_start_samples}s {chunk_length_samples}'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return self._process_command(command, stdout=PIPE) == 0
|
|
271
|
+
|
|
272
|
+
def process_cue(self, *, cue_file: Path, target_path: Path):
|
|
273
|
+
"""Parses .cue file, extracts separate tracks.
|
|
274
|
+
|
|
275
|
+
:param cue_file: .cue filepath
|
|
276
|
+
|
|
277
|
+
:param target_path: path to place files into
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
LOGGER.info('\nProcessing `%s`\n', cue_file.name)
|
|
281
|
+
|
|
282
|
+
parser = CueParser.from_file(fpath=cue_file, encoding=self.encoding)
|
|
283
|
+
cue = parser.run()
|
|
284
|
+
|
|
285
|
+
cd_info = cue.meta.data
|
|
286
|
+
tracks = cue.tracks
|
|
287
|
+
|
|
288
|
+
title = cd_info['ALBUM']
|
|
289
|
+
if cd_info['DATE'] is not None:
|
|
290
|
+
title = f"{cd_info['DATE']} - {title}"
|
|
291
|
+
|
|
292
|
+
if self._use_bundle_path:
|
|
293
|
+
bundle_path = target_path / sanitize_name(cd_info['PERFORMER']) / sanitize_name(title)
|
|
294
|
+
|
|
295
|
+
else:
|
|
296
|
+
bundle_path = target_path
|
|
297
|
+
self._create_target_path(bundle_path)
|
|
298
|
+
|
|
299
|
+
len_tracks_count = len(str(len(tracks)))
|
|
300
|
+
source_files: set[Path] = set()
|
|
301
|
+
has_failures = False
|
|
302
|
+
|
|
303
|
+
for track in tracks:
|
|
304
|
+
track_file = track.file.path
|
|
305
|
+
source_files.add(track_file)
|
|
306
|
+
|
|
307
|
+
if not track_file.exists():
|
|
308
|
+
LOGGER.error('Source file `%s` is not found. Track is skipped.', track_file)
|
|
309
|
+
has_failures = True
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
track_num = str(track.num).rjust(len_tracks_count, '0')
|
|
313
|
+
filename = f"{track_num} - {sanitize_name(track.title)}.flac"
|
|
314
|
+
|
|
315
|
+
extracted = self.sox_extract_audio(
|
|
316
|
+
source_file=track_file,
|
|
317
|
+
pos_start_samples=track.start,
|
|
318
|
+
pos_end_samples=track.end,
|
|
319
|
+
target_file=bundle_path / filename,
|
|
320
|
+
metadata=track.data
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
if not extracted:
|
|
324
|
+
has_failures = True
|
|
325
|
+
|
|
326
|
+
if self._delete_source:
|
|
327
|
+
self._delete_source_files(cue_file=cue_file, source_files=source_files, has_failures=has_failures)
|
|
328
|
+
|
|
329
|
+
def _delete_source_files(self, *, cue_file: Path, source_files: set[Path], has_failures: bool):
|
|
330
|
+
"""Deletes source audio file(s) and the .cue file after successful processing.
|
|
331
|
+
|
|
332
|
+
:param cue_file: .cue filepath
|
|
333
|
+
|
|
334
|
+
:param source_files: Audio files referenced by the .cue file
|
|
335
|
+
|
|
336
|
+
:param has_failures: Whether some tracks were not extracted
|
|
337
|
+
|
|
338
|
+
"""
|
|
339
|
+
if has_failures:
|
|
340
|
+
LOGGER.warning("Some tracks were not extracted. Source files are kept for: %s", cue_file.name)
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
if self._dry_run:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
for source_file in sorted(source_files):
|
|
347
|
+
LOGGER.info(" Deleting `%s` ...", source_file.name)
|
|
348
|
+
source_file.unlink()
|
|
349
|
+
|
|
350
|
+
LOGGER.info(" Deleting `%s` ...", cue_file.name)
|
|
351
|
+
cue_file.unlink()
|
|
352
|
+
|
|
353
|
+
def do(self, *, recursive: bool = False):
|
|
354
|
+
"""Main method processing .cue files in batch.
|
|
355
|
+
|
|
356
|
+
:param recursive: if True .cue search is also performed within subdirectories.
|
|
357
|
+
|
|
358
|
+
"""
|
|
359
|
+
self._create_target_path(self.path_target)
|
|
360
|
+
|
|
361
|
+
files_dict = self.filter_target_extensions(self.get_dir_files(recursive=recursive))
|
|
362
|
+
|
|
363
|
+
dir_initial = Path.cwd()
|
|
364
|
+
paths = sorted(files_dict.keys())
|
|
365
|
+
|
|
366
|
+
for path in paths:
|
|
367
|
+
os.chdir(path)
|
|
368
|
+
|
|
369
|
+
LOGGER.info('\n%s\nWorking on: %s\n', '====' * 10, path)
|
|
370
|
+
|
|
371
|
+
if self.path_target is None:
|
|
372
|
+
# When a target path is not specified, create `deflacue` subdirectory
|
|
373
|
+
# in every directory we are working at.
|
|
374
|
+
target_path = path / self._target_default
|
|
375
|
+
|
|
376
|
+
elif self._use_bundle_path:
|
|
377
|
+
# When a target path is specified, we create a subdirectory there
|
|
378
|
+
# named after the directory we are working on.
|
|
379
|
+
target_path = self.path_target / path.name
|
|
380
|
+
|
|
381
|
+
else:
|
|
382
|
+
target_path = self.path_target
|
|
383
|
+
|
|
384
|
+
self._create_target_path(target_path)
|
|
385
|
+
|
|
386
|
+
LOGGER.info('Target (output) path: %s', target_path)
|
|
387
|
+
|
|
388
|
+
for cue in files_dict[path]:
|
|
389
|
+
self.process_cue(cue_file=path / cue, target_path=target_path)
|
|
390
|
+
|
|
391
|
+
os.chdir(dir_initial)
|
|
392
|
+
|
|
393
|
+
LOGGER.info('We are done. Thank you.\n')
|
deflacue/exceptions.py
ADDED
deflacue/parser.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List, Tuple
|
|
5
|
+
|
|
6
|
+
from .exceptions import ParserError
|
|
7
|
+
|
|
8
|
+
LOGGER = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pos_to_frames(pos) -> int:
|
|
12
|
+
"""Converts position (mm:ss:ff) into frames.
|
|
13
|
+
|
|
14
|
+
:param pos:
|
|
15
|
+
|
|
16
|
+
"""
|
|
17
|
+
minutes, seconds, frames = map(int, pos.split(':'))
|
|
18
|
+
seconds = (minutes * 60) + seconds
|
|
19
|
+
rate = 44100
|
|
20
|
+
return (seconds * rate) + (frames * (rate // 75))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Context:
|
|
24
|
+
"""Basic context."""
|
|
25
|
+
|
|
26
|
+
_default = {}
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, data: dict):
|
|
29
|
+
self.data = {**self._default, **deepcopy(data)}
|
|
30
|
+
|
|
31
|
+
def add(self, key: str, val: str):
|
|
32
|
+
self.data[key] = val
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MetaContext(Context):
|
|
36
|
+
"""Global .cue meta context."""
|
|
37
|
+
|
|
38
|
+
_default = {
|
|
39
|
+
'ALBUM': 'Unknown',
|
|
40
|
+
'PERFORMER': 'Unknown',
|
|
41
|
+
'DATE': None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
def add(self, key: str, val: str):
|
|
45
|
+
if key == 'TITLE':
|
|
46
|
+
key = 'ALBUM'
|
|
47
|
+
super().add(key, val)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FileContext(Context):
|
|
51
|
+
"""File information."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *, path: str, ftype: str, data: dict):
|
|
54
|
+
self.path: Path = Path(path)
|
|
55
|
+
"""File path."""
|
|
56
|
+
|
|
57
|
+
self.type = ftype
|
|
58
|
+
"""File type."""
|
|
59
|
+
|
|
60
|
+
self.tracks: List[TrackContext] = []
|
|
61
|
+
"""Tracks in file."""
|
|
62
|
+
|
|
63
|
+
super().__init__(data=data)
|
|
64
|
+
|
|
65
|
+
def __str__(self):
|
|
66
|
+
return str(self.path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TrackContext(Context):
|
|
70
|
+
"""Track information."""
|
|
71
|
+
|
|
72
|
+
_default = {
|
|
73
|
+
'TITLE': 'Unknown',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def __init__(self, *, file: FileContext, num: int, dtype: str):
|
|
77
|
+
self.file = file
|
|
78
|
+
"""File containing track."""
|
|
79
|
+
|
|
80
|
+
self.num = num
|
|
81
|
+
"""Track number."""
|
|
82
|
+
|
|
83
|
+
self.type = dtype
|
|
84
|
+
"""Track data type."""
|
|
85
|
+
|
|
86
|
+
self.start: int = 0
|
|
87
|
+
"""Start position (frames)."""
|
|
88
|
+
|
|
89
|
+
super().__init__(data=file.data)
|
|
90
|
+
|
|
91
|
+
def __str__(self):
|
|
92
|
+
return f"{self.num} {self.title} @ {self.file}"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def title(self):
|
|
96
|
+
return self.data.get('TITLE', '')
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def end(self) -> int:
|
|
100
|
+
tracks = self.file.tracks
|
|
101
|
+
end = 0
|
|
102
|
+
|
|
103
|
+
for idx, track in enumerate(tracks):
|
|
104
|
+
if track is self:
|
|
105
|
+
try:
|
|
106
|
+
end = tracks[idx+1].start
|
|
107
|
+
|
|
108
|
+
except IndexError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
return end
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class CueData:
|
|
117
|
+
"""Represents data from .cue file."""
|
|
118
|
+
|
|
119
|
+
def __init__(self):
|
|
120
|
+
self.meta = []
|
|
121
|
+
|
|
122
|
+
self.meta = MetaContext(data={})
|
|
123
|
+
"""Basic information."""
|
|
124
|
+
|
|
125
|
+
self.files: List[FileContext] = []
|
|
126
|
+
"""Files in image."""
|
|
127
|
+
|
|
128
|
+
self.tracks: List[TrackContext] = []
|
|
129
|
+
"""Tracks in image."""
|
|
130
|
+
|
|
131
|
+
self._current_file: FileContext | None = None
|
|
132
|
+
self._current_track: TrackContext | None = None
|
|
133
|
+
self._current_context: Context = self.meta
|
|
134
|
+
|
|
135
|
+
def add_context(self, key, val):
|
|
136
|
+
self._current_context.add(key, val)
|
|
137
|
+
|
|
138
|
+
def add_file(self, *, path: str, ftype: str):
|
|
139
|
+
file_context = FileContext(
|
|
140
|
+
path=path,
|
|
141
|
+
ftype=ftype,
|
|
142
|
+
data=self._current_context.data
|
|
143
|
+
)
|
|
144
|
+
self._current_context = file_context
|
|
145
|
+
self._current_file = file_context
|
|
146
|
+
self.files.append(file_context)
|
|
147
|
+
|
|
148
|
+
def add_track(self, *, num: int, dtype: str):
|
|
149
|
+
file_context = self._current_file
|
|
150
|
+
track_context = TrackContext(
|
|
151
|
+
file=self._current_file,
|
|
152
|
+
num=num,
|
|
153
|
+
dtype=dtype,
|
|
154
|
+
)
|
|
155
|
+
track_context.add('TRACK_NUM', f'{num}')
|
|
156
|
+
file_context.tracks.append(track_context)
|
|
157
|
+
self._current_context = track_context
|
|
158
|
+
self._current_track = track_context
|
|
159
|
+
self.tracks.append(track_context)
|
|
160
|
+
|
|
161
|
+
def add_track_index(self, *, pos: str):
|
|
162
|
+
self._current_track.start = pos_to_frames(pos)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CueParser:
|
|
166
|
+
"""Simple Cue Sheet file parser."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, lines: List[str]):
|
|
169
|
+
self.lines = lines
|
|
170
|
+
|
|
171
|
+
def run(self) -> CueData:
|
|
172
|
+
|
|
173
|
+
cue = CueData()
|
|
174
|
+
parse_cmd = self._parse_command
|
|
175
|
+
unquote = self._unquote
|
|
176
|
+
|
|
177
|
+
for line in self.lines:
|
|
178
|
+
cmd, args = parse_cmd(line)
|
|
179
|
+
|
|
180
|
+
if cmd == 'REM':
|
|
181
|
+
cue.add_context(*parse_cmd(args))
|
|
182
|
+
|
|
183
|
+
elif cmd == 'FILE':
|
|
184
|
+
fpath, ftype = args.rsplit(' ', 1)
|
|
185
|
+
fpath = unquote(fpath)
|
|
186
|
+
cue.add_file(path=fpath, ftype=ftype)
|
|
187
|
+
|
|
188
|
+
elif cmd == 'TRACK':
|
|
189
|
+
num, _, dtype = args.partition(' ')
|
|
190
|
+
cue.add_track(num=int(num), dtype=dtype)
|
|
191
|
+
|
|
192
|
+
elif cmd == 'INDEX':
|
|
193
|
+
num, _, pos = args.partition(' ')
|
|
194
|
+
|
|
195
|
+
if num == '01':
|
|
196
|
+
cue.add_context(f'{cmd} {num}', pos)
|
|
197
|
+
cue.add_track_index(pos=pos)
|
|
198
|
+
|
|
199
|
+
else:
|
|
200
|
+
cue.add_context(cmd, args)
|
|
201
|
+
|
|
202
|
+
return cue
|
|
203
|
+
|
|
204
|
+
def _parse_command(self, cmd: str) -> Tuple[str, str]:
|
|
205
|
+
command, _, args = cmd.partition(' ')
|
|
206
|
+
args = self._unquote(args)
|
|
207
|
+
|
|
208
|
+
LOGGER.debug('Parsed command `%s`. Args: %s', command, args)
|
|
209
|
+
|
|
210
|
+
return command, args
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def _unquote(cls, val: str) -> str:
|
|
214
|
+
return val.strip(' "')
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def from_file(cls, fpath: Path, *, encoding: str | None = None) -> 'CueParser':
|
|
218
|
+
|
|
219
|
+
def read(coding: str | None = None) -> CueParser | None:
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
with Path(str(fpath)).open(encoding=coding) as f:
|
|
223
|
+
return CueParser([
|
|
224
|
+
line.strip() for line in f
|
|
225
|
+
if line.strip()
|
|
226
|
+
])
|
|
227
|
+
|
|
228
|
+
except UnicodeDecodeError:
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
parser = read(encoding)
|
|
232
|
+
|
|
233
|
+
if not parser:
|
|
234
|
+
# Try unicode as a fallback.
|
|
235
|
+
parser = read()
|
|
236
|
+
|
|
237
|
+
if not parser:
|
|
238
|
+
raise ParserError(
|
|
239
|
+
'Unable to read data from .cue file. '
|
|
240
|
+
'Please provide a correct encoding.'
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return parser
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deflacue
|
|
3
|
+
Version: 2.1.0
|
|
4
|
+
Summary: SoX based audio splitter to split audio CD images incorporated with .cue files
|
|
5
|
+
Project-URL: Homepage, https://github.com/idlesign/deflacue
|
|
6
|
+
Author-email: Igor Starikov <idlesign@yandex.ru>
|
|
7
|
+
License-Expression: BSD-3-Clause
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: audio,cue,flac,sox
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# deflacue
|
|
25
|
+
|
|
26
|
+
<https://github.com/idlesign/deflacue>
|
|
27
|
+
|
|
28
|
+
[](https://pypi.python.org/pypi/deflacue)
|
|
29
|
+
[](https://pypi.python.org/pypi/deflacue)
|
|
30
|
+
[](https://coveralls.io/r/idlesign/deflacue)
|
|
31
|
+
|
|
32
|
+
## What's that
|
|
33
|
+
|
|
34
|
+
*deflacue is a SoX based audio splitter appropriate to split audio CD images incorporated with .cue files.*
|
|
35
|
+
|
|
36
|
+
It is able to function both as a Python module and in command line mode.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- Large variety of supported lossless input audio formats FLAC, WAV, etc. (due to SoX).
|
|
41
|
+
- Batch audio files processing (including recursive path traversing).
|
|
42
|
+
- Automatic audio collection hierarchy building (Artist/Year-Album/Tracknum-Title).
|
|
43
|
+
- Automatic track metadata copying from .cue.
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
* Python 3.10+
|
|
48
|
+
* SoX command line utility — <http://sox.sourceforge.net>.
|
|
49
|
+
|
|
50
|
+
Ubuntu users may install the following SoX packages: `sox`, `libsox-fmt-all`.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
### From Python
|
|
55
|
+
|
|
56
|
+
`from deflacue import deflacue` — if you want to use it as module.
|
|
57
|
+
|
|
58
|
+
Use `Deflacue` class for SoX interaction.
|
|
59
|
+
|
|
60
|
+
Use `CueParser` class for .cue parsing.
|
|
61
|
+
|
|
62
|
+
### From Command Line
|
|
63
|
+
|
|
64
|
+
`deflacue --help` in command line — to get help on utility usage.
|
|
65
|
+
|
|
66
|
+
In the following example we create audio collection in `/home/idle/audio_collection/` from audio CD images
|
|
67
|
+
stored under `/home/idle/audio_raw/` processing Cue Sheet files created using `windows-1251` encoding:
|
|
68
|
+
|
|
69
|
+
```shell
|
|
70
|
+
$ deflacue -e windows-1251 /home/idle/audio_raw/ -d /home/idle/audio_collection/
|
|
71
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
deflacue/__init__.py,sha256=s5F39ikwvanccisuElhg0gEoVBisSR9dXR281TnsnzQ,18
|
|
2
|
+
deflacue/cli.py,sha256=65BxhtuFzpIxs8TFvSXa9u790tnCz59WiVTZ2sx8jK0,2052
|
|
3
|
+
deflacue/deflacue.py,sha256=c6lQk-416823mM46K09jHoJrFHe0GozxeOjrUyJGaiM,12724
|
|
4
|
+
deflacue/exceptions.py,sha256=qwmg5gEhqe0zbyNZamjD7u9cy7jvh-q9fZeiKpZDOBM,143
|
|
5
|
+
deflacue/parser.py,sha256=E9Aw2D29O1YIhMwSanehA0LCyien6yczIIctmbiUZDs,5940
|
|
6
|
+
deflacue-2.1.0.dist-info/METADATA,sha256=7ovWvtWc2A0gQNa-0Q3S95ffKT3u0g5HN7spNisvv6I,2556
|
|
7
|
+
deflacue-2.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
deflacue-2.1.0.dist-info/entry_points.txt,sha256=OylZ8m7onZIA0Hojxhek8-XucsFH8LyPGBBVgwNrcu0,47
|
|
9
|
+
deflacue-2.1.0.dist-info/licenses/LICENSE,sha256=LXASjE5Quh7Pa7ZuYjUdPZxHTP2VA4PUikEBzgofQgE,1539
|
|
10
|
+
deflacue-2.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Copyright (c) 2012-2026, Igor 'idle sign' Starikov
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
5
|
+
are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
8
|
+
this list of conditions and the following disclaimer.
|
|
9
|
+
|
|
10
|
+
2. Redistributions in binary form must reproduce the above copyright
|
|
11
|
+
notice, this list of conditions and the following disclaimer in the
|
|
12
|
+
documentation and/or other materials provided with the distribution.
|
|
13
|
+
|
|
14
|
+
3. Neither the name of the deflacue nor the names of its
|
|
15
|
+
contributors may be used to endorse or promote products derived from
|
|
16
|
+
this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
19
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
20
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
22
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
23
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
24
|
+
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
25
|
+
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
26
|
+
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
27
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|