TonieToolbox 0.5.0a1__py3-none-any.whl → 0.6.0a1__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.
@@ -7,29 +7,39 @@ import glob
7
7
  import subprocess
8
8
  import tempfile
9
9
  from .dependency_manager import get_ffmpeg_binary, get_opus_binary
10
+ from .constants import SUPPORTED_EXTENSIONS
10
11
  from .logger import get_logger
11
12
 
12
13
  logger = get_logger('audio_conversion')
13
14
 
14
15
 
15
- def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitrate=48, vbr=True, keep_temp=False, auto_download=False):
16
+ def get_opus_tempfile(
17
+ ffmpeg_binary: str = None,
18
+ opus_binary: str = None,
19
+ filename: str = None,
20
+ bitrate: int = 48,
21
+ vbr: bool = True,
22
+ keep_temp: bool = False,
23
+ auto_download: bool = False,
24
+ no_mono_conversion: bool = False
25
+ ) -> tuple[tempfile.SpooledTemporaryFile | None, str | None]:
16
26
  """
17
27
  Convert an audio file to Opus format and return a temporary file handle.
18
28
 
19
29
  Args:
20
- ffmpeg_binary: Path to the ffmpeg binary. If None, will be auto-detected or downloaded.
21
- opus_binary: Path to the opusenc binary. If None, will be auto-detected or downloaded.
22
- filename: Path to the input audio file
23
- bitrate: Bitrate for the Opus encoding in kbps
24
- vbr: Whether to use variable bitrate encoding
25
- keep_temp: Whether to keep the temporary files for testing
26
- auto_download: Whether to automatically download dependencies if not found
27
-
30
+ ffmpeg_binary (str | None): Path to the ffmpeg binary. If None, will be auto-detected or downloaded.
31
+ opus_binary (str | None): Path to the opusenc binary. If None, will be auto-detected or downloaded.
32
+ filename (str | None): Path to the input audio file
33
+ bitrate (int): Bitrate for the Opus encoding in kbps
34
+ vbr (bool): Whether to use variable bitrate encoding
35
+ keep_temp (bool): Whether to keep the temporary files for testing
36
+ auto_download (bool): Whether to automatically download dependencies if not found
37
+ no_mono_conversion (bool): Whether to skip mono to stereo conversion
28
38
  Returns:
29
- tuple: (file handle, temp_file_path) or (file handle, None) if keep_temp is False
39
+ tuple[tempfile.SpooledTemporaryFile | None, str | None]: (file handle, temp_file_path) or (file handle, None) if keep_temp is False
30
40
  """
31
- logger.trace("Entering get_opus_tempfile(ffmpeg_binary=%s, opus_binary=%s, filename=%s, bitrate=%d, vbr=%s, keep_temp=%s, auto_download=%s)",
32
- ffmpeg_binary, opus_binary, filename, bitrate, vbr, keep_temp, auto_download)
41
+ logger.trace("Entering get_opus_tempfile(ffmpeg_binary=%s, opus_binary=%s, filename=%s, bitrate=%d, vbr=%s, keep_temp=%s, auto_download=%s, no_mono_conversion=%s)",
42
+ ffmpeg_binary, opus_binary, filename, bitrate, vbr, keep_temp, auto_download, no_mono_conversion)
33
43
 
34
44
  logger.debug("Converting %s to Opus format (bitrate: %d kbps, vbr: %s)", filename, bitrate, vbr)
35
45
 
@@ -52,6 +62,38 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
52
62
  vbr_parameter = "--vbr" if vbr else "--hard-cbr"
53
63
  logger.debug("Using encoding parameter: %s", vbr_parameter)
54
64
 
65
+ is_mono = False
66
+ ffprobe_path = None
67
+ ffmpeg_dir, ffmpeg_file = os.path.split(ffmpeg_binary)
68
+ ffprobe_candidates = [
69
+ os.path.join(ffmpeg_dir, 'ffprobe'),
70
+ os.path.join(ffmpeg_dir, 'ffprobe.exe'),
71
+ 'ffprobe',
72
+ 'ffprobe.exe',
73
+ ]
74
+ for candidate in ffprobe_candidates:
75
+ if os.path.exists(candidate):
76
+ ffprobe_path = candidate
77
+ break
78
+ if ffprobe_path:
79
+ try:
80
+ probe_cmd = [ffprobe_path, '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=channels', '-of', 'default=noprint_wrappers=1:nokey=1', filename]
81
+ logger.debug(f"Probing audio channels with: {' '.join(probe_cmd)}")
82
+ result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
83
+ if result.returncode == 0:
84
+ channels = result.stdout.strip()
85
+ logger.debug(f"Detected channels: {channels}")
86
+ if channels == '1':
87
+ is_mono = True
88
+ else:
89
+ logger.warning(f"ffprobe failed to detect channels: {result.stderr}")
90
+ except Exception as e:
91
+ logger.warning(f"Mono detection failed: {e}")
92
+ else:
93
+ logger.warning("ffprobe not found, will always force stereo conversion for non-Opus input.")
94
+ is_mono = True # Always force stereo if we can't check
95
+ logger.info(f"Mono detected: {is_mono}, no_mono_conversion: {no_mono_conversion}")
96
+
55
97
  temp_path = None
56
98
  if keep_temp:
57
99
  temp_dir = os.path.join(tempfile.gettempdir(), "tonie_toolbox_temp")
@@ -62,7 +104,12 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
62
104
 
63
105
  logger.debug("Starting FFmpeg process")
64
106
  try:
65
- ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
107
+ if is_mono and not no_mono_conversion:
108
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-ac", "2", "-"]
109
+ logger.info(f"Forcing stereo conversion for mono input: {' '.join(ffmpeg_cmd)}")
110
+ else:
111
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
112
+ logger.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
66
113
  logger.trace("FFmpeg command: %s", ffmpeg_cmd)
67
114
  ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE)
68
115
  except FileNotFoundError:
@@ -106,7 +153,12 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
106
153
 
107
154
  logger.debug("Starting FFmpeg process")
108
155
  try:
109
- ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
156
+ if is_mono and not no_mono_conversion:
157
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-ac", "2", "-"]
158
+ logger.info(f"Forcing stereo conversion for mono input: {' '.join(ffmpeg_cmd)}")
159
+ else:
160
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
161
+ logger.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
110
162
  logger.trace("FFmpeg command: %s", ffmpeg_cmd)
111
163
  ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE)
112
164
  except FileNotFoundError:
@@ -155,24 +207,20 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
155
207
  return tmp_file, None
156
208
 
157
209
 
158
- def filter_directories(glob_list):
210
+ def filter_directories(glob_list: list[str]) -> list[str]:
159
211
  """
160
212
  Filter a list of glob results to include only audio files that can be handled by ffmpeg.
161
213
 
162
214
  Args:
163
- glob_list: List of path names from glob.glob()
164
-
215
+ glob_list (list[str]): List of path names from glob.glob()
165
216
  Returns:
166
- list: Filtered list containing only supported audio files
217
+ list[str]: Filtered list containing only supported audio files
167
218
  """
168
219
  logger.trace("Entering filter_directories() with %d items", len(glob_list))
169
220
  logger.debug("Filtering %d glob results for supported audio files", len(glob_list))
170
221
 
171
- # Common audio file extensions supported by ffmpeg
172
- supported_extensions = [
173
- '.wav', '.mp3', '.aac', '.m4a', '.flac', '.ogg', '.opus',
174
- '.ape', '.wma', '.aiff', '.mp2', '.mp4', '.webm', '.mka'
175
- ]
222
+ supported_extensions = SUPPORTED_EXTENSIONS
223
+ logger.debug("Supported audio file extensions: %s", supported_extensions)
176
224
 
177
225
  filtered = []
178
226
  for name in glob_list:
@@ -189,17 +237,16 @@ def filter_directories(glob_list):
189
237
  return filtered
190
238
 
191
239
 
192
- def get_input_files(input_filename):
240
+ def get_input_files(input_filename: str) -> list[str]:
193
241
  """
194
242
  Get a list of input files to process.
195
243
 
196
244
  Supports direct file paths, directory paths, glob patterns, and .lst files.
197
245
 
198
246
  Args:
199
- input_filename: Input file pattern or list file path
200
-
247
+ input_filename (str): Input file pattern or list file path
201
248
  Returns:
202
- list: List of input file paths
249
+ list[str]: List of input file paths
203
250
  """
204
251
  logger.trace("Entering get_input_files(input_filename=%s)", input_filename)
205
252
  logger.debug("Getting input files for pattern: %s", input_filename)
@@ -244,22 +291,46 @@ def get_input_files(input_filename):
244
291
 
245
292
  logger.debug("Found %d files in list file", len(input_files))
246
293
  else:
247
- logger.debug("Processing glob pattern: %s", input_filename)
294
+ logger.debug("Processing input path: %s", input_filename)
295
+
296
+ # Try the exact pattern first
248
297
  input_files = sorted(filter_directories(glob.glob(input_filename)))
249
- logger.debug("Found %d files matching pattern", len(input_files))
298
+ if input_files:
299
+ logger.debug("Found %d files matching exact pattern", len(input_files))
300
+ else:
301
+ # If no extension is provided, try appending a wildcard for extension
302
+ _, ext = os.path.splitext(input_filename)
303
+ if not ext:
304
+ wildcard_pattern = input_filename + ".*"
305
+ logger.debug("No extension in pattern, trying with wildcard: %s", wildcard_pattern)
306
+ input_files = sorted(filter_directories(glob.glob(wildcard_pattern)))
307
+
308
+ # If still no files found, try treating it as a directory
309
+ if not input_files and os.path.exists(os.path.dirname(input_filename)):
310
+ potential_dir = input_filename
311
+ if os.path.isdir(potential_dir):
312
+ logger.debug("Treating input as directory: %s", potential_dir)
313
+ dir_glob = os.path.join(potential_dir, "*")
314
+ input_files = sorted(filter_directories(glob.glob(dir_glob)))
315
+ if input_files:
316
+ logger.debug("Found %d audio files in directory", len(input_files))
317
+
318
+ if input_files:
319
+ logger.debug("Found %d files after trying alternatives", len(input_files))
320
+ else:
321
+ logger.warning("No files found for pattern %s even after trying alternatives", input_filename)
250
322
 
251
323
  logger.trace("Exiting get_input_files() with %d files", len(input_files))
252
324
  return input_files
253
325
 
254
326
 
255
- def append_to_filename(output_filename, tag):
327
+ def append_to_filename(output_filename: str, tag: str) -> str:
256
328
  """
257
329
  Append a tag to a filename, preserving the extension.
258
330
 
259
331
  Args:
260
- output_filename: Original filename
261
- tag: Tag to append (typically an 8-character hex value)
262
-
332
+ output_filename (str): Original filename
333
+ tag (str): Tag to append (typically an 8-character hex value)
263
334
  Returns:
264
335
  str: Modified filename with tag
265
336
  """
TonieToolbox/config.py ADDED
@@ -0,0 +1 @@
1
+ ## TODO: Add config.py
TonieToolbox/constants.py CHANGED
@@ -1,13 +1,13 @@
1
1
  """
2
2
  Constants used throughout the TonieToolbox package
3
3
  """
4
- SAMPLE_RATE_KHZ = 48
5
- ONLY_CONVERT_FRAMEPACKING = -1
6
- OTHER_PACKET_NEEDED = -2
7
- DO_NOTHING = -3
8
- TOO_MANY_SEGMENTS = -4
9
- TIMESTAMP_DEDUCT = 0x50000000
10
- OPUS_TAGS = [
4
+ SAMPLE_RATE_KHZ: int = 48
5
+ ONLY_CONVERT_FRAMEPACKING: int = -1
6
+ OTHER_PACKET_NEEDED: int = -2
7
+ DO_NOTHING: int = -3
8
+ TOO_MANY_SEGMENTS: int = -4
9
+ TIMESTAMP_DEDUCT: int = 0x50000000
10
+ OPUS_TAGS: list[bytearray] = [
11
11
  bytearray(
12
12
  b"\x4F\x70\x75\x73\x54\x61\x67\x73\x0D\x00\x00\x00\x4C\x61\x76\x66\x35\x38\x2E\x32\x30\x2E\x31\x30\x30\x03\x00\x00\x00\x26\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x3D\x6F\x70\x75\x73\x65\x6E\x63\x20\x66\x72\x6F\x6D\x20\x6F\x70\x75\x73\x2D\x74\x6F\x6F\x6C\x73\x20\x30\x2E\x31\x2E\x31\x30\x2A\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x5F\x6F\x70\x74\x69\x6F\x6E\x73\x3D\x2D\x2D\x71\x75\x69\x65\x74\x20\x2D\x2D\x62\x69\x74\x72\x61\x74\x65\x20\x39\x36\x20\x2D\x2D\x76\x62\x72\x3B\x01\x00\x00\x70\x61\x64\x3D\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30"),
13
13
  bytearray(
@@ -15,7 +15,7 @@ OPUS_TAGS = [
15
15
  ]
16
16
 
17
17
  # Mapping of language tags to ISO codes
18
- LANGUAGE_MAPPING = {
18
+ LANGUAGE_MAPPING: dict[str, str] = {
19
19
  # Common language names to ISO codes
20
20
  'deutsch': 'de-de',
21
21
  'german': 'de-de',
@@ -39,7 +39,7 @@ LANGUAGE_MAPPING = {
39
39
  }
40
40
 
41
41
  # Mapping of genre tags to tonie categories
42
- GENRE_MAPPING = {
42
+ GENRE_MAPPING: dict[str, str] = {
43
43
  # Standard Tonie category names from tonies.json
44
44
  'hörspiel': 'Hörspiele & Hörbücher',
45
45
  'hörbuch': 'Hörspiele & Hörbücher',
@@ -87,4 +87,88 @@ GENRE_MAPPING = {
87
87
 
88
88
  # Default to standard format for custom
89
89
  'custom': 'Hörspiele & Hörbücher',
90
+ }
91
+
92
+ # Supported file extensions for audio files
93
+ SUPPORTED_EXTENSIONS = [
94
+ '.wav', '.mp3', '.aac', '.m4a', '.flac', '.ogg', '.opus',
95
+ '.ape', '.wma', '.aiff', '.mp2', '.mp4', '.webm', '.mka'
96
+ ]
97
+
98
+ ARTWORK_NAMES = [
99
+ 'cover', 'folder', 'album', 'front', 'artwork', 'image',
100
+ 'albumart', 'albumartwork', 'booklet'
101
+ ]
102
+ ARTWORK_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
103
+
104
+
105
+ TAG_VALUE_REPLACEMENTS = {
106
+ "Die drei ???": "Die drei Fragezeichen",
107
+ "Die Drei ???": "Die drei Fragezeichen",
108
+ "DIE DREI ???": "Die drei Fragezeichen",
109
+ "Die drei !!!": "Die drei Ausrufezeichen",
110
+ "Die Drei !!!": "Die drei Ausrufezeichen",
111
+ "DIE DREI !!!": "Die drei Ausrufezeichen",
112
+ "TKKG™": "TKKG",
113
+ "Die drei ??? Kids": "Die drei Fragezeichen Kids",
114
+ "Die Drei ??? Kids": "Die drei Fragezeichen Kids",
115
+ "Bibi & Tina": "Bibi und Tina",
116
+ "Benjamin Blümchen™": "Benjamin Blümchen",
117
+ "???": "Fragezeichen",
118
+ "!!!": "Ausrufezeichen",
119
+ }
120
+
121
+ TAG_MAPPINGS = {
122
+ # ID3 (MP3) tags
123
+ 'TIT2': 'title',
124
+ 'TALB': 'album',
125
+ 'TPE1': 'artist',
126
+ 'TPE2': 'albumartist',
127
+ 'TCOM': 'composer',
128
+ 'TRCK': 'tracknumber',
129
+ 'TPOS': 'discnumber',
130
+ 'TDRC': 'date',
131
+ 'TCON': 'genre',
132
+ 'TPUB': 'publisher',
133
+ 'TCOP': 'copyright',
134
+ 'COMM': 'comment',
135
+
136
+ # Vorbis tags (FLAC, OGG)
137
+ 'title': 'title',
138
+ 'album': 'album',
139
+ 'artist': 'artist',
140
+ 'albumartist': 'albumartist',
141
+ 'composer': 'composer',
142
+ 'tracknumber': 'tracknumber',
143
+ 'discnumber': 'discnumber',
144
+ 'date': 'date',
145
+ 'genre': 'genre',
146
+ 'publisher': 'publisher',
147
+ 'copyright': 'copyright',
148
+ 'comment': 'comment',
149
+
150
+ # MP4 (M4A, AAC) tags
151
+ '©nam': 'title',
152
+ '©alb': 'album',
153
+ '©ART': 'artist',
154
+ 'aART': 'albumartist',
155
+ '©wrt': 'composer',
156
+ 'trkn': 'tracknumber',
157
+ 'disk': 'discnumber',
158
+ '©day': 'date',
159
+ '©gen': 'genre',
160
+ '©pub': 'publisher',
161
+ 'cprt': 'copyright',
162
+ '©cmt': 'comment',
163
+
164
+ # Additional tags some files might have
165
+ 'album_artist': 'albumartist',
166
+ 'track': 'tracknumber',
167
+ 'track_number': 'tracknumber',
168
+ 'disc': 'discnumber',
169
+ 'disc_number': 'discnumber',
170
+ 'year': 'date',
171
+ 'albuminterpret': 'albumartist', # German tag name
172
+ 'interpret': 'artist', # German tag name
173
+
90
174
  }
@@ -15,10 +15,9 @@ def sanitize_filename(filename: str) -> str:
15
15
  Sanitize a filename by removing invalid characters and trimming.
16
16
 
17
17
  Args:
18
- filename: The filename to sanitize
19
-
18
+ filename (str): The filename to sanitize
20
19
  Returns:
21
- A sanitized filename
20
+ str: A sanitized filename
22
21
  """
23
22
  # Remove invalid characters for filenames
24
23
  sanitized = re.sub(r'[<>:"/\\|?*]', '_', filename)
@@ -29,7 +28,7 @@ def sanitize_filename(filename: str) -> str:
29
28
  return "tonie"
30
29
  return sanitized
31
30
 
32
- def guess_output_filename(input_filename: str, input_files: List[str] = None) -> str:
31
+ def guess_output_filename(input_filename: str, input_files: list[str] = None) -> str:
33
32
  """
34
33
  Generate a sensible output filename based on input file or directory.
35
34
 
@@ -40,11 +39,10 @@ def guess_output_filename(input_filename: str, input_files: List[str] = None) ->
40
39
  4. For multiple files: Use the common parent directory name
41
40
 
42
41
  Args:
43
- input_filename: The input filename or pattern
44
- input_files: List of resolved input files (optional)
45
-
42
+ input_filename (str): The input filename or pattern
43
+ input_files (list[str] | None): List of resolved input files (optional)
46
44
  Returns:
47
- Generated output filename without extension
45
+ str: Generated output filename without extension
48
46
  """
49
47
  logger.debug("Guessing output filename from input: %s", input_filename)
50
48
 
@@ -0,0 +1,20 @@
1
+ import platform
2
+
3
+ def handle_integration(args):
4
+ import platform
5
+ if platform.system() == 'Windows':
6
+ from .integration_windows import WindowsClassicContextMenuIntegration as ContextMenuIntegration
7
+ if args.install_integration:
8
+ ContextMenuIntegration.install()
9
+ elif args.uninstall_integration:
10
+ ContextMenuIntegration.uninstall()
11
+ elif platform.system() == 'Darwin':
12
+ from .integration_macos import MacOSContextMenuIntegration as ContextMenuIntegration
13
+ if args.install_integration:
14
+ ContextMenuIntegration.install()
15
+ elif args.uninstall_integration:
16
+ ContextMenuIntegration.uninstall()
17
+ elif platform.system() == 'Linux':
18
+ raise NotImplementedError("Context menu integration is not supported on Linux YET. But Soon™")
19
+ else:
20
+ raise NotImplementedError(f"Context menu integration is not supported on this OS: {platform.system()}")