TonieToolbox 0.4.2__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.
- TonieToolbox/__init__.py +1 -1
- TonieToolbox/__main__.py +269 -349
- TonieToolbox/artwork.py +105 -0
- TonieToolbox/audio_conversion.py +48 -5
- TonieToolbox/media_tags.py +5 -4
- TonieToolbox/recursive_processor.py +24 -19
- TonieToolbox/tags.py +74 -0
- TonieToolbox/teddycloud.py +250 -593
- TonieToolbox/tonie_analysis.py +173 -13
- TonieToolbox/tonie_file.py +17 -29
- TonieToolbox/tonies_json.py +1036 -170
- TonieToolbox/version_handler.py +26 -22
- {tonietoolbox-0.4.2.dist-info → tonietoolbox-0.5.0.dist-info}/METADATA +147 -99
- tonietoolbox-0.5.0.dist-info/RECORD +26 -0
- {tonietoolbox-0.4.2.dist-info → tonietoolbox-0.5.0.dist-info}/WHEEL +1 -1
- tonietoolbox-0.4.2.dist-info/RECORD +0 -24
- {tonietoolbox-0.4.2.dist-info → tonietoolbox-0.5.0.dist-info}/entry_points.txt +0 -0
- {tonietoolbox-0.4.2.dist-info → tonietoolbox-0.5.0.dist-info}/licenses/LICENSE.md +0 -0
- {tonietoolbox-0.4.2.dist-info → tonietoolbox-0.5.0.dist-info}/top_level.txt +0 -0
TonieToolbox/artwork.py
ADDED
@@ -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
|
TonieToolbox/audio_conversion.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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:
|
TonieToolbox/media_tags.py
CHANGED
@@ -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
|
-
|
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
|
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
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
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
|
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
|
294
|
+
Process folders recursively for audio files to create Tonie files.
|
297
295
|
|
298
296
|
Args:
|
299
|
-
root_path:
|
300
|
-
use_media_tags: Whether to use media tags
|
301
|
-
name_template:
|
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
|
-
|
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
|