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 +1 -1
- TonieToolbox/__main__.py +70 -28
- TonieToolbox/audio_conversion.py +88 -27
- TonieToolbox/dependency_manager.py +3 -1
- TonieToolbox/recursive_processor.py +250 -0
- {tonietoolbox-0.1.8.dist-info → tonietoolbox-0.2.1.dist-info}/METADATA +71 -24
- {tonietoolbox-0.1.8.dist-info → tonietoolbox-0.2.1.dist-info}/RECORD +11 -10
- {tonietoolbox-0.1.8.dist-info → tonietoolbox-0.2.1.dist-info}/WHEEL +0 -0
- {tonietoolbox-0.1.8.dist-info → tonietoolbox-0.2.1.dist-info}/entry_points.txt +0 -0
- {tonietoolbox-0.1.8.dist-info → tonietoolbox-0.2.1.dist-info}/licenses/LICENSE.md +0 -0
- {tonietoolbox-0.1.8.dist-info → tonietoolbox-0.2.1.dist-info}/top_level.txt +0 -0
TonieToolbox/__init__.py
CHANGED
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('--
|
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('
|
42
|
-
parser.add_argument('--
|
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
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
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)
|
TonieToolbox/audio_conversion.py
CHANGED
@@ -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
|
-
|
62
|
-
|
63
|
-
|
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
|
-
|
67
|
-
|
68
|
-
|
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
|
-
|
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
|
73
|
-
logger.error("
|
74
|
-
raise RuntimeError(f"
|
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
|
-
|
78
|
-
|
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
|
-
|
84
|
-
|
85
|
-
|
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
|
-
|
89
|
-
|
90
|
-
|
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
|
-
|
101
|
-
|
102
|
-
|
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.
|
163
|
-
|
164
|
-
|
165
|
-
|
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
|
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
|
162
|
+
tonietoolbox -h
|
146
163
|
```
|
147
164
|
|
148
165
|
Output:
|
149
166
|
```
|
150
|
-
usage: TonieToolbox.py [-h] [
|
151
|
-
[
|
152
|
-
[
|
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
|
-
--
|
165
|
-
--
|
166
|
-
|
167
|
-
--
|
168
|
-
--
|
169
|
-
|
170
|
-
|
171
|
-
--
|
172
|
-
|
173
|
-
--
|
174
|
-
|
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
|
178
|
-
--trace
|
179
|
-
--quiet
|
180
|
-
--silent
|
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 --
|
215
|
-
tonietoolbox input.mp3 --
|
216
|
-
tonietoolbox input.mp3 --
|
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=
|
2
|
-
TonieToolbox/__main__.py,sha256=
|
3
|
-
TonieToolbox/audio_conversion.py,sha256=
|
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=
|
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.
|
16
|
-
tonietoolbox-0.1.
|
17
|
-
tonietoolbox-0.1.
|
18
|
-
tonietoolbox-0.1.
|
19
|
-
tonietoolbox-0.1.
|
20
|
-
tonietoolbox-0.1.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|