TonieToolbox 0.2.2__py3-none-any.whl → 0.2.3__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.2.2'
5
+ __version__ = '0.2.3'
TonieToolbox/__main__.py CHANGED
@@ -17,6 +17,7 @@ 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
19
  from .recursive_processor import process_recursive_folders
20
+ from .media_tags import is_available as is_media_tags_available, ensure_mutagen
20
21
 
21
22
  def main():
22
23
  """Entry point for the TonieToolbox application."""
@@ -52,6 +53,15 @@ def main():
52
53
  parser.add_argument('-D', '--detailed-compare', action='store_true',
53
54
  help='Show detailed OGG page differences when comparing files')
54
55
 
56
+ # Media tag options
57
+ media_tag_group = parser.add_argument_group('Media Tag Options')
58
+ media_tag_group.add_argument('-m', '--use-media-tags', action='store_true',
59
+ help='Use media tags from audio files for naming')
60
+ media_tag_group.add_argument('--name-template', metavar='TEMPLATE', action='store',
61
+ help='Template for naming files using media tags. Example: "{album} - {artist}"')
62
+ media_tag_group.add_argument('--show-tags', action='store_true',
63
+ help='Show available media tags from input files')
64
+
55
65
  # Version check options
56
66
  version_group = parser.add_argument_group('Version Check Options')
57
67
  version_group.add_argument('-S', '--skip-update-check', action='store_true',
@@ -85,14 +95,12 @@ def main():
85
95
  logger = get_logger('main')
86
96
  logger.debug("Starting TonieToolbox v%s with log level: %s", __version__, logging.getLevelName(log_level))
87
97
 
88
- # Handle version cache operations
89
98
  if args.clear_version_cache:
90
99
  if clear_version_cache():
91
100
  logger.info("Version cache cleared successfully")
92
101
  else:
93
102
  logger.info("No version cache to clear or error clearing cache")
94
103
 
95
- # Check for updates
96
104
  if not args.skip_update_check:
97
105
  logger.debug("Checking for updates (force_refresh=%s)", args.force_refresh_cache)
98
106
  is_latest, latest_version, message, update_confirmed = check_for_updates(
@@ -119,10 +127,24 @@ def main():
119
127
  sys.exit(1)
120
128
  logger.debug("Using opusenc binary: %s", opus_binary)
121
129
 
130
+ # Check for media tags library and handle --show-tags option
131
+ if (args.use_media_tags or args.show_tags or args.name_template) and not is_media_tags_available():
132
+ if not ensure_mutagen(auto_install=args.auto_download):
133
+ logger.warning("Media tags functionality requires the mutagen library but it could not be installed.")
134
+ if args.use_media_tags or args.show_tags:
135
+ logger.error("Cannot proceed with --use-media-tags or --show-tags without mutagen library")
136
+ sys.exit(1)
137
+ else:
138
+ logger.info("Successfully enabled media tag support")
139
+
122
140
  # Handle recursive processing
123
141
  if args.recursive:
124
142
  logger.info("Processing folders recursively: %s", args.input_filename)
125
- process_tasks = process_recursive_folders(args.input_filename)
143
+ process_tasks = process_recursive_folders(
144
+ args.input_filename,
145
+ use_media_tags=args.use_media_tags,
146
+ name_template=args.name_template
147
+ )
126
148
 
127
149
  if not process_tasks:
128
150
  logger.error("No folders with audio files found for recursive processing")
@@ -178,8 +200,104 @@ def main():
178
200
  logger.error("No files found for pattern %s", args.input_filename)
179
201
  sys.exit(1)
180
202
 
203
+ # Show tags for input files if requested
204
+ if args.show_tags:
205
+ from .media_tags import get_file_tags
206
+ logger.info("Showing media tags for input files:")
207
+
208
+ for file_index, file_path in enumerate(files):
209
+ tags = get_file_tags(file_path)
210
+ if tags:
211
+ print(f"\nFile {file_index + 1}: {os.path.basename(file_path)}")
212
+ print("-" * 40)
213
+ for tag_name, tag_value in sorted(tags.items()):
214
+ print(f"{tag_name}: {tag_value}")
215
+ else:
216
+ print(f"\nFile {file_index + 1}: {os.path.basename(file_path)} - No tags found")
217
+
218
+ sys.exit(0)
219
+
220
+ # Use media tags for file naming if requested
221
+ guessed_name = None
222
+ if args.use_media_tags:
223
+ # If this is a single folder, try to get consistent album info
224
+ if len(files) > 1 and os.path.dirname(files[0]) == os.path.dirname(files[-1]):
225
+ folder_path = os.path.dirname(files[0])
226
+
227
+ from .media_tags import extract_album_info, format_metadata_filename
228
+ logger.debug("Extracting album info from folder: %s", folder_path)
229
+
230
+ album_info = extract_album_info(folder_path)
231
+ if album_info:
232
+ # Use album info for naming the output file
233
+ template = args.name_template or "{album} - {artist}"
234
+ new_name = format_metadata_filename(album_info, template)
235
+
236
+ if new_name:
237
+ logger.info("Using album metadata for output filename: %s", new_name)
238
+ guessed_name = new_name
239
+ else:
240
+ logger.debug("Could not format filename from album metadata")
241
+
242
+ # For single files, use the file's metadata
243
+ elif len(files) == 1:
244
+ from .media_tags import get_file_tags, format_metadata_filename
245
+
246
+ tags = get_file_tags(files[0])
247
+ if tags:
248
+ template = args.name_template or "{title} - {artist}"
249
+ new_name = format_metadata_filename(tags, template)
250
+
251
+ if new_name:
252
+ logger.info("Using file metadata for output filename: %s", new_name)
253
+ guessed_name = new_name
254
+ else:
255
+ logger.debug("Could not format filename from file metadata")
256
+
257
+ # For multiple files from different folders, try to use common tags if they exist
258
+ elif len(files) > 1:
259
+ from .media_tags import get_file_tags, format_metadata_filename
260
+
261
+ # Try to find common tags among files
262
+ common_tags = {}
263
+ for file_path in files:
264
+ tags = get_file_tags(file_path)
265
+ if tags:
266
+ for key, value in tags.items():
267
+ if key in ['album', 'albumartist', 'artist']:
268
+ if key not in common_tags:
269
+ common_tags[key] = value
270
+ # Only keep values that are the same across files
271
+ elif common_tags[key] != value:
272
+ common_tags[key] = None
273
+
274
+ # Remove None values
275
+ common_tags = {k: v for k, v in common_tags.items() if v is not None}
276
+
277
+ if common_tags:
278
+ template = args.name_template or "Collection - {album}" if 'album' in common_tags else "Collection"
279
+ new_name = format_metadata_filename(common_tags, template)
280
+
281
+ if new_name:
282
+ logger.info("Using common metadata for output filename: %s", new_name)
283
+ guessed_name = new_name
284
+ else:
285
+ logger.debug("Could not format filename from common metadata")
286
+
181
287
  if args.output_filename:
182
288
  out_filename = args.output_filename
289
+ elif guessed_name:
290
+ if args.output_to_source:
291
+ source_dir = os.path.dirname(files[0]) if files else '.'
292
+ out_filename = os.path.join(source_dir, guessed_name)
293
+ logger.debug("Using source location for output with media tags: %s", out_filename)
294
+ else:
295
+ output_dir = './output'
296
+ if not os.path.exists(output_dir):
297
+ logger.debug("Creating default output directory: %s", output_dir)
298
+ os.makedirs(output_dir, exist_ok=True)
299
+ out_filename = os.path.join(output_dir, guessed_name)
300
+ logger.debug("Using default output location with media tags: %s", out_filename)
183
301
  else:
184
302
  guessed_name = guess_output_filename(args.input_filename, files)
185
303
  if args.output_to_source:
@@ -52,6 +52,10 @@ DEPENDENCIES = {
52
52
  'darwin': {
53
53
  'package': 'opus-tools'
54
54
  }
55
+ },
56
+ 'mutagen': {
57
+ 'package': 'mutagen',
58
+ 'python_package': True
55
59
  }
56
60
  }
57
61
 
@@ -365,6 +369,92 @@ def install_package(package_name):
365
369
  logger.error("Failed to install %s: %s", package_name, e)
366
370
  return False
367
371
 
372
+ def install_python_package(package_name):
373
+ """
374
+ Attempt to install a Python package using pip.
375
+
376
+ Args:
377
+ package_name (str): Name of the package to install
378
+
379
+ Returns:
380
+ bool: True if installation was successful, False otherwise
381
+ """
382
+ logger.info("Attempting to install Python package: %s", package_name)
383
+ try:
384
+ import subprocess
385
+ import sys
386
+
387
+ # Try to install the package using pip
388
+ subprocess.check_call([sys.executable, "-m", "pip", "install", package_name])
389
+ logger.info("Successfully installed Python package: %s", package_name)
390
+ return True
391
+ except Exception as e:
392
+ logger.error("Failed to install Python package %s: %s", package_name, str(e))
393
+ return False
394
+
395
+ def check_python_package(package_name):
396
+ """
397
+ Check if a Python package is installed.
398
+
399
+ Args:
400
+ package_name (str): Name of the package to check
401
+
402
+ Returns:
403
+ bool: True if the package is installed, False otherwise
404
+ """
405
+ logger.debug("Checking if Python package is installed: %s", package_name)
406
+ try:
407
+ __import__(package_name)
408
+ logger.debug("Python package %s is installed", package_name)
409
+ return True
410
+ except ImportError:
411
+ logger.debug("Python package %s is not installed", package_name)
412
+ return False
413
+
414
+ def ensure_mutagen(auto_install=True):
415
+ """
416
+ Ensure that the Mutagen library is available, installing it if necessary and allowed.
417
+
418
+ Args:
419
+ auto_install (bool): Whether to automatically install Mutagen if not found (defaults to True)
420
+
421
+ Returns:
422
+ bool: True if Mutagen is available, False otherwise
423
+ """
424
+ logger.debug("Checking if Mutagen is available")
425
+
426
+ try:
427
+ import mutagen
428
+ logger.debug("Mutagen is already installed")
429
+ return True
430
+ except ImportError:
431
+ logger.debug("Mutagen is not installed")
432
+
433
+ if auto_install:
434
+ logger.info("Auto-install enabled, attempting to install Mutagen")
435
+ if install_python_package('mutagen'):
436
+ try:
437
+ import mutagen
438
+ logger.info("Successfully installed and imported Mutagen")
439
+ return True
440
+ except ImportError:
441
+ logger.error("Mutagen was installed but could not be imported")
442
+ else:
443
+ logger.error("Failed to install Mutagen")
444
+ else:
445
+ logger.warning("Mutagen is not installed and --auto-download is not used.")
446
+
447
+ return False
448
+
449
+ def is_mutagen_available():
450
+ """
451
+ Check if the Mutagen library is available.
452
+
453
+ Returns:
454
+ bool: True if Mutagen is available, False otherwise
455
+ """
456
+ return check_python_package('mutagen')
457
+
368
458
  def ensure_dependency(dependency_name, auto_download=False):
369
459
  """
370
460
  Ensure that a dependency is available, downloading it if necessary.
@@ -0,0 +1,471 @@
1
+ """
2
+ Media tag processing functionality for the TonieToolbox package
3
+
4
+ This module handles reading and processing metadata tags from audio files,
5
+ which can be used to enhance Tonie file creation with proper track information.
6
+ """
7
+
8
+ import os
9
+ from typing import Dict, Any, Optional, List
10
+ import logging
11
+ from .logger import get_logger
12
+ from .dependency_manager import is_mutagen_available, ensure_mutagen
13
+
14
+ # Global variables to track dependency state and store module references
15
+ MUTAGEN_AVAILABLE = False
16
+ mutagen = None
17
+ ID3 = None
18
+ FLAC = None
19
+ MP4 = None
20
+ OggOpus = None
21
+ OggVorbis = None
22
+
23
+ def _import_mutagen():
24
+ """
25
+ Import the mutagen modules and update global variables.
26
+
27
+ Returns:
28
+ bool: True if import was successful, False otherwise
29
+ """
30
+ global MUTAGEN_AVAILABLE, mutagen, ID3, FLAC, MP4, OggOpus, OggVorbis
31
+
32
+ try:
33
+ import mutagen as _mutagen
34
+ from mutagen.id3 import ID3 as _ID3
35
+ from mutagen.flac import FLAC as _FLAC
36
+ from mutagen.mp4 import MP4 as _MP4
37
+ from mutagen.oggopus import OggOpus as _OggOpus
38
+ from mutagen.oggvorbis import OggVorbis as _OggVorbis
39
+
40
+ # Assign to global variables
41
+ mutagen = _mutagen
42
+ ID3 = _ID3
43
+ FLAC = _FLAC
44
+ MP4 = _MP4
45
+ OggOpus = _OggOpus
46
+ OggVorbis = _OggVorbis
47
+ MUTAGEN_AVAILABLE = True
48
+ return True
49
+ except ImportError:
50
+ MUTAGEN_AVAILABLE = False
51
+ return False
52
+
53
+ # Try to import mutagen if it's available
54
+ if is_mutagen_available():
55
+ _import_mutagen()
56
+
57
+ logger = get_logger('media_tags')
58
+
59
+ # Define tag mapping for different formats to standardized names
60
+ # This helps normalize tags across different audio formats
61
+ TAG_MAPPING = {
62
+ # ID3 (MP3) tags
63
+ 'TIT2': 'title',
64
+ 'TALB': 'album',
65
+ 'TPE1': 'artist',
66
+ 'TPE2': 'albumartist',
67
+ 'TCOM': 'composer',
68
+ 'TRCK': 'tracknumber',
69
+ 'TPOS': 'discnumber',
70
+ 'TDRC': 'date',
71
+ 'TCON': 'genre',
72
+ 'TPUB': 'publisher',
73
+ 'TCOP': 'copyright',
74
+ 'COMM': 'comment',
75
+
76
+ # Vorbis tags (FLAC, OGG)
77
+ 'title': 'title',
78
+ 'album': 'album',
79
+ 'artist': 'artist',
80
+ 'albumartist': 'albumartist',
81
+ 'composer': 'composer',
82
+ 'tracknumber': 'tracknumber',
83
+ 'discnumber': 'discnumber',
84
+ 'date': 'date',
85
+ 'genre': 'genre',
86
+ 'publisher': 'publisher',
87
+ 'copyright': 'copyright',
88
+ 'comment': 'comment',
89
+
90
+ # MP4 (M4A, AAC) tags
91
+ '©nam': 'title',
92
+ '©alb': 'album',
93
+ '©ART': 'artist',
94
+ 'aART': 'albumartist',
95
+ '©wrt': 'composer',
96
+ 'trkn': 'tracknumber',
97
+ 'disk': 'discnumber',
98
+ '©day': 'date',
99
+ '©gen': 'genre',
100
+ '©pub': 'publisher',
101
+ 'cprt': 'copyright',
102
+ '©cmt': 'comment',
103
+
104
+ # Additional tags some files might have
105
+ 'album_artist': 'albumartist',
106
+ 'track': 'tracknumber',
107
+ 'track_number': 'tracknumber',
108
+ 'disc': 'discnumber',
109
+ 'disc_number': 'discnumber',
110
+ 'year': 'date',
111
+ 'albuminterpret': 'albumartist', # German tag name
112
+ 'interpret': 'artist', # German tag name
113
+ }
114
+
115
+ # Define replacements for special tag values
116
+ TAG_VALUE_REPLACEMENTS = {
117
+ "Die drei ???": "Die drei Fragezeichen",
118
+ "Die Drei ???": "Die drei Fragezeichen",
119
+ "DIE DREI ???": "Die drei Fragezeichen",
120
+ "Die drei !!!": "Die drei Ausrufezeichen",
121
+ "Die Drei !!!": "Die drei Ausrufezeichen",
122
+ "DIE DREI !!!": "Die drei Ausrufezeichen",
123
+ "TKKG™": "TKKG",
124
+ "Die drei ??? Kids": "Die drei Fragezeichen Kids",
125
+ "Die Drei ??? Kids": "Die drei Fragezeichen Kids",
126
+ "Bibi & Tina": "Bibi und Tina",
127
+ "Benjamin Blümchen™": "Benjamin Blümchen",
128
+ "???": "Fragezeichen",
129
+ "!!!": "Ausrufezeichen",
130
+ }
131
+
132
+ def normalize_tag_value(value: str) -> str:
133
+ """
134
+ Normalize tag values by replacing special characters or known patterns
135
+ with more file-system-friendly alternatives.
136
+
137
+ Args:
138
+ value: The original tag value
139
+
140
+ Returns:
141
+ Normalized tag value
142
+ """
143
+ if not value:
144
+ return value
145
+
146
+ # Check for direct replacements first
147
+ if value in TAG_VALUE_REPLACEMENTS:
148
+ logger.debug("Direct tag replacement: '%s' -> '%s'", value, TAG_VALUE_REPLACEMENTS[value])
149
+ return TAG_VALUE_REPLACEMENTS[value]
150
+
151
+ # Check for partial matches and replacements
152
+ result = value
153
+ for pattern, replacement in TAG_VALUE_REPLACEMENTS.items():
154
+ if pattern in result:
155
+ original = result
156
+ result = result.replace(pattern, replacement)
157
+ logger.debug("Partial tag replacement: '%s' -> '%s'", original, result)
158
+
159
+ # Special case for "Die drei ???" type patterns that might have been missed
160
+ result = result.replace("???", "Fragezeichen")
161
+
162
+ return result
163
+
164
+ def is_available() -> bool:
165
+ """
166
+ Check if tag reading functionality is available.
167
+
168
+ Returns:
169
+ bool: True if mutagen is available, False otherwise
170
+ """
171
+ return MUTAGEN_AVAILABLE or is_mutagen_available()
172
+
173
+ def get_file_tags(file_path: str) -> Dict[str, Any]:
174
+ """
175
+ Extract metadata tags from an audio file.
176
+
177
+ Args:
178
+ file_path: Path to the audio file
179
+
180
+ Returns:
181
+ Dictionary containing standardized tag names and values
182
+ """
183
+ global MUTAGEN_AVAILABLE
184
+
185
+ if not MUTAGEN_AVAILABLE:
186
+ # Try to ensure mutagen is available
187
+ if ensure_mutagen(auto_install=True):
188
+ # If successful, import the necessary modules
189
+ if not _import_mutagen():
190
+ logger.warning("Mutagen library not available. Cannot read media tags.")
191
+ return {}
192
+ else:
193
+ logger.warning("Mutagen library not available. Cannot read media tags.")
194
+ return {}
195
+
196
+ logger.debug("Reading tags from file: %s", file_path)
197
+ tags = {}
198
+
199
+ try:
200
+ # Use mutagen to identify and load the file
201
+ audio = mutagen.File(file_path)
202
+ if audio is None:
203
+ logger.warning("Could not identify file format: %s", file_path)
204
+ return tags
205
+
206
+ # Process different file types
207
+ if isinstance(audio, ID3) or hasattr(audio, 'ID3'):
208
+ # MP3 files
209
+ id3 = audio if isinstance(audio, ID3) else audio.ID3
210
+ for tag_key, tag_value in id3.items():
211
+ tag_name = tag_key.split(':')[0] # Handle ID3 tags with colons
212
+ if tag_name in TAG_MAPPING:
213
+ tag_value_str = str(tag_value)
214
+ tags[TAG_MAPPING[tag_name]] = normalize_tag_value(tag_value_str)
215
+ elif isinstance(audio, (FLAC, OggOpus, OggVorbis)):
216
+ # FLAC and OGG files
217
+ for tag_key, tag_values in audio.items():
218
+ tag_key_lower = tag_key.lower()
219
+ if tag_key_lower in TAG_MAPPING:
220
+ # Some tags might have multiple values, we'll take the first one
221
+ tag_value = tag_values[0] if tag_values else ''
222
+ tags[TAG_MAPPING[tag_key_lower]] = normalize_tag_value(tag_value)
223
+ elif isinstance(audio, MP4):
224
+ # MP4 files
225
+ for tag_key, tag_value in audio.items():
226
+ if tag_key in TAG_MAPPING:
227
+ if isinstance(tag_value, list):
228
+ if tag_key in ('trkn', 'disk'):
229
+ # Handle track and disc number tuples
230
+ if tag_value and isinstance(tag_value[0], tuple) and len(tag_value[0]) >= 1:
231
+ tags[TAG_MAPPING[tag_key]] = str(tag_value[0][0])
232
+ else:
233
+ tag_value_str = str(tag_value[0]) if tag_value else ''
234
+ tags[TAG_MAPPING[tag_key]] = normalize_tag_value(tag_value_str)
235
+ else:
236
+ tag_value_str = str(tag_value)
237
+ tags[TAG_MAPPING[tag_key]] = normalize_tag_value(tag_value_str)
238
+ else:
239
+ # Generic audio file - try to read any available tags
240
+ for tag_key, tag_value in audio.items():
241
+ tag_key_lower = tag_key.lower()
242
+ if tag_key_lower in TAG_MAPPING:
243
+ if isinstance(tag_value, list):
244
+ tag_value_str = str(tag_value[0]) if tag_value else ''
245
+ tags[TAG_MAPPING[tag_key_lower]] = normalize_tag_value(tag_value_str)
246
+ else:
247
+ tag_value_str = str(tag_value)
248
+ tags[TAG_MAPPING[tag_key_lower]] = normalize_tag_value(tag_value_str)
249
+
250
+ logger.debug("Successfully read %d tags from file", len(tags))
251
+ return tags
252
+ except Exception as e:
253
+ logger.error("Error reading tags from file %s: %s", file_path, str(e))
254
+ return tags
255
+
256
+ def extract_first_audio_file_tags(folder_path: str) -> Dict[str, str]:
257
+ """
258
+ Extract tags from the first audio file in a folder.
259
+
260
+ Args:
261
+ folder_path: Path to folder containing audio files
262
+
263
+ Returns:
264
+ Dictionary containing standardized tag names and values
265
+ """
266
+ from .audio_conversion import filter_directories
267
+ import glob
268
+
269
+ logger.debug("Looking for audio files in %s", folder_path)
270
+ files = filter_directories(glob.glob(os.path.join(folder_path, "*")))
271
+
272
+ if not files:
273
+ logger.debug("No audio files found in folder")
274
+ return {}
275
+
276
+ # Get tags from the first file
277
+ first_file = files[0]
278
+ logger.debug("Using first audio file for tags: %s", first_file)
279
+
280
+ return get_file_tags(first_file)
281
+
282
+ def extract_album_info(folder_path: str) -> Dict[str, str]:
283
+ """
284
+ Extract album information from audio files in a folder.
285
+ Tries to get consistent album, artist and other information.
286
+
287
+ Args:
288
+ folder_path: Path to folder containing audio files
289
+
290
+ Returns:
291
+ Dictionary with extracted metadata (album, albumartist, etc.)
292
+ """
293
+ from .audio_conversion import filter_directories
294
+ import glob
295
+
296
+ logger.debug("Extracting album information from folder: %s", folder_path)
297
+
298
+ # Get all audio files in the folder
299
+ audio_files = filter_directories(glob.glob(os.path.join(folder_path, "*")))
300
+ if not audio_files:
301
+ logger.debug("No audio files found in folder")
302
+ return {}
303
+
304
+ # Collect tag information from all files
305
+ all_tags = []
306
+ for file_path in audio_files:
307
+ tags = get_file_tags(file_path)
308
+ if tags:
309
+ all_tags.append(tags)
310
+
311
+ if not all_tags:
312
+ logger.debug("Could not read tags from any files in folder")
313
+ return {}
314
+
315
+ # Try to find consistent album information
316
+ result = {}
317
+ key_tags = ['album', 'albumartist', 'artist', 'date', 'genre']
318
+
319
+ for tag_name in key_tags:
320
+ # Count occurrences of each value
321
+ value_counts = {}
322
+ for tags in all_tags:
323
+ if tag_name in tags:
324
+ value = tags[tag_name]
325
+ if value in value_counts:
326
+ value_counts[value] += 1
327
+ else:
328
+ value_counts[value] = 1
329
+
330
+ # Use the most common value, or the first one if there's a tie
331
+ if value_counts:
332
+ most_common_value = max(value_counts.items(), key=lambda x: x[1])[0]
333
+ result[tag_name] = most_common_value
334
+
335
+ logger.debug("Extracted album info: %s", str(result))
336
+ return result
337
+
338
+ def get_file_metadata(file_path: str) -> Dict[str, str]:
339
+ """
340
+ Get comprehensive metadata about a single audio file,
341
+ including both file tags and additional information.
342
+
343
+ Args:
344
+ file_path: Path to the audio file
345
+
346
+ Returns:
347
+ Dictionary containing metadata information
348
+ """
349
+ metadata = {}
350
+
351
+ # Get basic file information
352
+ try:
353
+ basename = os.path.basename(file_path)
354
+ filename, extension = os.path.splitext(basename)
355
+
356
+ metadata['filename'] = filename
357
+ metadata['extension'] = extension.lower().replace('.', '')
358
+ metadata['path'] = file_path
359
+
360
+ # Get file size
361
+ metadata['filesize'] = os.path.getsize(file_path)
362
+
363
+ # Add tags from the file
364
+ tags = get_file_tags(file_path)
365
+ metadata.update(tags)
366
+
367
+ return metadata
368
+ except Exception as e:
369
+ logger.error("Error getting file metadata for %s: %s", file_path, str(e))
370
+ return metadata
371
+
372
+ def get_folder_metadata(folder_path: str) -> Dict[str, Any]:
373
+ """
374
+ Get comprehensive metadata about a folder of audio files.
375
+
376
+ Args:
377
+ folder_path: Path to folder containing audio files
378
+
379
+ Returns:
380
+ Dictionary containing metadata information and list of files
381
+ """
382
+ folder_metadata = {}
383
+
384
+ # Get basic folder information
385
+ folder_metadata['folder_name'] = os.path.basename(folder_path)
386
+ folder_metadata['folder_path'] = folder_path
387
+
388
+ # Try to extract album info
389
+ album_info = extract_album_info(folder_path)
390
+ folder_metadata.update(album_info)
391
+
392
+ # Also get folder name metadata using existing function
393
+ from .recursive_processor import extract_folder_meta
394
+ folder_name_meta = extract_folder_meta(folder_path)
395
+
396
+ # Combine the metadata, prioritizing tag-based over folder name based
397
+ for key, value in folder_name_meta.items():
398
+ if key not in folder_metadata or not folder_metadata[key]:
399
+ folder_metadata[key] = value
400
+
401
+ # Get list of audio files with their metadata
402
+ from .audio_conversion import filter_directories
403
+ import glob
404
+
405
+ audio_files = filter_directories(glob.glob(os.path.join(folder_path, "*")))
406
+ files_metadata = []
407
+
408
+ for file_path in audio_files:
409
+ file_metadata = get_file_metadata(file_path)
410
+ files_metadata.append(file_metadata)
411
+
412
+ folder_metadata['files'] = files_metadata
413
+ folder_metadata['file_count'] = len(files_metadata)
414
+
415
+ return folder_metadata
416
+
417
+ def format_metadata_filename(metadata: Dict[str, str], template: str = "{tracknumber} - {title}") -> str:
418
+ """
419
+ Format a filename using metadata and a template string.
420
+
421
+ Args:
422
+ metadata: Dictionary of metadata tags
423
+ template: Template string with placeholders matching metadata keys
424
+
425
+ Returns:
426
+ Formatted string, or empty string if formatting fails
427
+ """
428
+ try:
429
+ # Format track numbers correctly (e.g., "1" -> "01")
430
+ if 'tracknumber' in metadata:
431
+ track = metadata['tracknumber']
432
+ if '/' in track: # Handle "1/10" format
433
+ track = track.split('/')[0]
434
+ try:
435
+ metadata['tracknumber'] = f"{int(track):02d}"
436
+ except (ValueError, TypeError):
437
+ pass # Keep original value if not a simple number
438
+
439
+ # Format disc numbers the same way
440
+ if 'discnumber' in metadata:
441
+ disc = metadata['discnumber']
442
+ if '/' in disc: # Handle "1/2" format
443
+ disc = disc.split('/')[0]
444
+ try:
445
+ metadata['discnumber'] = f"{int(disc):02d}"
446
+ except (ValueError, TypeError):
447
+ pass
448
+
449
+ # Substitute keys in template
450
+ result = template
451
+ for key, value in metadata.items():
452
+ placeholder = "{" + key + "}"
453
+ if placeholder in result:
454
+ result = result.replace(placeholder, str(value))
455
+
456
+ # Clean up any remaining placeholders for missing metadata
457
+ import re
458
+ result = re.sub(r'\{[^}]+\}', '', result)
459
+
460
+ # Clean up consecutive spaces, dashes, etc.
461
+ result = re.sub(r'\s+', ' ', result)
462
+ result = re.sub(r'[-_\s]*-[-_\s]*', ' - ', result)
463
+ result = re.sub(r'^\s+|\s+$', '', result) # trim
464
+
465
+ # Replace characters that aren't allowed in filenames
466
+ result = re.sub(r'[<>:"/\\|?*]', '-', result)
467
+
468
+ return result
469
+ except Exception as e:
470
+ logger.error("Error formatting metadata: %s", str(e))
471
+ return ""
@@ -204,12 +204,101 @@ def extract_folder_meta(folder_path: str) -> Dict[str, str]:
204
204
  return meta
205
205
 
206
206
 
207
- def process_recursive_folders(root_path: str) -> List[Tuple[str, str, List[str]]]:
207
+ def get_folder_name_from_metadata(folder_path: str, use_media_tags: bool = False, template: str = None) -> str:
208
+ """
209
+ Generate a suitable output filename for a folder based on folder name
210
+ and optionally audio file metadata.
211
+
212
+ Args:
213
+ folder_path: Path to folder
214
+ use_media_tags: Whether to use media tags from audio files if available
215
+ template: Optional template for formatting output name using media tags
216
+
217
+ Returns:
218
+ String with cleaned output name
219
+ """
220
+ # Start with folder name metadata
221
+ folder_meta = extract_folder_meta(folder_path)
222
+ output_name = None
223
+
224
+ # Try to get metadata from audio files if requested
225
+ if use_media_tags:
226
+ try:
227
+ # Import here to avoid circular imports
228
+ from .media_tags import extract_album_info, format_metadata_filename, is_available, normalize_tag_value
229
+
230
+ if is_available():
231
+ logger.debug("Using media tags to generate folder name for: %s", folder_path)
232
+
233
+ # Get album metadata from the files
234
+ album_info = extract_album_info(folder_path)
235
+
236
+ if album_info:
237
+ # Normalize all tag values to handle special characters
238
+ for key, value in album_info.items():
239
+ album_info[key] = normalize_tag_value(value)
240
+
241
+ # Add folder metadata as fallback values
242
+ if 'number' in folder_meta and folder_meta['number']:
243
+ if 'tracknumber' not in album_info or not album_info['tracknumber']:
244
+ album_info['tracknumber'] = folder_meta['number']
245
+
246
+ if 'title' in folder_meta and folder_meta['title']:
247
+ if 'album' not in album_info or not album_info['album']:
248
+ album_info['album'] = normalize_tag_value(folder_meta['title'])
249
+
250
+ # Use template or default format
251
+ format_template = template or "{album}"
252
+ if 'artist' in album_info and album_info['artist']:
253
+ format_template = format_template + " - {artist}"
254
+ if 'number' in folder_meta and folder_meta['number']:
255
+ format_template = "{tracknumber} - " + format_template
256
+
257
+ formatted_name = format_metadata_filename(album_info, format_template)
258
+
259
+ if formatted_name:
260
+ logger.debug("Generated name from media tags: %s", formatted_name)
261
+ output_name = formatted_name
262
+ except Exception as e:
263
+ logger.warning("Error using media tags for folder naming: %s", str(e))
264
+
265
+ # Fall back to folder name parsing if no media tags or if media tag extraction failed
266
+ if not output_name:
267
+ if folder_meta['number'] and folder_meta['title']:
268
+ # Apply normalization to the title from the folder name
269
+ try:
270
+ from .media_tags import normalize_tag_value
271
+ normalized_title = normalize_tag_value(folder_meta['title'])
272
+ output_name = f"{folder_meta['number']} - {normalized_title}"
273
+ except:
274
+ output_name = f"{folder_meta['number']} - {folder_meta['title']}"
275
+ else:
276
+ # Try to normalize the folder name itself
277
+ folder_name = os.path.basename(folder_path)
278
+ try:
279
+ from .media_tags import normalize_tag_value
280
+ output_name = normalize_tag_value(folder_name)
281
+ except:
282
+ output_name = folder_name
283
+
284
+ # Clean up the output name (remove invalid filename characters)
285
+ output_name = re.sub(r'[<>:"/\\|?*]', '_', output_name)
286
+ output_name = output_name.replace("???", "Fragezeichen")
287
+ output_name = output_name.replace("!!!", "Ausrufezeichen")
288
+
289
+ logger.debug("Final generated output name: %s", output_name)
290
+ return output_name
291
+
292
+
293
+ def process_recursive_folders(root_path: str, use_media_tags: bool = False,
294
+ name_template: str = None) -> List[Tuple[str, str, List[str]]]:
208
295
  """
209
296
  Process folders recursively and prepare data for conversion.
210
297
 
211
298
  Args:
212
299
  root_path: Root directory to start processing from
300
+ use_media_tags: Whether to use media tags from audio files for naming
301
+ name_template: Optional template for formatting output names using media tags
213
302
 
214
303
  Returns:
215
304
  List of tuples: (output_filename, folder_path, list_of_audio_files)
@@ -230,17 +319,13 @@ def process_recursive_folders(root_path: str) -> List[Tuple[str, str, List[str]]
230
319
  # Use natural sort order to ensure consistent results
231
320
  audio_files = natural_sort(audio_files)
232
321
 
233
- meta = extract_folder_meta(folder_path)
234
-
235
322
  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)
323
+ # Generate output filename using metadata
324
+ output_name = get_folder_name_from_metadata(
325
+ folder_path,
326
+ use_media_tags=use_media_tags,
327
+ template=name_template
328
+ )
244
329
 
245
330
  results.append((output_name, folder_path, audio_files))
246
331
  logger.debug("Created processing task: %s -> %s (%d files)",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TonieToolbox
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Convert audio files to Tonie box compatible format
5
5
  Home-page: https://github.com/Quentendo64/TonieToolbox
6
6
  Author: Quentendo64
@@ -46,6 +46,7 @@ A Python tool for converting audio files to Tonie box compatible format (TAF - T
46
46
  - [Basic Usage](#basic-usage)
47
47
  - [Advanced Options](#advanced-options)
48
48
  - [Common Usage Examples](#common-usage-examples)
49
+ - [Media Tags](#media-tags)
49
50
  - [Technical Details](#technical-details)
50
51
  - [TAF File Structure](#taf-tonie-audio-format-file-structure)
51
52
  - [File Analysis](#file-analysis)
@@ -67,15 +68,17 @@ The tool provides several capabilities:
67
68
  - Split Tonie files into individual opus tracks
68
69
  - Compare two TAF files for debugging differences
69
70
  - Support various input formats through FFmpeg conversion
71
+ - Extract and use audio media tags (ID3, Vorbis Comments, etc.) for better file naming
70
72
 
71
73
  ## Requirements
72
74
 
73
75
  - Python 3.6 or higher
74
- - FFmpeg (for converting non-opus audio files).
76
+ - FFmpeg (for converting non-opus audio files)
75
77
  - opus-tools (specifically `opusenc` for encoding to opus format)
78
+ - mutagen (for reading audio file metadata, auto-installed when needed)
76
79
 
77
80
  Make sure FFmpeg and opus-tools are installed on your system and accessible in your PATH.
78
- If the requirements are not found in PATH. TonieToolbox will download the missing requirements.
81
+ If the requirements are not found in PATH. TonieToolbox will download the missing requirements with --auto-download.
79
82
 
80
83
  ## Installation
81
84
 
@@ -166,7 +169,8 @@ Output:
166
169
  ```
167
170
  usage: TonieToolbox.py [-h] [-v] [-t TIMESTAMP] [-f FFMPEG] [-o OPUSENC]
168
171
  [-b BITRATE] [-c] [-a TAG] [-n] [-i] [-s] [-r] [-O]
169
- [-A] [-k] [-C FILE2] [-D] [-d] [-T] [-q] [-Q]
172
+ [-A] [-k] [-C FILE2] [-D] [-m] [--name-template TEMPLATE]
173
+ [--show-tags] [-d] [-T] [-q] [-Q]
170
174
  SOURCE [TARGET]
171
175
 
172
176
  Create Tonie compatible file from Ogg opus file(s).
@@ -198,6 +202,12 @@ optional arguments:
198
202
  -D, --detailed-compare
199
203
  Show detailed OGG page differences when comparing files
200
204
 
205
+ Media Tag Options:
206
+ -m, --use-media-tags Use media tags from audio files for naming
207
+ --name-template TEMPLATE
208
+ Template for naming files using media tags. Example: "{album} - {artist}"
209
+ --show-tags Show available media tags from input files
210
+
201
211
  Version Check Options:
202
212
  -S, --skip-update-check
203
213
  Skip checking for updates
@@ -269,6 +279,52 @@ Process a music collection with nested album folders and save TAF files alongsid
269
279
  tonietoolbox --recursive --output-to-source "\Hörspiele\"
270
280
  ```
271
281
 
282
+ ### Media Tags
283
+
284
+ TonieToolbox can read metadata tags from audio files (such as ID3 tags in MP3 files, Vorbis comments in FLAC/OGG files, etc.) and use them to create more meaningful filenames or display information about your audio collection.
285
+
286
+ #### View available tags in audio files:
287
+
288
+ To see what tags are available in your audio files:
289
+
290
+ ```
291
+ tonietoolbox --show-tags input.mp3
292
+ ```
293
+
294
+ This will display all readable tags from the file, which can be useful for creating naming templates.
295
+
296
+ #### Use media tags for file naming:
297
+
298
+ To use the metadata from audio files when generating output filenames:
299
+
300
+ ```
301
+ tonietoolbox input.mp3 --use-media-tags
302
+ ```
303
+
304
+ For single files, this will use a default template of "{title} - {artist}" for the output filename.
305
+
306
+ #### Custom naming templates:
307
+
308
+ You can specify custom templates for generating filenames based on the audio metadata:
309
+
310
+ ```
311
+ tonietoolbox input.mp3 --use-media-tags --name-template "{artist} - {album} - {title}"
312
+ ```
313
+
314
+ #### Recursive processing with media tags:
315
+
316
+ When processing folders recursively, media tags can provide more consistent naming:
317
+
318
+ ```
319
+ tonietoolbox --recursive --use-media-tags "Music/Collection/"
320
+ ```
321
+
322
+ This will attempt to use the album information from the audio files for naming the output files:
323
+
324
+ ```
325
+ tonietoolbox --recursive --use-media-tags --name-template "{date} - {album} ({artist})" "Music/Collection/"
326
+ ```
327
+
272
328
  ## Technical Details
273
329
 
274
330
  ### TAF (Tonie Audio Format) File Structure
@@ -1,21 +1,22 @@
1
- TonieToolbox/__init__.py,sha256=UhSjPdhrXyhaT47L1QQH8FMLyK-1ddkUIQWtXr71Dcs,96
2
- TonieToolbox/__main__.py,sha256=eumivCJXMmlGZJLk3bC61-NwQEq3x5lElSIsmb2ZKWE,11157
1
+ TonieToolbox/__init__.py,sha256=mRuWBCYSLc6tAHyVDd70qM5LHgWH7R8ClFbFfILlEso,96
2
+ TonieToolbox/__main__.py,sha256=Otltlkl9Cd36BYs5_23ufCkdxguFFVzuVDwSvWDSPfI,17078
3
3
  TonieToolbox/audio_conversion.py,sha256=ra72qsE8j2GEP_4kqDT9m6aKlnnREZhZAlpf7y83pA0,11202
4
4
  TonieToolbox/constants.py,sha256=QQWQpnCI65GByLlXLOkt2n8nALLu4m6BWp0zuhI3M04,2021
5
- TonieToolbox/dependency_manager.py,sha256=fBojtYnzK-jN3zOj9ntwotJhnyJfv6a-iz8zivK6L_Q,25017
5
+ TonieToolbox/dependency_manager.py,sha256=dRy8JxV3Dv1x8uWIXMonU0ArnCRknI1rkrj8MkJcyl4,27916
6
6
  TonieToolbox/filename_generator.py,sha256=RqQHyGTKakuWR01yMSnFVMU_HfLw3rqFxKhXNIHdTlg,3441
7
7
  TonieToolbox/logger.py,sha256=Up9fBVkOZwkY61_645bX4tienCpyVSkap-FeTV0v730,1441
8
+ TonieToolbox/media_tags.py,sha256=T8osaU101iGC43NaMbbHD8mwNh-dGa_uPj7IPlhMIno,16091
8
9
  TonieToolbox/ogg_page.py,sha256=-ViaIRBgh5ayfwmyplL8QmmRr5P36X8W0DdHkSFUYUU,21948
9
10
  TonieToolbox/opus_packet.py,sha256=OcHXEe3I_K4mWPUD55prpG42sZxJsEeAxqSbFxBmb0c,7895
10
- TonieToolbox/recursive_processor.py,sha256=vhQzC05bJVRPX8laj_5lxuRD40eLsZatzwCoCavMsmY,9304
11
+ TonieToolbox/recursive_processor.py,sha256=m70-oJ4MRXvI9OMy14sC9UsQIxwVEnEoA3hhLGdHhL4,13457
11
12
  TonieToolbox/tonie_analysis.py,sha256=kp4Wx4cTDddtF2AlS6IX4xs1vQ-mpZ0gsAy4-UdRAAM,23287
12
13
  TonieToolbox/tonie_file.py,sha256=vY0s8X4ln35ZXpdpGmBcIxgpTJAjduiVvBh34WObyrw,19647
13
14
  TonieToolbox/tonie_header.proto,sha256=WaWfwO4VrwGtscK2ujfDRKtpeBpaVPoZhI8iMmR-C0U,202
14
15
  TonieToolbox/tonie_header_pb2.py,sha256=s5bp4ULTEekgq6T61z9fDkRavyPM-3eREs20f_Pxxe8,3665
15
16
  TonieToolbox/version_handler.py,sha256=7Zx-pgzAUhz6jMplvNal1wHyxidodVxaNcAV0EMph5k,9778
16
- tonietoolbox-0.2.2.dist-info/licenses/LICENSE.md,sha256=rGoga9ZAgNco9fBapVFpWf6ri7HOBp1KRnt1uIruXMk,35190
17
- tonietoolbox-0.2.2.dist-info/METADATA,sha256=cm2XH96XGJR6OEL0Wc0yvldIIG0rVFZvhaBheKpuzjw,10514
18
- tonietoolbox-0.2.2.dist-info/WHEEL,sha256=lTU6B6eIfYoiQJTZNc-fyaR6BpL6ehTzU3xGYxn2n8k,91
19
- tonietoolbox-0.2.2.dist-info/entry_points.txt,sha256=oqpeyBxel7aScg35Xr4gZKnf486S5KW9okqeBwyJxxc,60
20
- tonietoolbox-0.2.2.dist-info/top_level.txt,sha256=Wkkm-2p7I3ENfS7ZbYtYUB2g-xwHrXVlERHfonsOPuE,13
21
- tonietoolbox-0.2.2.dist-info/RECORD,,
17
+ tonietoolbox-0.2.3.dist-info/licenses/LICENSE.md,sha256=rGoga9ZAgNco9fBapVFpWf6ri7HOBp1KRnt1uIruXMk,35190
18
+ tonietoolbox-0.2.3.dist-info/METADATA,sha256=2LiL3ouh2jIdA3CD7i8nSZY-PStXbhkT8GNTZVvpgrM,12444
19
+ tonietoolbox-0.2.3.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
20
+ tonietoolbox-0.2.3.dist-info/entry_points.txt,sha256=oqpeyBxel7aScg35Xr4gZKnf486S5KW9okqeBwyJxxc,60
21
+ tonietoolbox-0.2.3.dist-info/top_level.txt,sha256=Wkkm-2p7I3ENfS7ZbYtYUB2g-xwHrXVlERHfonsOPuE,13
22
+ tonietoolbox-0.2.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.1)
2
+ Generator: setuptools (79.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5