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 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
@@ -0,0 +1,7 @@
1
+
2
+ class DeflacueError(Exception):
3
+ """Exception type raised by deflacue."""
4
+
5
+
6
+ class ParserError(Exception):
7
+ """Cue file parser error."""
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
+ [![release](https://img.shields.io/pypi/v/deflacue.svg)](https://pypi.python.org/pypi/deflacue)
29
+ [![lic](https://img.shields.io/pypi/l/deflacue.svg)](https://pypi.python.org/pypi/deflacue)
30
+ [![coverage](https://img.shields.io/coveralls/idlesign/deflacue/master.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deflacue = deflacue.cli:main
@@ -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.