TonieToolbox 0.2.3__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
TonieToolbox/constants.py CHANGED
@@ -9,7 +9,82 @@ TOO_MANY_SEGMENTS = -4
9
9
  TIMESTAMP_DEDUCT = 0x50000000
10
10
  OPUS_TAGS = [
11
11
  bytearray(
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\x30"),
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(
14
14
  b"\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\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")
15
- ]
15
+ ]
16
+
17
+ # Mapping of language tags to ISO codes
18
+ LANGUAGE_MAPPING = {
19
+ # Common language names to ISO codes
20
+ 'deutsch': 'de-de',
21
+ 'german': 'de-de',
22
+ 'english': 'en-us',
23
+ 'englisch': 'en-us',
24
+ 'français': 'fr-fr',
25
+ 'french': 'fr-fr',
26
+ 'franzosisch': 'fr-fr',
27
+ 'italiano': 'it-it',
28
+ 'italian': 'it-it',
29
+ 'italienisch': 'it-it',
30
+ 'español': 'es-es',
31
+ 'spanish': 'es-es',
32
+ 'spanisch': 'es-es',
33
+ # Two-letter codes
34
+ 'de': 'de-de',
35
+ 'en': 'en-us',
36
+ 'fr': 'fr-fr',
37
+ 'it': 'it-it',
38
+ 'es': 'es-es',
39
+ }
40
+
41
+ # Mapping of genre tags to tonie categories
42
+ GENRE_MAPPING = {
43
+ # Standard Tonie category names from tonies.json
44
+ 'hörspiel': 'Hörspiele & Hörbücher',
45
+ 'hörbuch': 'Hörspiele & Hörbücher',
46
+ 'hörbücher': 'Hörspiele & Hörbücher',
47
+ 'hörspiele': 'Hörspiele & Hörbücher',
48
+ 'audiobook': 'Hörspiele & Hörbücher',
49
+ 'audio book': 'Hörspiele & Hörbücher',
50
+ 'audio play': 'Hörspiele & Hörbücher',
51
+ 'audio-play': 'Hörspiele & Hörbücher',
52
+ 'audiospiel': 'Hörspiele & Hörbücher',
53
+ 'geschichte': 'Hörspiele & Hörbücher',
54
+ 'geschichten': 'Hörspiele & Hörbücher',
55
+ 'erzählung': 'Hörspiele & Hörbücher',
56
+
57
+ # Music related genres
58
+ 'musik': 'music',
59
+ 'lieder': 'music',
60
+ 'songs': 'music',
61
+ 'music': 'music',
62
+ 'lied': 'music',
63
+ 'song': 'music',
64
+
65
+ # More specific categories
66
+ 'kinder': 'Hörspiele & Hörbücher',
67
+ 'children': 'Hörspiele & Hörbücher',
68
+ 'märchen': 'Hörspiele & Hörbücher',
69
+ 'fairy tale': 'Hörspiele & Hörbücher',
70
+ 'märche': 'Hörspiele & Hörbücher',
71
+
72
+ 'wissen': 'Wissen & Hörmagazine',
73
+ 'knowledge': 'Wissen & Hörmagazine',
74
+ 'sachbuch': 'Wissen & Hörmagazine',
75
+ 'learning': 'Wissen & Hörmagazine',
76
+ 'educational': 'Wissen & Hörmagazine',
77
+ 'bildung': 'Wissen & Hörmagazine',
78
+ 'information': 'Wissen & Hörmagazine',
79
+
80
+ 'schlaf': 'Schlaflieder & Entspannung',
81
+ 'sleep': 'Schlaflieder & Entspannung',
82
+ 'meditation': 'Schlaflieder & Entspannung',
83
+ 'entspannung': 'Schlaflieder & Entspannung',
84
+ 'relaxation': 'Schlaflieder & Entspannung',
85
+ 'schlaflied': 'Schlaflieder & Entspannung',
86
+ 'einschlafhilfe': 'Schlaflieder & Entspannung',
87
+
88
+ # Default to standard format for custom
89
+ 'custom': 'Hörspiele & Hörbücher',
90
+ }
@@ -466,7 +466,7 @@ def ensure_dependency(dependency_name, auto_download=False):
466
466
  Returns:
467
467
  str: Path to the binary if available, None otherwise
468
468
  """
469
- logger.info("Ensuring dependency: %s", dependency_name)
469
+ logger.debug("Ensuring dependency: %s", dependency_name)
470
470
  system = get_system()
471
471
 
472
472
  if system not in ['windows', 'linux', 'darwin']:
@@ -496,7 +496,7 @@ def ensure_dependency(dependency_name, auto_download=False):
496
496
  existing_binary = find_binary_in_extracted_dir(dependency_dir, binary_path)
497
497
  if existing_binary and os.path.exists(existing_binary):
498
498
  # Verify that the binary works
499
- logger.info("Found previously downloaded %s: %s", dependency_name, existing_binary)
499
+ logger.debug("Found previously downloaded %s: %s", dependency_name, existing_binary)
500
500
  try:
501
501
  if os.access(existing_binary, os.X_OK) or system == 'windows':
502
502
  if system in ['linux', 'darwin']:
@@ -509,14 +509,14 @@ def ensure_dependency(dependency_name, auto_download=False):
509
509
  try:
510
510
  result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
511
511
  if result.returncode == 0:
512
- logger.info("Using previously downloaded %s: %s", dependency_name, existing_binary)
512
+ logger.debug("Using previously downloaded %s: %s", dependency_name, existing_binary)
513
513
  return existing_binary
514
514
  except:
515
515
  # If --version fails, try without arguments
516
516
  try:
517
517
  result = subprocess.run([existing_binary], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
518
518
  if result.returncode == 0:
519
- logger.info("Using previously downloaded %s: %s", dependency_name, existing_binary)
519
+ logger.debug("Using previously downloaded %s: %s", dependency_name, existing_binary)
520
520
  return existing_binary
521
521
  except:
522
522
  pass
@@ -525,7 +525,7 @@ def ensure_dependency(dependency_name, auto_download=False):
525
525
  try:
526
526
  result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=5)
527
527
  if result.returncode == 0:
528
- logger.info("Using previously downloaded %s: %s", dependency_name, existing_binary)
528
+ logger.debug("Using previously downloaded %s: %s", dependency_name, existing_binary)
529
529
  return existing_binary
530
530
  except:
531
531
  pass
TonieToolbox/logger.py CHANGED
@@ -5,6 +5,8 @@ Logging configuration for the TonieToolbox package.
5
5
  import logging
6
6
  import os
7
7
  import sys
8
+ from datetime import datetime
9
+ from pathlib import Path
8
10
 
9
11
  # Define log levels and their names
10
12
  TRACE = 5 # Custom level for ultra-verbose debugging
@@ -19,20 +21,39 @@ def trace(self, message, *args, **kwargs):
19
21
  # Add trace method to the Logger class
20
22
  logging.Logger.trace = trace
21
23
 
22
- def setup_logging(level=logging.INFO):
24
+ def get_log_file_path():
25
+ """
26
+ Get the path to the log file in the .tonietoolbox folder with timestamp.
27
+
28
+ Returns:
29
+ Path: Path to the log file
30
+ """
31
+ # Create .tonietoolbox folder in user's home directory if it doesn't exist
32
+ log_dir = Path.home() / '.tonietoolbox'
33
+ log_dir.mkdir(exist_ok=True)
34
+
35
+ # Create timestamp string for the filename
36
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
37
+
38
+ # Define log file path with timestamp
39
+ log_file = log_dir / f'tonietoolbox_{timestamp}.log'
40
+
41
+ return log_file
42
+
43
+ def setup_logging(level=logging.INFO, log_to_file=False):
23
44
  """
24
45
  Set up logging configuration for the entire application.
25
46
 
26
47
  Args:
27
48
  level: Logging level (default: logging.INFO)
49
+ log_to_file: Whether to log to a file (default: False)
28
50
 
29
51
  Returns:
30
52
  logging.Logger: Root logger instance
31
53
  """
32
- # Configure root logger
33
- logging.basicConfig(
34
- level=level,
35
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
54
+ # Create formatter
55
+ formatter = logging.Formatter(
56
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
36
57
  datefmt='%Y-%m-%d %H:%M:%S'
37
58
  )
38
59
 
@@ -40,6 +61,31 @@ def setup_logging(level=logging.INFO):
40
61
  root_logger = logging.getLogger('TonieToolbox')
41
62
  root_logger.setLevel(level)
42
63
 
64
+ # Remove any existing handlers to avoid duplicate logs
65
+ for handler in root_logger.handlers[:]:
66
+ root_logger.removeHandler(handler)
67
+
68
+ # Console handler
69
+ console_handler = logging.StreamHandler(sys.stdout)
70
+ console_handler.setFormatter(formatter)
71
+ console_handler.setLevel(level)
72
+ root_logger.addHandler(console_handler)
73
+
74
+ # File handler (if enabled)
75
+ if log_to_file:
76
+ try:
77
+ log_file = get_log_file_path()
78
+ file_handler = logging.FileHandler(
79
+ log_file,
80
+ encoding='utf-8'
81
+ )
82
+ file_handler.setFormatter(formatter)
83
+ file_handler.setLevel(level)
84
+ root_logger.addHandler(file_handler)
85
+ root_logger.info(f"Log file created at: {log_file}")
86
+ except Exception as e:
87
+ root_logger.error(f"Failed to set up file logging: {e}")
88
+
43
89
  return root_logger
44
90
 
45
91
  def get_logger(name):
@@ -8,6 +8,9 @@ which can be used to enhance Tonie file creation with proper track information.
8
8
  import os
9
9
  from typing import Dict, Any, Optional, List
10
10
  import logging
11
+ import tempfile
12
+ import base64
13
+ from mutagen.flac import Picture
11
14
  from .logger import get_logger
12
15
  from .dependency_manager import is_mutagen_available, ensure_mutagen
13
16
 
@@ -206,12 +209,28 @@ def get_file_tags(file_path: str) -> Dict[str, Any]:
206
209
  # Process different file types
207
210
  if isinstance(audio, ID3) or hasattr(audio, 'ID3'):
208
211
  # 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)
212
+ try:
213
+ id3 = audio if isinstance(audio, ID3) else audio.ID3
214
+ for tag_key, tag_value in id3.items():
215
+ tag_name = tag_key.split(':')[0] # Handle ID3 tags with colons
216
+ if tag_name in TAG_MAPPING:
217
+ tag_value_str = str(tag_value)
218
+ tags[TAG_MAPPING[tag_name]] = normalize_tag_value(tag_value_str)
219
+ except (AttributeError, TypeError) as e:
220
+ logger.debug("Error accessing ID3 tags: %s", e)
221
+ # Try alternative approach for ID3 tags
222
+ try:
223
+ if hasattr(audio, 'tags') and audio.tags:
224
+ for tag_key in audio.tags.keys():
225
+ if tag_key in TAG_MAPPING:
226
+ tag_value = audio.tags[tag_key]
227
+ if hasattr(tag_value, 'text'):
228
+ tag_value_str = str(tag_value.text[0]) if tag_value.text else ''
229
+ else:
230
+ tag_value_str = str(tag_value)
231
+ tags[TAG_MAPPING[tag_key]] = normalize_tag_value(tag_value_str)
232
+ except Exception as e:
233
+ logger.debug("Alternative ID3 tag reading failed: %s", e)
215
234
  elif isinstance(audio, (FLAC, OggOpus, OggVorbis)):
216
235
  # FLAC and OGG files
217
236
  for tag_key, tag_values in audio.items():
@@ -468,4 +487,167 @@ def format_metadata_filename(metadata: Dict[str, str], template: str = "{tracknu
468
487
  return result
469
488
  except Exception as e:
470
489
  logger.error("Error formatting metadata: %s", str(e))
471
- return ""
490
+ return ""
491
+
492
+ def extract_artwork(file_path: str, output_path: Optional[str] = None) -> Optional[str]:
493
+ """
494
+ Extract artwork from an audio file.
495
+
496
+ Args:
497
+ file_path: Path to the audio file
498
+ output_path: Path where to save the extracted artwork.
499
+ If None, a temporary file will be created.
500
+
501
+ Returns:
502
+ Path to the extracted artwork file, or None if no artwork was found
503
+ """
504
+ if not MUTAGEN_AVAILABLE:
505
+ logger.debug("Mutagen not available - cannot extract artwork")
506
+ return None
507
+
508
+ if not os.path.exists(file_path):
509
+ logger.error("File not found: %s", file_path)
510
+ return None
511
+
512
+ try:
513
+ file_ext = os.path.splitext(file_path.lower())[1]
514
+ artwork_data = None
515
+ mime_type = None
516
+
517
+ # Extract artwork based on file type
518
+ if file_ext == '.mp3':
519
+ audio = mutagen.File(file_path)
520
+
521
+ # Try to get artwork from APIC frames
522
+ if audio.tags:
523
+ for frame in audio.tags.values():
524
+ if frame.FrameID == 'APIC':
525
+ artwork_data = frame.data
526
+ mime_type = frame.mime
527
+ break
528
+
529
+ elif file_ext == '.flac':
530
+ audio = FLAC(file_path)
531
+
532
+ # Get pictures from FLAC
533
+ if audio.pictures:
534
+ artwork_data = audio.pictures[0].data
535
+ mime_type = audio.pictures[0].mime
536
+
537
+ elif file_ext in ['.m4a', '.mp4', '.aac']:
538
+ audio = MP4(file_path)
539
+
540
+ # Check 'covr' atom
541
+ if 'covr' in audio:
542
+ artwork_data = audio['covr'][0]
543
+ # Determine mime type based on data format
544
+ if isinstance(artwork_data, mutagen.mp4.MP4Cover):
545
+ if artwork_data.format == mutagen.mp4.MP4Cover.FORMAT_JPEG:
546
+ mime_type = 'image/jpeg'
547
+ elif artwork_data.format == mutagen.mp4.MP4Cover.FORMAT_PNG:
548
+ mime_type = 'image/png'
549
+ else:
550
+ mime_type = 'image/jpeg' # Default guess
551
+
552
+ elif file_ext == '.ogg':
553
+ try:
554
+ audio = OggVorbis(file_path)
555
+ except:
556
+ try:
557
+ audio = OggOpus(file_path)
558
+ except:
559
+ logger.debug("Could not determine OGG type for %s", file_path)
560
+ return None
561
+
562
+ # For OGG files, metadata pictures are more complex to extract
563
+ if 'metadata_block_picture' in audio:
564
+ picture_data = base64.b64decode(audio['metadata_block_picture'][0])
565
+ flac_picture = Picture(data=picture_data)
566
+ artwork_data = flac_picture.data
567
+ mime_type = flac_picture.mime
568
+
569
+ # If we found artwork data, save it to a file
570
+ if artwork_data:
571
+ # Determine file extension from mime type
572
+ if mime_type == 'image/jpeg':
573
+ ext = '.jpg'
574
+ elif mime_type == 'image/png':
575
+ ext = '.png'
576
+ else:
577
+ ext = '.jpg' # Default to jpg
578
+
579
+ # Create output path if not provided
580
+ if not output_path:
581
+ # Create a temporary file
582
+ temp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
583
+ output_path = temp_file.name
584
+ temp_file.close()
585
+ elif not os.path.splitext(output_path)[1]:
586
+ # Add extension if not in the output path
587
+ output_path += ext
588
+
589
+ # Write artwork to file
590
+ with open(output_path, 'wb') as f:
591
+ f.write(artwork_data)
592
+
593
+ logger.info("Extracted artwork saved to %s", output_path)
594
+ return output_path
595
+ else:
596
+ logger.debug("No artwork found in file: %s", file_path)
597
+ return None
598
+
599
+ except Exception as e:
600
+ logger.debug("Error extracting artwork: %s", e)
601
+ return None
602
+
603
+ def find_cover_image(source_dir):
604
+ """
605
+ Find a cover image in the source directory.
606
+
607
+ Args:
608
+ source_dir: Path to the directory to search for cover images
609
+
610
+ Returns:
611
+ str: Path to the found cover image, or None if not found
612
+ """
613
+ if not os.path.isdir(source_dir):
614
+ return None
615
+
616
+ # Common cover image file names
617
+ cover_names = [
618
+ 'cover', 'folder', 'album', 'front', 'artwork', 'image',
619
+ 'albumart', 'albumartwork', 'booklet'
620
+ ]
621
+
622
+ # Common image extensions
623
+ image_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
624
+
625
+ # Try different variations
626
+ for name in cover_names:
627
+ for ext in image_extensions:
628
+ # Try exact name match
629
+ cover_path = os.path.join(source_dir, name + ext)
630
+ if os.path.exists(cover_path):
631
+ logger.debug("Found cover image: %s", cover_path)
632
+ return cover_path
633
+
634
+ # Try case-insensitive match
635
+ for file in os.listdir(source_dir):
636
+ if file.lower() == (name + ext).lower():
637
+ cover_path = os.path.join(source_dir, file)
638
+ logger.debug("Found cover image: %s", cover_path)
639
+ return cover_path
640
+
641
+ # If no exact matches, try finding any file containing the cover names
642
+ for file in os.listdir(source_dir):
643
+ file_lower = file.lower()
644
+ file_ext = os.path.splitext(file_lower)[1]
645
+ if file_ext in image_extensions:
646
+ for name in cover_names:
647
+ if name in file_lower:
648
+ cover_path = os.path.join(source_dir, file)
649
+ logger.debug("Found cover image: %s", cover_path)
650
+ return cover_path
651
+
652
+ logger.debug("No cover image found in directory: %s", source_dir)
653
+ return None