TonieToolbox 0.1.8__py3-none-any.whl → 0.2.1__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.
TonieToolbox/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
  TonieToolbox - Convert audio files to Tonie box compatible format
3
3
  """
4
4
 
5
- __version__ = '0.1.8'
5
+ __version__ = '0.2.1'
TonieToolbox/__main__.py CHANGED
@@ -16,51 +16,55 @@ from .dependency_manager import get_ffmpeg_binary, get_opus_binary
16
16
  from .logger import setup_logging, get_logger
17
17
  from .filename_generator import guess_output_filename
18
18
  from .version_handler import check_for_updates, clear_version_cache
19
+ from .recursive_processor import process_recursive_folders
19
20
 
20
21
  def main():
21
22
  """Entry point for the TonieToolbox application."""
22
23
  parser = argparse.ArgumentParser(description='Create Tonie compatible file from Ogg opus file(s).')
23
- parser.add_argument('--version', action='version', version=f'TonieToolbox {__version__}',
24
+ parser.add_argument('-v', '--version', action='version', version=f'TonieToolbox {__version__}',
24
25
  help='show program version and exit')
25
26
  parser.add_argument('input_filename', metavar='SOURCE', type=str,
26
27
  help='input file or directory or a file list (.lst)')
27
28
  parser.add_argument('output_filename', metavar='TARGET', nargs='?', type=str,
28
29
  help='the output file name (default: ---ID---)')
29
- parser.add_argument('--ts', dest='user_timestamp', metavar='TIMESTAMP', action='store',
30
+ parser.add_argument('-t', '--timestamp', dest='user_timestamp', metavar='TIMESTAMP', action='store',
30
31
  help='set custom timestamp / bitstream serial')
31
32
 
32
- parser.add_argument('--ffmpeg', help='specify location of ffmpeg', default=None)
33
- parser.add_argument('--opusenc', help='specify location of opusenc', default=None)
34
- parser.add_argument('--bitrate', type=int, help='set encoding bitrate in kbps (default: 96)', default=96)
35
- parser.add_argument('--cbr', action='store_true', help='encode in cbr mode')
36
- parser.add_argument('--append-tonie-tag', metavar='TAG', action='store',
33
+ parser.add_argument('-f', '--ffmpeg', help='specify location of ffmpeg', default=None)
34
+ parser.add_argument('-o', '--opusenc', help='specify location of opusenc', default=None)
35
+ parser.add_argument('-b', '--bitrate', type=int, help='set encoding bitrate in kbps (default: 96)', default=96)
36
+ parser.add_argument('-c', '--cbr', action='store_true', help='encode in cbr mode')
37
+ parser.add_argument('-a', '--append-tonie-tag', metavar='TAG', action='store',
37
38
  help='append [TAG] to filename (must be an 8-character hex value)')
38
- parser.add_argument('--no-tonie-header', action='store_true', help='do not write Tonie header')
39
- parser.add_argument('--info', action='store_true', help='Check and display info about Tonie file')
40
- parser.add_argument('--split', action='store_true', help='Split Tonie file into opus tracks')
41
- parser.add_argument('--auto-download', action='store_true', help='Automatically download FFmpeg and opusenc if needed')
42
- parser.add_argument('--keep-temp', action='store_true',
39
+ parser.add_argument('-n', '--no-tonie-header', action='store_true', help='do not write Tonie header')
40
+ parser.add_argument('-i', '--info', action='store_true', help='Check and display info about Tonie file')
41
+ parser.add_argument('-s', '--split', action='store_true', help='Split Tonie file into opus tracks')
42
+ parser.add_argument('-r', '--recursive', action='store_true', help='Process folders recursively')
43
+ parser.add_argument('-O', '--output-to-source', action='store_true',
44
+ help='Save output files in the source directory instead of output directory')
45
+ parser.add_argument('-A', '--auto-download', action='store_true', help='Automatically download FFmpeg and opusenc if needed')
46
+ parser.add_argument('-k', '--keep-temp', action='store_true',
43
47
  help='Keep temporary opus files in a temp folder for testing')
44
- parser.add_argument('--compare', action='store', metavar='FILE2',
48
+ parser.add_argument('-C', '--compare', action='store', metavar='FILE2',
45
49
  help='Compare input file with another .taf file for debugging')
46
- parser.add_argument('--detailed-compare', action='store_true',
50
+ parser.add_argument('-D', '--detailed-compare', action='store_true',
47
51
  help='Show detailed OGG page differences when comparing files')
48
52
 
49
53
  # Version check options
50
54
  version_group = parser.add_argument_group('Version Check Options')
51
- version_group.add_argument('--skip-update-check', action='store_true',
55
+ version_group.add_argument('-S', '--skip-update-check', action='store_true',
52
56
  help='Skip checking for updates')
53
- version_group.add_argument('--force-refresh-cache', action='store_true',
57
+ version_group.add_argument('-F', '--force-refresh-cache', action='store_true',
54
58
  help='Force refresh of update information from PyPI')
55
- version_group.add_argument('--clear-version-cache', action='store_true',
59
+ version_group.add_argument('-X', '--clear-version-cache', action='store_true',
56
60
  help='Clear cached version information')
57
61
 
58
62
  log_group = parser.add_argument_group('Logging Options')
59
63
  log_level_group = log_group.add_mutually_exclusive_group()
60
- log_level_group.add_argument('--debug', action='store_true', help='Enable debug logging')
61
- log_level_group.add_argument('--trace', action='store_true', help='Enable trace logging (very verbose)')
62
- log_level_group.add_argument('--quiet', action='store_true', help='Show only warnings and errors')
63
- log_level_group.add_argument('--silent', action='store_true', help='Show only errors')
64
+ log_level_group.add_argument('-d', '--debug', action='store_true', help='Enable debug logging')
65
+ log_level_group.add_argument('-T', '--trace', action='store_true', help='Enable trace logging (very verbose)')
66
+ log_level_group.add_argument('-q', '--quiet', action='store_true', help='Show only warnings and errors')
67
+ log_level_group.add_argument('-Q', '--silent', action='store_true', help='Show only errors')
64
68
 
65
69
  args = parser.parse_args()
66
70
  if args.trace:
@@ -113,6 +117,39 @@ def main():
113
117
  sys.exit(1)
114
118
  logger.debug("Using opusenc binary: %s", opus_binary)
115
119
 
120
+ # Handle recursive processing
121
+ if args.recursive:
122
+ logger.info("Processing folders recursively: %s", args.input_filename)
123
+ process_tasks = process_recursive_folders(args.input_filename)
124
+
125
+ if not process_tasks:
126
+ logger.error("No folders with audio files found for recursive processing")
127
+ sys.exit(1)
128
+
129
+ output_dir = None if args.output_to_source else './output'
130
+
131
+ if output_dir and not os.path.exists(output_dir):
132
+ os.makedirs(output_dir, exist_ok=True)
133
+ logger.debug("Created output directory: %s", output_dir)
134
+
135
+ for task_index, (output_name, folder_path, audio_files) in enumerate(process_tasks):
136
+ if args.output_to_source:
137
+ task_out_filename = os.path.join(folder_path, f"{output_name}.taf")
138
+ else:
139
+ task_out_filename = os.path.join(output_dir, f"{output_name}.taf")
140
+
141
+ logger.info("[%d/%d] Processing folder: %s -> %s",
142
+ task_index + 1, len(process_tasks), folder_path, task_out_filename)
143
+
144
+ create_tonie_file(task_out_filename, audio_files, args.no_tonie_header, args.user_timestamp,
145
+ args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp,
146
+ args.auto_download)
147
+ logger.info("Successfully created Tonie file: %s", task_out_filename)
148
+
149
+ logger.info("Recursive processing completed. Created %d Tonie files.", len(process_tasks))
150
+ sys.exit(0)
151
+
152
+ # Handle directory or file input
116
153
  if os.path.isdir(args.input_filename):
117
154
  logger.debug("Input is a directory: %s", args.input_filename)
118
155
  args.input_filename += "/*"
@@ -143,12 +180,17 @@ def main():
143
180
  out_filename = args.output_filename
144
181
  else:
145
182
  guessed_name = guess_output_filename(args.input_filename, files)
146
- output_dir = './output'
147
- if not os.path.exists(output_dir):
148
- logger.debug("Creating default output directory: %s", output_dir)
149
- os.makedirs(output_dir, exist_ok=True)
150
- out_filename = os.path.join(output_dir, guessed_name)
151
- logger.debug("Using default output location: %s", out_filename)
183
+ if args.output_to_source:
184
+ source_dir = os.path.dirname(files[0]) if files else '.'
185
+ out_filename = os.path.join(source_dir, guessed_name)
186
+ logger.debug("Using source location for output: %s", out_filename)
187
+ else:
188
+ output_dir = './output'
189
+ if not os.path.exists(output_dir):
190
+ logger.debug("Creating default output directory: %s", output_dir)
191
+ os.makedirs(output_dir, exist_ok=True)
192
+ out_filename = os.path.join(output_dir, guessed_name)
193
+ logger.debug("Using default output location: %s", out_filename)
152
194
 
153
195
  if args.append_tonie_tag:
154
196
  logger.debug("Appending Tonie tag to output filename")
@@ -162,7 +204,7 @@ def main():
162
204
 
163
205
  if not out_filename.lower().endswith('.taf'):
164
206
  out_filename += '.taf'
165
-
207
+
166
208
  logger.info("Creating Tonie file: %s with %d input file(s)", out_filename, len(files))
167
209
  create_tonie_file(out_filename, files, args.no_tonie_header, args.user_timestamp,
168
210
  args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp, args.auto_download)
@@ -58,37 +58,65 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
58
58
  logger.info("Creating persistent temporary file: %s", temp_path)
59
59
 
60
60
  logger.debug("Starting FFmpeg process")
61
- ffmpeg_process = subprocess.Popen(
62
- [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
63
- "-ar", "48000", "-"], stdout=subprocess.PIPE)
61
+ try:
62
+ ffmpeg_process = subprocess.Popen(
63
+ [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
64
+ "-ar", "48000", "-"], stdout=subprocess.PIPE)
65
+ except FileNotFoundError:
66
+ logger.error("Error opening input file %s", filename)
67
+ raise RuntimeError(f"Error opening input file {filename}")
64
68
 
65
69
  logger.debug("Starting opusenc process")
66
- opusenc_process = subprocess.Popen(
67
- [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", temp_path],
68
- stdin=ffmpeg_process.stdout, stderr=subprocess.DEVNULL)
70
+ try:
71
+ opusenc_process = subprocess.Popen(
72
+ [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", temp_path],
73
+ stdin=ffmpeg_process.stdout, stderr=subprocess.DEVNULL)
74
+ except Exception as e:
75
+ logger.error("Opus encoding failed: %s", str(e))
76
+ raise RuntimeError(f"Opus encoding failed: {str(e)}")
69
77
 
70
- opusenc_process.communicate()
78
+ ffmpeg_process.stdout.close() # Allow ffmpeg to receive SIGPIPE if opusenc exits
79
+ opusenc_return = opusenc_process.wait()
80
+ ffmpeg_return = ffmpeg_process.wait()
71
81
 
72
- if opusenc_process.returncode != 0:
73
- logger.error("Opus encoding failed with return code %d", opusenc_process.returncode)
74
- raise RuntimeError(f"Opus encoding failed with return code {opusenc_process.returncode}")
82
+ if ffmpeg_return != 0:
83
+ logger.error("FFmpeg processing failed with return code %d", ffmpeg_return)
84
+ raise RuntimeError(f"FFmpeg processing failed with return code {ffmpeg_return}")
85
+
86
+ if opusenc_return != 0:
87
+ logger.error("Opus encoding failed with return code %d", opusenc_return)
88
+ raise RuntimeError(f"Opus encoding failed with return code {opusenc_return}")
75
89
 
76
90
  logger.debug("Opening temporary file for reading")
77
- tmp_file = open(temp_path, "rb")
78
- return tmp_file, temp_path
91
+ try:
92
+ tmp_file = open(temp_path, "rb")
93
+ return tmp_file, temp_path
94
+ except Exception as e:
95
+ logger.error("Failed to open temporary file: %s", str(e))
96
+ raise RuntimeError(f"Failed to open temporary file: {str(e)}")
79
97
  else:
80
98
  logger.debug("Using in-memory temporary file")
81
99
 
82
100
  logger.debug("Starting FFmpeg process")
83
- ffmpeg_process = subprocess.Popen(
84
- [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
85
- "-ar", "48000", "-"], stdout=subprocess.PIPE)
101
+ try:
102
+ ffmpeg_process = subprocess.Popen(
103
+ [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
104
+ "-ar", "48000", "-"], stdout=subprocess.PIPE)
105
+ except FileNotFoundError:
106
+ logger.error("Error opening input file %s", filename)
107
+ raise RuntimeError(f"Error opening input file {filename}")
86
108
 
87
109
  logger.debug("Starting opusenc process")
88
- opusenc_process = subprocess.Popen(
89
- [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", "-"],
90
- stdin=ffmpeg_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
110
+ try:
111
+ opusenc_process = subprocess.Popen(
112
+ [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", "-"],
113
+ stdin=ffmpeg_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
114
+ except Exception as e:
115
+ logger.error("Opus encoding failed: %s", str(e))
116
+ raise RuntimeError(f"Opus encoding failed: {str(e)}")
91
117
 
118
+ ffmpeg_process.stdout.close() # Allow ffmpeg to receive SIGPIPE if opusenc exits
119
+
92
120
  tmp_file = tempfile.SpooledTemporaryFile()
93
121
  bytes_written = 0
94
122
 
@@ -97,9 +125,16 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
97
125
  tmp_file.write(chunk)
98
126
  bytes_written += len(chunk)
99
127
 
100
- if opusenc_process.wait() != 0:
101
- logger.error("Opus encoding failed with return code %d", opusenc_process.returncode)
102
- raise RuntimeError(f"Opus encoding failed with return code {opusenc_process.returncode}")
128
+ opusenc_return = opusenc_process.wait()
129
+ ffmpeg_return = ffmpeg_process.wait()
130
+
131
+ if ffmpeg_return != 0:
132
+ logger.error("FFmpeg processing failed with return code %d", ffmpeg_return)
133
+ raise RuntimeError(f"FFmpeg processing failed with return code {ffmpeg_return}")
134
+
135
+ if opusenc_return != 0:
136
+ logger.error("Opus encoding failed with return code %d", opusenc_return)
137
+ raise RuntimeError(f"Opus encoding failed with return code {opusenc_return}")
103
138
 
104
139
  logger.debug("Wrote %d bytes to temporary file", bytes_written)
105
140
  tmp_file.seek(0)
@@ -157,12 +192,38 @@ def get_input_files(input_filename):
157
192
  logger.debug("Processing list file: %s", input_filename)
158
193
  list_dir = os.path.dirname(os.path.abspath(input_filename))
159
194
  input_files = []
160
- with open(input_filename) as file_list:
161
- for line in file_list:
162
- fname = line.rstrip()
163
- full_path = os.path.join(list_dir, fname)
164
- input_files.append(full_path)
165
- logger.trace("Added file from list: %s", full_path)
195
+ with open(input_filename, 'r', encoding='utf-8') as file_list:
196
+ for line_num, line in enumerate(file_list, 1):
197
+ fname = line.strip()
198
+ if not fname or fname.startswith('#'): # Skip empty lines and comments
199
+ continue
200
+
201
+ # Remove any quote characters from path
202
+ fname = fname.strip('"\'')
203
+
204
+ # Check if the path is absolute or has a drive letter (Windows)
205
+ if os.path.isabs(fname) or (len(fname) > 1 and fname[1] == ':'):
206
+ full_path = fname # Use as is if it's an absolute path
207
+ logger.trace("Using absolute path from list: %s", full_path)
208
+ else:
209
+ full_path = os.path.join(list_dir, fname)
210
+ logger.trace("Using relative path from list: %s", full_path)
211
+
212
+ # Handle directory paths by finding all audio files in the directory
213
+ if os.path.isdir(full_path):
214
+ logger.debug("Path is a directory, finding audio files in: %s", full_path)
215
+ dir_glob = os.path.join(full_path, "*")
216
+ dir_files = sorted(filter_directories(glob.glob(dir_glob)))
217
+ if dir_files:
218
+ input_files.extend(dir_files)
219
+ logger.debug("Found %d audio files in directory", len(dir_files))
220
+ else:
221
+ logger.warning("No audio files found in directory at line %d: %s", line_num, full_path)
222
+ elif os.path.isfile(full_path):
223
+ input_files.append(full_path)
224
+ else:
225
+ logger.warning("File not found at line %d: %s", line_num, full_path)
226
+
166
227
  logger.debug("Found %d files in list file", len(input_files))
167
228
  else:
168
229
  logger.debug("Processing glob pattern: %s", input_filename)
@@ -392,6 +392,9 @@ def ensure_dependency(dependency_name, auto_download=False):
392
392
  dependency_info = DEPENDENCIES[dependency_name].get(system, {})
393
393
  binary_path = dependency_info.get('bin_path', dependency_name if dependency_name != 'opusenc' else 'opusenc')
394
394
 
395
+ # Define bin_name early so it's available in all code paths
396
+ bin_name = dependency_name if dependency_name != 'opusenc' else 'opusenc'
397
+
395
398
  # Create a specific folder for this dependency
396
399
  dependency_dir = os.path.join(user_data_dir, dependency_name)
397
400
 
@@ -442,7 +445,6 @@ def ensure_dependency(dependency_name, auto_download=False):
442
445
  logger.warning("Error verifying downloaded binary: %s", e)
443
446
 
444
447
  # Second priority: Check if it's in PATH (only if auto_download is False)
445
- bin_name = dependency_name if dependency_name != 'opusenc' else 'opusenc'
446
448
  path_binary = check_binary_in_path(bin_name)
447
449
  if path_binary:
448
450
  logger.info("Found %s in PATH: %s", dependency_name, path_binary)
@@ -0,0 +1,250 @@
1
+ """
2
+ Recursive folder processing functionality for the TonieToolbox package
3
+ """
4
+
5
+ import os
6
+ import glob
7
+ from typing import List, Dict, Tuple, Set
8
+ import logging
9
+ import re
10
+
11
+ from .audio_conversion import filter_directories
12
+ from .logger import get_logger
13
+
14
+ logger = get_logger('recursive_processor')
15
+
16
+
17
+ def find_audio_folders(root_path: str) -> List[Dict[str, any]]:
18
+ """
19
+ Find and return all folders that contain audio files in a recursive manner,
20
+ organized in a way that handles nested folder structures.
21
+
22
+ Args:
23
+ root_path: Root directory to start searching from
24
+
25
+ Returns:
26
+ List of dictionaries with folder information, including paths and relationships
27
+ """
28
+ logger.info("Finding folders with audio files in: %s", root_path)
29
+
30
+ # Dictionary to store folder information
31
+ # Key: folder path, Value: {audio_files, parent, children, depth}
32
+ folders_info = {}
33
+ abs_root = os.path.abspath(root_path)
34
+
35
+ # First pass: Identify all folders containing audio files and calculate their depth
36
+ for dirpath, dirnames, filenames in os.walk(abs_root):
37
+ # Look for audio files in this directory
38
+ all_files = [os.path.join(dirpath, f) for f in filenames]
39
+ audio_files = filter_directories(all_files)
40
+
41
+ if audio_files:
42
+ # Calculate folder depth relative to root
43
+ rel_path = os.path.relpath(dirpath, abs_root)
44
+ depth = 0 if rel_path == '.' else rel_path.count(os.sep) + 1
45
+
46
+ # Store folder info
47
+ folders_info[dirpath] = {
48
+ 'path': dirpath,
49
+ 'audio_files': audio_files,
50
+ 'parent': os.path.dirname(dirpath),
51
+ 'children': [],
52
+ 'depth': depth,
53
+ 'file_count': len(audio_files)
54
+ }
55
+ logger.debug("Found folder with %d audio files: %s (depth %d)",
56
+ len(audio_files), dirpath, depth)
57
+
58
+ # Second pass: Build parent-child relationships
59
+ for folder_path, info in folders_info.items():
60
+ parent_path = info['parent']
61
+ if parent_path in folders_info:
62
+ folders_info[parent_path]['children'].append(folder_path)
63
+
64
+ # Convert to list and sort by path for consistent processing
65
+ folder_list = sorted(folders_info.values(), key=lambda x: x['path'])
66
+ logger.info("Found %d folders containing audio files", len(folder_list))
67
+
68
+ return folder_list
69
+
70
+
71
+ def determine_processing_folders(folders: List[Dict[str, any]]) -> List[Dict[str, any]]:
72
+ """
73
+ Determine which folders should be processed based on their position in the hierarchy.
74
+
75
+ Args:
76
+ folders: List of folder dictionaries with hierarchy information
77
+
78
+ Returns:
79
+ List of folders that should be processed (filtered)
80
+ """
81
+ # We'll use a set to track which folders we've decided to process
82
+ to_process = set()
83
+
84
+ # Let's examine folders with the deepest nesting level first
85
+ max_depth = max(folder['depth'] for folder in folders) if folders else 0
86
+
87
+ # First, mark terminal folders (leaf nodes) for processing
88
+ for folder in folders:
89
+ if not folder['children']: # No children means it's a leaf node
90
+ to_process.add(folder['path'])
91
+ logger.debug("Marking leaf folder for processing: %s", folder['path'])
92
+
93
+ # Check if any parent folders should be processed
94
+ # If a parent folder has significantly more audio files than the sum of its children,
95
+ # or some children aren't marked for processing, we should process the parent too
96
+ all_folders_by_path = {folder['path']: folder for folder in folders}
97
+
98
+ # Work from bottom up (max depth to min)
99
+ for depth in range(max_depth, -1, -1):
100
+ for folder in [f for f in folders if f['depth'] == depth]:
101
+ if folder['path'] in to_process:
102
+ continue
103
+
104
+ # Count audio files in children that will be processed
105
+ child_file_count = sum(all_folders_by_path[child]['file_count']
106
+ for child in folder['children']
107
+ if child in to_process)
108
+
109
+ # If this folder has more files than what will be processed in children,
110
+ # or not all children will be processed, then process this folder too
111
+ if folder['file_count'] > child_file_count or any(child not in to_process for child in folder['children']):
112
+ to_process.add(folder['path'])
113
+ logger.debug("Marking parent folder for processing: %s (files: %d, child files: %d)",
114
+ folder['path'], folder['file_count'], child_file_count)
115
+
116
+ # Return only folders that should be processed
117
+ result = [folder for folder in folders if folder['path'] in to_process]
118
+ logger.info("Determined %d folders should be processed (out of %d total folders with audio)",
119
+ len(result), len(folders))
120
+ return result
121
+
122
+
123
+ def get_folder_audio_files(folder_path: str) -> List[str]:
124
+ """
125
+ Get all audio files in a specific folder.
126
+
127
+ Args:
128
+ folder_path: Path to folder
129
+
130
+ Returns:
131
+ List of paths to audio files in natural sort order
132
+ """
133
+ audio_files = glob.glob(os.path.join(folder_path, "*"))
134
+ filtered_files = filter_directories(audio_files)
135
+
136
+ # Sort files naturally (so that '2' comes before '10')
137
+ sorted_files = natural_sort(filtered_files)
138
+ logger.debug("Found %d audio files in folder: %s", len(sorted_files), folder_path)
139
+
140
+ return sorted_files
141
+
142
+
143
+ def natural_sort(file_list: List[str]) -> List[str]:
144
+ """
145
+ Sort a list of files in natural order (so that 2 comes before 10).
146
+
147
+ Args:
148
+ file_list: List of file paths
149
+
150
+ Returns:
151
+ Naturally sorted list of file paths
152
+ """
153
+ def convert(text):
154
+ return int(text) if text.isdigit() else text.lower()
155
+
156
+ def alphanum_key(key):
157
+ return [convert(c) for c in re.split('([0-9]+)', key)]
158
+
159
+ return sorted(file_list, key=alphanum_key)
160
+
161
+
162
+ def extract_folder_meta(folder_path: str) -> Dict[str, str]:
163
+ """
164
+ Extract metadata from folder name.
165
+ Common format might be: "YYYY - NNN - Title"
166
+
167
+ Args:
168
+ folder_path: Path to folder
169
+
170
+ Returns:
171
+ Dictionary with extracted metadata (year, number, title)
172
+ """
173
+ folder_name = os.path.basename(folder_path)
174
+ logger.debug("Extracting metadata from folder: %s", folder_name)
175
+
176
+ # Try to match the format "YYYY - NNN - Title"
177
+ match = re.match(r'(\d{4})\s*-\s*(\d+)\s*-\s*(.+)', folder_name)
178
+
179
+ meta = {
180
+ 'year': '',
181
+ 'number': '',
182
+ 'title': folder_name # Default to the folder name if parsing fails
183
+ }
184
+
185
+ if match:
186
+ year, number, title = match.groups()
187
+ meta['year'] = year
188
+ meta['number'] = number
189
+ meta['title'] = title.strip()
190
+ logger.debug("Extracted metadata: year=%s, number=%s, title=%s",
191
+ meta['year'], meta['number'], meta['title'])
192
+ else:
193
+ # Try to match just the number format "NNN - Title"
194
+ match = re.match(r'(\d+)\s*-\s*(.+)', folder_name)
195
+ if match:
196
+ number, title = match.groups()
197
+ meta['number'] = number
198
+ meta['title'] = title.strip()
199
+ logger.debug("Extracted metadata: number=%s, title=%s",
200
+ meta['number'], meta['title'])
201
+ else:
202
+ logger.debug("Could not extract structured metadata from folder name")
203
+
204
+ return meta
205
+
206
+
207
+ def process_recursive_folders(root_path: str) -> List[Tuple[str, str, List[str]]]:
208
+ """
209
+ Process folders recursively and prepare data for conversion.
210
+
211
+ Args:
212
+ root_path: Root directory to start processing from
213
+
214
+ Returns:
215
+ List of tuples: (output_filename, folder_path, list_of_audio_files)
216
+ """
217
+ logger.info("Processing folders recursively: %s", root_path)
218
+
219
+ # Get folder info with hierarchy details
220
+ all_folders = find_audio_folders(root_path)
221
+
222
+ # Determine which folders should be processed
223
+ folders_to_process = determine_processing_folders(all_folders)
224
+
225
+ results = []
226
+ for folder_info in folders_to_process:
227
+ folder_path = folder_info['path']
228
+ audio_files = folder_info['audio_files']
229
+
230
+ # Use natural sort order to ensure consistent results
231
+ audio_files = natural_sort(audio_files)
232
+
233
+ meta = extract_folder_meta(folder_path)
234
+
235
+ if audio_files:
236
+ # Create output filename from metadata
237
+ if meta['number'] and meta['title']:
238
+ output_name = f"{meta['number']} - {meta['title']}"
239
+ else:
240
+ output_name = os.path.basename(folder_path)
241
+
242
+ # Clean up the output name (remove invalid filename characters)
243
+ output_name = re.sub(r'[<>:"/\\|?*]', '_', output_name)
244
+
245
+ results.append((output_name, folder_path, audio_files))
246
+ logger.debug("Created processing task: %s -> %s (%d files)",
247
+ folder_path, output_name, len(audio_files))
248
+
249
+ logger.info("Created %d processing tasks", len(results))
250
+ return results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TonieToolbox
3
- Version: 0.1.8
3
+ Version: 0.2.1
4
4
  Summary: Convert audio files to Tonie box compatible format
5
5
  Home-page: https://github.com/Quentendo64/TonieToolbox
6
6
  Author: Quentendo64
@@ -62,6 +62,7 @@ TonieToolbox allows you to create custom audio content for Tonie boxes by conver
62
62
  The tool provides several capabilities:
63
63
 
64
64
  - Convert single or multiple audio files into a Tonie-compatible format
65
+ - Process complex folder structures recursively to handle entire audio collections
65
66
  - Analyze and validate existing Tonie files
66
67
  - Split Tonie files into individual opus tracks
67
68
  - Compare two TAF files for debugging differences
@@ -137,20 +138,35 @@ Or use a list file (.lst) containing paths to multiple audio files:
137
138
  tonietoolbox playlist.lst
138
139
  ```
139
140
 
141
+ **Process folders recursively:**
142
+
143
+ To process an entire folder structure with multiple audio folders:
144
+
145
+ ```
146
+ tonietoolbox --recursive "Music/Albums"
147
+ ```
148
+
149
+ This will scan all subfolders, identify those containing audio files, and create a TAF file for each folder.
150
+
151
+ By default, all generated TAF files are saved in the `.\output` directory. If you want to save each TAF file in its source directory instead:
152
+
153
+ ```
154
+ tonietoolbox --recursive --output-to-source "Music/Albums"
155
+ ```
156
+
140
157
  ### Advanced Options
141
158
 
142
159
  Run the following command to see all available options:
143
160
 
144
161
  ```
145
- tonietoolbox --help
162
+ tonietoolbox -h
146
163
  ```
147
164
 
148
165
  Output:
149
166
  ```
150
- usage: TonieToolbox.py [-h] [--ts TIMESTAMP] [--ffmpeg FFMPEG] [--opusenc OPUSENC]
151
- [--bitrate BITRATE] [--cbr] [--append-tonie-tag TAG]
152
- [--no-tonie-header] [--info] [--split] [--recursive] [--compare FILE2]
153
- [--detailed-compare] [--debug] [--trace] [--quiet] [--silent]
167
+ usage: TonieToolbox.py [-h] [-v] [-t TIMESTAMP] [-f FFMPEG] [-o OPUSENC]
168
+ [-b BITRATE] [-c] [-a TAG] [-n] [-i] [-s] [-r] [-O]
169
+ [-A] [-k] [-C FILE2] [-D] [-d] [-T] [-q] [-Q]
154
170
  SOURCE [TARGET]
155
171
 
156
172
  Create Tonie compatible file from Ogg opus file(s).
@@ -161,23 +177,40 @@ positional arguments:
161
177
 
162
178
  optional arguments:
163
179
  -h, --help show this help message and exit
164
- --ts TIMESTAMP set custom timestamp / bitstream serial / reference .taf file
165
- --ffmpeg FFMPEG specify location of ffmpeg
166
- --opusenc OPUSENC specify location of opusenc
167
- --bitrate BITRATE set encoding bitrate in kbps (default: 96)
168
- --cbr encode in cbr mode
169
- --append-tonie-tag TAG append [TAG] to filename (must be an 8-character hex value)
170
- --no-tonie-header do not write Tonie header
171
- --info Check and display info about Tonie file
172
- --split Split Tonie file into opus tracks
173
- --compare FILE2 Compare input file with another .taf file for debugging
174
- --detailed-compare Show detailed OGG page differences when comparing files
180
+ -v, --version show program version and exit
181
+ -t, --timestamp TIMESTAMP
182
+ set custom timestamp / bitstream serial / reference .taf file
183
+ -f, --ffmpeg FFMPEG specify location of ffmpeg
184
+ -o, --opusenc OPUSENC specify location of opusenc
185
+ -b, --bitrate BITRATE set encoding bitrate in kbps (default: 96)
186
+ -c, --cbr encode in cbr mode
187
+ -a, --append-tonie-tag TAG
188
+ append [TAG] to filename (must be an 8-character hex value)
189
+ -n, --no-tonie-header do not write Tonie header
190
+ -i, --info Check and display info about Tonie file
191
+ -s, --split Split Tonie file into opus tracks
192
+ -r, --recursive Process folders recursively
193
+ -O, --output-to-source
194
+ Save output files in the source directory instead of output directory
195
+ -A, --auto-download Automatically download FFmpeg and opusenc if needed
196
+ -k, --keep-temp Keep temporary opus files in a temp folder for testing
197
+ -C, --compare FILE2 Compare input file with another .taf file for debugging
198
+ -D, --detailed-compare
199
+ Show detailed OGG page differences when comparing files
200
+
201
+ Version Check Options:
202
+ -S, --skip-update-check
203
+ Skip checking for updates
204
+ -F, --force-refresh-cache
205
+ Force refresh of update information from PyPI
206
+ -X, --clear-version-cache
207
+ Clear cached version information
175
208
 
176
209
  Logging Options:
177
- --debug Enable debug logging
178
- --trace Enable trace logging (very verbose)
179
- --quiet Show only warnings and errors
180
- --silent Show only errors
210
+ -d, --debug Enable debug logging
211
+ -T, --trace Enable trace logging (very verbose)
212
+ -q, --quiet Show only warnings and errors
213
+ -Q, --silent Show only errors
181
214
  ```
182
215
 
183
216
  ### Common Usage Examples
@@ -211,9 +244,9 @@ tonietoolbox file1.taf --compare file2.taf --detailed-compare
211
244
  #### Custom timestamp options:
212
245
 
213
246
  ```
214
- tonietoolbox input.mp3 --ts 1745078762 # UNIX Timestamp
215
- tonietoolbox input.mp3 --ts 0x6803C9EA # Bitstream time
216
- tonietoolbox input.mp3 --ts ./reference.taf # Reference TAF for extraction
247
+ tonietoolbox input.mp3 --timestamp 1745078762 # UNIX Timestamp
248
+ tonietoolbox input.mp3 --timestamp 0x6803C9EA # Bitstream time
249
+ tonietoolbox input.mp3 --timestamp ./reference.taf # Reference TAF for extraction
217
250
  ```
218
251
 
219
252
  #### Set custom bitrate:
@@ -222,6 +255,20 @@ tonietoolbox input.mp3 --ts ./reference.taf # Reference TAF for extraction
222
255
  tonietoolbox input.mp3 --bitrate 128
223
256
  ```
224
257
 
258
+ #### Process a complex folder structure:
259
+
260
+ Process an audiobook series with multiple folders:
261
+
262
+ ```
263
+ tonietoolbox --recursive "\Hörspiele\Die drei Fragezeichen\Folgen"
264
+ ```
265
+
266
+ Process a music collection with nested album folders and save TAF files alongside the source directories:
267
+
268
+ ```
269
+ tonietoolbox --recursive --output-to-source "\Hörspiele\"
270
+ ```
271
+
225
272
  ## Technical Details
226
273
 
227
274
  ### TAF (Tonie Audio Format) File Structure
@@ -1,20 +1,21 @@
1
- TonieToolbox/__init__.py,sha256=hVjFi2m8AmDYdgUfUTtXSWyyN2C7gLHLN2UgFdmsjpo,96
2
- TonieToolbox/__main__.py,sha256=eEsfnwLsgI6ynwR4uxo4CKptuOJDpGp6lYWSRwJSY3M,8520
3
- TonieToolbox/audio_conversion.py,sha256=10PayO1VQDGee2bcO6JD8zRSFoJRJhO2pBeba5k15vk,7998
1
+ TonieToolbox/__init__.py,sha256=141g35CQjQQB9-oCGkOKQkDV9GWkssN0IhK1vh1FBoc,96
2
+ TonieToolbox/__main__.py,sha256=KE5x6KMLQZGd4nwjk71AmlxMZOgnN-lXLBNDi6XkHJc,10917
3
+ TonieToolbox/audio_conversion.py,sha256=ra72qsE8j2GEP_4kqDT9m6aKlnnREZhZAlpf7y83pA0,11202
4
4
  TonieToolbox/constants.py,sha256=QQWQpnCI65GByLlXLOkt2n8nALLu4m6BWp0zuhI3M04,2021
5
- TonieToolbox/dependency_manager.py,sha256=Ag4_xuB6shqTAuoIVs5hfwzXA3nrOJbxW_08vC_76Ig,23386
5
+ TonieToolbox/dependency_manager.py,sha256=fWtYp_UQDKrgIKcOyy95w7Grk_wYx5Fadyg8ulpb7nE,23451
6
6
  TonieToolbox/filename_generator.py,sha256=RqQHyGTKakuWR01yMSnFVMU_HfLw3rqFxKhXNIHdTlg,3441
7
7
  TonieToolbox/logger.py,sha256=Up9fBVkOZwkY61_645bX4tienCpyVSkap-FeTV0v730,1441
8
8
  TonieToolbox/ogg_page.py,sha256=-ViaIRBgh5ayfwmyplL8QmmRr5P36X8W0DdHkSFUYUU,21948
9
9
  TonieToolbox/opus_packet.py,sha256=OcHXEe3I_K4mWPUD55prpG42sZxJsEeAxqSbFxBmb0c,7895
10
+ TonieToolbox/recursive_processor.py,sha256=vhQzC05bJVRPX8laj_5lxuRD40eLsZatzwCoCavMsmY,9304
10
11
  TonieToolbox/tonie_analysis.py,sha256=4eOzxHL_g0TJFhuexNHcZXivxZ7eb5xfb9-efUZ02W0,20344
11
12
  TonieToolbox/tonie_file.py,sha256=nIS4qhpBKIyPvTU39yYljRidpY6cz78halXlz3HJy9w,15294
12
13
  TonieToolbox/tonie_header.proto,sha256=WaWfwO4VrwGtscK2ujfDRKtpeBpaVPoZhI8iMmR-C0U,202
13
14
  TonieToolbox/tonie_header_pb2.py,sha256=s5bp4ULTEekgq6T61z9fDkRavyPM-3eREs20f_Pxxe8,3665
14
15
  TonieToolbox/version_handler.py,sha256=7Zx-pgzAUhz6jMplvNal1wHyxidodVxaNcAV0EMph5k,9778
15
- tonietoolbox-0.1.8.dist-info/licenses/LICENSE.md,sha256=rGoga9ZAgNco9fBapVFpWf6ri7HOBp1KRnt1uIruXMk,35190
16
- tonietoolbox-0.1.8.dist-info/METADATA,sha256=4TWL1cpSAVPDreuEIhjT2I3EZkVr7we-UFHrSNJ4Vlw,8971
17
- tonietoolbox-0.1.8.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
18
- tonietoolbox-0.1.8.dist-info/entry_points.txt,sha256=oqpeyBxel7aScg35Xr4gZKnf486S5KW9okqeBwyJxxc,60
19
- tonietoolbox-0.1.8.dist-info/top_level.txt,sha256=Wkkm-2p7I3ENfS7ZbYtYUB2g-xwHrXVlERHfonsOPuE,13
20
- tonietoolbox-0.1.8.dist-info/RECORD,,
16
+ tonietoolbox-0.2.1.dist-info/licenses/LICENSE.md,sha256=rGoga9ZAgNco9fBapVFpWf6ri7HOBp1KRnt1uIruXMk,35190
17
+ tonietoolbox-0.2.1.dist-info/METADATA,sha256=mMJ6-auDHNFK12NcDU_HNQlwiC6Y98fprLJICBihaxI,10514
18
+ tonietoolbox-0.2.1.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
19
+ tonietoolbox-0.2.1.dist-info/entry_points.txt,sha256=oqpeyBxel7aScg35Xr4gZKnf486S5KW9okqeBwyJxxc,60
20
+ tonietoolbox-0.2.1.dist-info/top_level.txt,sha256=Wkkm-2p7I3ENfS7ZbYtYUB2g-xwHrXVlERHfonsOPuE,13
21
+ tonietoolbox-0.2.1.dist-info/RECORD,,