TonieToolbox 0.4.1__py3-none-any.whl → 0.5.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.
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ Artwork handling functionality for TonieToolbox.
4
+ """
5
+
6
+ import os
7
+ import tempfile
8
+ import shutil
9
+ from typing import List, Optional, Tuple
10
+
11
+ from .logger import get_logger
12
+ from .teddycloud import TeddyCloudClient
13
+ from .media_tags import extract_artwork, find_cover_image
14
+
15
+
16
+ def upload_artwork(client: TeddyCloudClient, taf_filename, source_path, audio_files) -> Tuple[bool, Optional[str]]:
17
+ """
18
+ Find and upload artwork for a Tonie file.
19
+
20
+ Args:
21
+ client: TeddyCloudClient instance to use for API communication
22
+ taf_filename: The filename of the Tonie file (.taf)
23
+ source_path: Source directory to look for artwork
24
+ audio_files: List of audio files to extract artwork from if needed
25
+ Returns:
26
+ tuple: (success, artwork_url) where success is a boolean and artwork_url is the URL of the uploaded artwork
27
+ """
28
+ logger = get_logger('artwork')
29
+ logger.info("Looking for artwork for Tonie file: %s", taf_filename)
30
+ taf_basename = os.path.basename(taf_filename)
31
+ taf_name = os.path.splitext(taf_basename)[0]
32
+ artwork_path = None
33
+ temp_artwork = None
34
+ artwork_path = find_cover_image(source_path)
35
+ if not artwork_path and audio_files and len(audio_files) > 0:
36
+ logger.info("No cover image found, trying to extract from audio files")
37
+ temp_artwork = extract_artwork(audio_files[0])
38
+ if temp_artwork:
39
+ artwork_path = temp_artwork
40
+ logger.info("Extracted artwork from audio file: %s", temp_artwork)
41
+
42
+ if not artwork_path:
43
+ logger.warning("No artwork found for %s", source_path)
44
+ return False, None
45
+
46
+ logger.info("Found artwork: %s", artwork_path)
47
+ artwork_upload_path = "/custom_img"
48
+ artwork_ext = os.path.splitext(artwork_path)[1]
49
+ renamed_artwork_path = None
50
+ upload_success = False
51
+ artwork_url = None
52
+
53
+ try:
54
+ renamed_artwork_path = os.path.join(os.path.dirname(artwork_path),
55
+ f"{taf_name}{artwork_ext}")
56
+
57
+ if renamed_artwork_path != artwork_path:
58
+ shutil.copy(artwork_path, renamed_artwork_path)
59
+ logger.debug("Created renamed artwork copy: %s", renamed_artwork_path)
60
+
61
+ logger.info("Uploading artwork to path: %s as %s%s",
62
+ artwork_upload_path, taf_name, artwork_ext)
63
+ try:
64
+ response = client.upload_file(
65
+ file_path=renamed_artwork_path,
66
+ destination_path=artwork_upload_path,
67
+ special="library"
68
+ )
69
+ upload_success = response.get('success', False)
70
+
71
+ if not upload_success:
72
+ logger.error("Failed to upload %s to TeddyCloud", renamed_artwork_path)
73
+ else:
74
+ logger.info("Successfully uploaded %s to TeddyCloud", renamed_artwork_path)
75
+ logger.debug("Upload response: %s", response)
76
+ except Exception as e:
77
+ logger.error("Error uploading artwork: %s", e)
78
+ upload_success = False
79
+
80
+ if upload_success:
81
+ if not artwork_upload_path.endswith('/'):
82
+ artwork_upload_path += '/'
83
+ artwork_url = f"{artwork_upload_path}{taf_name}{artwork_ext}"
84
+ logger.debug("Artwork URL: %s", artwork_url)
85
+
86
+ except Exception as e:
87
+ logger.error("Error during artwork handling: %s", e)
88
+ upload_success = False
89
+
90
+ finally:
91
+ if renamed_artwork_path != artwork_path and renamed_artwork_path and os.path.exists(renamed_artwork_path):
92
+ try:
93
+ os.unlink(renamed_artwork_path)
94
+ logger.debug("Removed temporary renamed artwork file: %s", renamed_artwork_path)
95
+ except Exception as e:
96
+ logger.debug("Failed to remove temporary renamed artwork file: %s", e)
97
+ if temp_artwork and os.path.exists(temp_artwork):
98
+ try:
99
+ if temp_artwork.startswith(tempfile.gettempdir()):
100
+ os.unlink(temp_artwork)
101
+ logger.debug("Removed temporary extracted artwork file: %s", temp_artwork)
102
+ except Exception as e:
103
+ logger.debug("Failed to remove temporary artwork file: %s", e)
104
+
105
+ return upload_success, artwork_url
@@ -12,7 +12,7 @@ from .logger import get_logger
12
12
  logger = get_logger('audio_conversion')
13
13
 
14
14
 
15
- def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitrate=48, vbr=True, keep_temp=False, auto_download=False):
15
+ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitrate=48, vbr=True, keep_temp=False, auto_download=False, no_mono_conversion=False):
16
16
  """
17
17
  Convert an audio file to Opus format and return a temporary file handle.
18
18
 
@@ -24,12 +24,13 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
24
24
  vbr: Whether to use variable bitrate encoding
25
25
  keep_temp: Whether to keep the temporary files for testing
26
26
  auto_download: Whether to automatically download dependencies if not found
27
+ no_mono_conversion: Whether to skip mono to stereo conversion
27
28
 
28
29
  Returns:
29
30
  tuple: (file handle, temp_file_path) or (file handle, None) if keep_temp is False
30
31
  """
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)
32
+ 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)",
33
+ ffmpeg_binary, opus_binary, filename, bitrate, vbr, keep_temp, auto_download, no_mono_conversion)
33
34
 
34
35
  logger.debug("Converting %s to Opus format (bitrate: %d kbps, vbr: %s)", filename, bitrate, vbr)
35
36
 
@@ -52,6 +53,38 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
52
53
  vbr_parameter = "--vbr" if vbr else "--hard-cbr"
53
54
  logger.debug("Using encoding parameter: %s", vbr_parameter)
54
55
 
56
+ is_mono = False
57
+ ffprobe_path = None
58
+ ffmpeg_dir, ffmpeg_file = os.path.split(ffmpeg_binary)
59
+ ffprobe_candidates = [
60
+ os.path.join(ffmpeg_dir, 'ffprobe'),
61
+ os.path.join(ffmpeg_dir, 'ffprobe.exe'),
62
+ 'ffprobe',
63
+ 'ffprobe.exe',
64
+ ]
65
+ for candidate in ffprobe_candidates:
66
+ if os.path.exists(candidate):
67
+ ffprobe_path = candidate
68
+ break
69
+ if ffprobe_path:
70
+ try:
71
+ probe_cmd = [ffprobe_path, '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=channels', '-of', 'default=noprint_wrappers=1:nokey=1', filename]
72
+ logger.debug(f"Probing audio channels with: {' '.join(probe_cmd)}")
73
+ result = subprocess.run(probe_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
74
+ if result.returncode == 0:
75
+ channels = result.stdout.strip()
76
+ logger.debug(f"Detected channels: {channels}")
77
+ if channels == '1':
78
+ is_mono = True
79
+ else:
80
+ logger.warning(f"ffprobe failed to detect channels: {result.stderr}")
81
+ except Exception as e:
82
+ logger.warning(f"Mono detection failed: {e}")
83
+ else:
84
+ logger.warning("ffprobe not found, will always force stereo conversion for non-Opus input.")
85
+ is_mono = True # Always force stereo if we can't check
86
+ logger.info(f"Mono detected: {is_mono}, no_mono_conversion: {no_mono_conversion}")
87
+
55
88
  temp_path = None
56
89
  if keep_temp:
57
90
  temp_dir = os.path.join(tempfile.gettempdir(), "tonie_toolbox_temp")
@@ -62,7 +95,12 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
62
95
 
63
96
  logger.debug("Starting FFmpeg process")
64
97
  try:
65
- ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
98
+ if is_mono and not no_mono_conversion:
99
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-ac", "2", "-"]
100
+ logger.info(f"Forcing stereo conversion for mono input: {' '.join(ffmpeg_cmd)}")
101
+ else:
102
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
103
+ logger.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
66
104
  logger.trace("FFmpeg command: %s", ffmpeg_cmd)
67
105
  ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE)
68
106
  except FileNotFoundError:
@@ -106,7 +144,12 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
106
144
 
107
145
  logger.debug("Starting FFmpeg process")
108
146
  try:
109
- ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
147
+ if is_mono and not no_mono_conversion:
148
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-ac", "2", "-"]
149
+ logger.info(f"Forcing stereo conversion for mono input: {' '.join(ffmpeg_cmd)}")
150
+ else:
151
+ ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
152
+ logger.info(f"FFmpeg command: {' '.join(ffmpeg_cmd)}")
110
153
  logger.trace("FFmpeg command: %s", ffmpeg_cmd)
111
154
  ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE)
112
155
  except FileNotFoundError:
@@ -267,6 +267,7 @@ def get_file_tags(file_path: str) -> Dict[str, Any]:
267
267
  tags[TAG_MAPPING[tag_key_lower]] = normalize_tag_value(tag_value_str)
268
268
 
269
269
  logger.debug("Successfully read %d tags from file", len(tags))
270
+ logger.debug("Tags: %s", str(tags))
270
271
  return tags
271
272
  except Exception as e:
272
273
  logger.error("Error reading tags from file %s: %s", file_path, str(e))
@@ -330,12 +331,12 @@ def extract_album_info(folder_path: str) -> Dict[str, str]:
330
331
  if not all_tags:
331
332
  logger.debug("Could not read tags from any files in folder")
332
333
  return {}
333
-
334
- # Try to find consistent album information
335
334
  result = {}
336
- key_tags = ['album', 'albumartist', 'artist', 'date', 'genre']
335
+ all_tag_names = set()
336
+ for tags in all_tags:
337
+ all_tag_names.update(tags.keys())
337
338
 
338
- for tag_name in key_tags:
339
+ for tag_name in all_tag_names:
339
340
  # Count occurrences of each value
340
341
  value_counts = {}
341
342
  for tags in all_tags:
@@ -217,14 +217,10 @@ def get_folder_name_from_metadata(folder_path: str, use_media_tags: bool = False
217
217
  Returns:
218
218
  String with cleaned output name
219
219
  """
220
- # Start with folder name metadata
221
220
  folder_meta = extract_folder_meta(folder_path)
222
- output_name = None
223
-
224
- # Try to get metadata from audio files if requested
221
+ output_name = None
225
222
  if use_media_tags:
226
223
  try:
227
- # Import here to avoid circular imports
228
224
  from .media_tags import extract_album_info, format_metadata_filename, is_available, normalize_tag_value
229
225
 
230
226
  if is_available():
@@ -247,12 +243,15 @@ def get_folder_name_from_metadata(folder_path: str, use_media_tags: bool = False
247
243
  if 'album' not in album_info or not album_info['album']:
248
244
  album_info['album'] = normalize_tag_value(folder_meta['title'])
249
245
 
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
246
+ if template:
247
+ format_template = template
248
+ logger.debug("Using provided name template: %s", format_template)
249
+ else:
250
+ format_template = "{album}"
251
+ if 'artist' in album_info and album_info['artist']:
252
+ format_template = format_template + " - {artist}"
253
+ if 'number' in folder_meta and folder_meta['number']:
254
+ format_template = "{tracknumber} - " + format_template
256
255
 
257
256
  formatted_name = format_metadata_filename(album_info, format_template)
258
257
 
@@ -290,20 +289,26 @@ def get_folder_name_from_metadata(folder_path: str, use_media_tags: bool = False
290
289
  return output_name
291
290
 
292
291
 
293
- def process_recursive_folders(root_path: str, use_media_tags: bool = False,
294
- name_template: str = None) -> List[Tuple[str, str, List[str]]]:
292
+ def process_recursive_folders(root_path, use_media_tags=False, name_template=None):
295
293
  """
296
- Process folders recursively and prepare data for conversion.
294
+ Process folders recursively for audio files to create Tonie files.
297
295
 
298
296
  Args:
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
302
-
297
+ root_path (str): The root path to start processing from
298
+ use_media_tags (bool): Whether to use media tags for naming
299
+ name_template (str): Template for naming files using media tags
300
+
303
301
  Returns:
304
- List of tuples: (output_filename, folder_path, list_of_audio_files)
302
+ list: A list of tuples (output_name, folder_path, audio_files)
305
303
  """
304
+ logger = get_logger("recursive_processor")
306
305
  logger.info("Processing folders recursively: %s", root_path)
306
+ # Make sure the path exists
307
+ if not os.path.exists(root_path):
308
+ logger.error("Path does not exist: %s", root_path)
309
+ return []
310
+
311
+ logger.info("Finding folders with audio files in: %s", root_path)
307
312
 
308
313
  # Get folder info with hierarchy details
309
314
  all_folders = find_audio_folders(root_path)
TonieToolbox/tags.py ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/python3
2
+ """
3
+ TonieToolbox - Tags handling functionality.
4
+ This module provides functionality to retrieve and display tags from a TeddyCloud instance.
5
+ """
6
+ from .logger import get_logger
7
+ from .teddycloud import TeddyCloudClient
8
+ import json
9
+ from typing import Optional, Union
10
+
11
+ logger = get_logger('tags')
12
+
13
+ def get_tags(client: TeddyCloudClient) -> bool:
14
+ """
15
+ Get and display tags from a TeddyCloud instance.
16
+
17
+ Args:
18
+ client: TeddyCloudClient instance to use for API communication
19
+
20
+ Returns:
21
+ True if tags were retrieved successfully, False otherwise
22
+ """
23
+ logger.info("Getting tags from TeddyCloud using provided client")
24
+
25
+ response = client.get_tag_index()
26
+
27
+ if not response:
28
+ logger.error("Failed to retrieve tags from TeddyCloud")
29
+ return False
30
+ if isinstance(response, dict) and 'tags' in response:
31
+ tags = response['tags']
32
+ logger.info("Successfully retrieved %d tags from TeddyCloud", len(tags))
33
+
34
+ print("\nAvailable Tags from TeddyCloud:")
35
+ print("-" * 60)
36
+
37
+ sorted_tags = sorted(tags, key=lambda x: (x.get('type', ''), x.get('uid', '')))
38
+
39
+ for tag in sorted_tags:
40
+ uid = tag.get('uid', 'Unknown UID')
41
+ tag_type = tag.get('type', 'Unknown')
42
+ valid = "✓" if tag.get('valid', False) else "✗"
43
+ series = tag.get('tonieInfo', {}).get('series', '')
44
+ episode = tag.get('tonieInfo', {}).get('episode', '')
45
+ source = tag.get('source', '')
46
+ print(f"UID: {uid} ({tag_type}) - Valid: {valid}")
47
+ if series:
48
+ print(f"Series: {series}")
49
+ if episode:
50
+ print(f"Episode: {episode}")
51
+ if source:
52
+ print(f"Source: {source}")
53
+ tracks = tag.get('tonieInfo', {}).get('tracks', [])
54
+ if tracks:
55
+ print("Tracks:")
56
+ for i, track in enumerate(tracks, 1):
57
+ print(f" {i}. {track}")
58
+ track_seconds = tag.get('trackSeconds', [])
59
+ if track_seconds and len(track_seconds) > 1:
60
+ total_seconds = track_seconds[-1]
61
+ minutes = total_seconds // 60
62
+ seconds = total_seconds % 60
63
+ print(f"Duration: {minutes}:{seconds:02d} ({len(track_seconds)-1} tracks)")
64
+
65
+ print("-" * 60)
66
+ else:
67
+ logger.info("Successfully retrieved tag data from TeddyCloud")
68
+ print("\nTag data from TeddyCloud:")
69
+ print("-" * 60)
70
+ print(json.dumps(response, indent=2))
71
+
72
+ print("-" * 60)
73
+
74
+ return True