TonieToolbox 0.2.3__py3-none-any.whl → 0.3.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 +287 -5
- TonieToolbox/dependency_manager.py +5 -5
- TonieToolbox/media_tags.py +167 -1
- TonieToolbox/teddycloud.py +580 -0
- {tonietoolbox-0.2.3.dist-info → tonietoolbox-0.3.0.dist-info}/METADATA +99 -2
- {tonietoolbox-0.2.3.dist-info → tonietoolbox-0.3.0.dist-info}/RECORD +11 -10
- {tonietoolbox-0.2.3.dist-info → tonietoolbox-0.3.0.dist-info}/WHEEL +0 -0
- {tonietoolbox-0.2.3.dist-info → tonietoolbox-0.3.0.dist-info}/entry_points.txt +0 -0
- {tonietoolbox-0.2.3.dist-info → tonietoolbox-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
- {tonietoolbox-0.2.3.dist-info → tonietoolbox-0.3.0.dist-info}/top_level.txt +0 -0
TonieToolbox/__init__.py
CHANGED
TonieToolbox/__main__.py
CHANGED
@@ -18,13 +18,40 @@ 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
20
|
from .media_tags import is_available as is_media_tags_available, ensure_mutagen
|
21
|
+
from .teddycloud import upload_to_teddycloud, get_tags_from_teddycloud, get_file_paths
|
21
22
|
|
22
23
|
def main():
|
23
24
|
"""Entry point for the TonieToolbox application."""
|
24
25
|
parser = argparse.ArgumentParser(description='Create Tonie compatible file from Ogg opus file(s).')
|
25
26
|
parser.add_argument('-v', '--version', action='version', version=f'TonieToolbox {__version__}',
|
26
27
|
help='show program version and exit')
|
27
|
-
|
28
|
+
|
29
|
+
# TeddyCloud options first to check for existence before requiring SOURCE
|
30
|
+
teddycloud_group = parser.add_argument_group('TeddyCloud Options')
|
31
|
+
teddycloud_group.add_argument('--upload', metavar='URL', action='store',
|
32
|
+
help='Upload to TeddyCloud instance (e.g., https://teddycloud.example.com). Supports .taf, .jpg, .jpeg, .png files.')
|
33
|
+
teddycloud_group.add_argument('--include-artwork', action='store_true',
|
34
|
+
help='Upload cover artwork image alongside the Tonie file when using --upload')
|
35
|
+
teddycloud_group.add_argument('--get-tags', action='store', metavar='URL',
|
36
|
+
help='Get available tags from TeddyCloud instance')
|
37
|
+
teddycloud_group.add_argument('--ignore-ssl-verify', action='store_true',
|
38
|
+
help='Ignore SSL certificate verification (for self-signed certificates)')
|
39
|
+
teddycloud_group.add_argument('--special-folder', action='store', metavar='FOLDER',
|
40
|
+
help='Special folder to upload to (currently only "library" is supported)', default='library')
|
41
|
+
teddycloud_group.add_argument('--path', action='store', metavar='PATH',
|
42
|
+
help='Path where to write the file on TeddyCloud server')
|
43
|
+
teddycloud_group.add_argument('--show-progress', action='store_true', default=True,
|
44
|
+
help='Show progress bar during file upload (default: enabled)')
|
45
|
+
teddycloud_group.add_argument('--connection-timeout', type=int, metavar='SECONDS', default=10,
|
46
|
+
help='Connection timeout in seconds (default: 10)')
|
47
|
+
teddycloud_group.add_argument('--read-timeout', type=int, metavar='SECONDS', default=300,
|
48
|
+
help='Read timeout in seconds (default: 300)')
|
49
|
+
teddycloud_group.add_argument('--max-retries', type=int, metavar='RETRIES', default=3,
|
50
|
+
help='Maximum number of retry attempts (default: 3)')
|
51
|
+
teddycloud_group.add_argument('--retry-delay', type=int, metavar='SECONDS', default=5,
|
52
|
+
help='Delay between retry attempts in seconds (default: 5)')
|
53
|
+
|
54
|
+
parser.add_argument('input_filename', metavar='SOURCE', type=str, nargs='?',
|
28
55
|
help='input file or directory or a file list (.lst)')
|
29
56
|
parser.add_argument('output_filename', metavar='TARGET', nargs='?', type=str,
|
30
57
|
help='the output file name (default: ---ID---)')
|
@@ -79,6 +106,11 @@ def main():
|
|
79
106
|
log_level_group.add_argument('-Q', '--silent', action='store_true', help='Show only errors')
|
80
107
|
|
81
108
|
args = parser.parse_args()
|
109
|
+
|
110
|
+
# Validate that input_filename is provided if not using --get-tags or --upload-existing
|
111
|
+
if args.input_filename is None and not (args.get_tags or args.upload):
|
112
|
+
parser.error("the following arguments are required: SOURCE")
|
113
|
+
|
82
114
|
if args.trace:
|
83
115
|
from .logger import TRACE
|
84
116
|
log_level = TRACE
|
@@ -90,11 +122,12 @@ def main():
|
|
90
122
|
log_level = logging.ERROR
|
91
123
|
else:
|
92
124
|
log_level = logging.INFO
|
93
|
-
|
125
|
+
|
94
126
|
setup_logging(log_level)
|
95
127
|
logger = get_logger('main')
|
96
128
|
logger.debug("Starting TonieToolbox v%s with log level: %s", __version__, logging.getLevelName(log_level))
|
97
129
|
|
130
|
+
|
98
131
|
if args.clear_version_cache:
|
99
132
|
if clear_version_cache():
|
100
133
|
logger.info("Version cache cleared successfully")
|
@@ -111,6 +144,59 @@ def main():
|
|
111
144
|
if not is_latest and not update_confirmed and not (args.silent or args.quiet):
|
112
145
|
logger.info("Update available but user chose to continue without updating.")
|
113
146
|
|
147
|
+
# Handle get-tags from TeddyCloud if requested
|
148
|
+
if args.get_tags:
|
149
|
+
teddycloud_url = args.get_tags
|
150
|
+
success = get_tags_from_teddycloud(teddycloud_url, args.ignore_ssl_verify)
|
151
|
+
sys.exit(0 if success else 1)
|
152
|
+
|
153
|
+
# Handle upload to TeddyCloud if requested
|
154
|
+
if args.upload:
|
155
|
+
teddycloud_url = args.upload
|
156
|
+
|
157
|
+
if not args.input_filename:
|
158
|
+
logger.error("Missing input file for --upload. Provide a file path as SOURCE argument.")
|
159
|
+
sys.exit(1)
|
160
|
+
|
161
|
+
# Check if the input file is already a .taf file or an image file
|
162
|
+
if os.path.exists(args.input_filename) and (args.input_filename.lower().endswith('.taf') or
|
163
|
+
args.input_filename.lower().endswith(('.jpg', '.jpeg', '.png'))):
|
164
|
+
# Direct upload of existing TAF or image file
|
165
|
+
# Use get_file_paths to handle Windows backslashes and resolve the paths correctly
|
166
|
+
file_paths = get_file_paths(args.input_filename)
|
167
|
+
|
168
|
+
if not file_paths:
|
169
|
+
logger.error("No files found for pattern %s", args.input_filename)
|
170
|
+
sys.exit(1)
|
171
|
+
|
172
|
+
logger.info("Found %d file(s) to upload to TeddyCloud %s", len(file_paths), teddycloud_url)
|
173
|
+
|
174
|
+
for file_path in file_paths:
|
175
|
+
# Only upload supported file types
|
176
|
+
if not file_path.lower().endswith(('.taf', '.jpg', '.jpeg', '.png')):
|
177
|
+
logger.warning("Skipping unsupported file type: %s", file_path)
|
178
|
+
continue
|
179
|
+
|
180
|
+
logger.info("Uploading %s to TeddyCloud %s", file_path, teddycloud_url)
|
181
|
+
upload_success = upload_to_teddycloud(
|
182
|
+
file_path, teddycloud_url, args.ignore_ssl_verify,
|
183
|
+
args.special_folder, args.path, args.show_progress,
|
184
|
+
args.connection_timeout, args.read_timeout,
|
185
|
+
args.max_retries, args.retry_delay
|
186
|
+
)
|
187
|
+
|
188
|
+
if not upload_success:
|
189
|
+
logger.error("Failed to upload %s to TeddyCloud", file_path)
|
190
|
+
sys.exit(1)
|
191
|
+
else:
|
192
|
+
logger.info("Successfully uploaded %s to TeddyCloud", file_path)
|
193
|
+
|
194
|
+
sys.exit(0)
|
195
|
+
|
196
|
+
# If we get here, it's not a TAF or image file, so continue with normal processing
|
197
|
+
# which will convert the input files and upload the result later
|
198
|
+
pass
|
199
|
+
|
114
200
|
ffmpeg_binary = args.ffmpeg
|
115
201
|
if ffmpeg_binary is None:
|
116
202
|
ffmpeg_binary = get_ffmpeg_binary(args.auto_download)
|
@@ -156,6 +242,7 @@ def main():
|
|
156
242
|
os.makedirs(output_dir, exist_ok=True)
|
157
243
|
logger.debug("Created output directory: %s", output_dir)
|
158
244
|
|
245
|
+
created_files = []
|
159
246
|
for task_index, (output_name, folder_path, audio_files) in enumerate(process_tasks):
|
160
247
|
if args.output_to_source:
|
161
248
|
task_out_filename = os.path.join(folder_path, f"{output_name}.taf")
|
@@ -169,8 +256,102 @@ def main():
|
|
169
256
|
args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp,
|
170
257
|
args.auto_download, not args.use_legacy_tags)
|
171
258
|
logger.info("Successfully created Tonie file: %s", task_out_filename)
|
259
|
+
created_files.append(task_out_filename)
|
172
260
|
|
173
261
|
logger.info("Recursive processing completed. Created %d Tonie files.", len(process_tasks))
|
262
|
+
|
263
|
+
# Handle upload to TeddyCloud if requested
|
264
|
+
if args.upload and created_files:
|
265
|
+
teddycloud_url = args.upload
|
266
|
+
|
267
|
+
for taf_file in created_files:
|
268
|
+
upload_success = upload_to_teddycloud(
|
269
|
+
taf_file, teddycloud_url, args.ignore_ssl_verify,
|
270
|
+
args.special_folder, args.path, args.show_progress,
|
271
|
+
args.connection_timeout, args.read_timeout,
|
272
|
+
args.max_retries, args.retry_delay
|
273
|
+
)
|
274
|
+
|
275
|
+
if not upload_success:
|
276
|
+
logger.error("Failed to upload %s to TeddyCloud", taf_file)
|
277
|
+
else:
|
278
|
+
logger.info("Successfully uploaded %s to TeddyCloud", taf_file)
|
279
|
+
|
280
|
+
# Handle artwork upload if requested
|
281
|
+
if args.include_artwork:
|
282
|
+
# Extract folder path from the current task
|
283
|
+
folder_path = os.path.dirname(taf_file)
|
284
|
+
taf_file_basename = os.path.basename(taf_file)
|
285
|
+
taf_name = os.path.splitext(taf_file_basename)[0] # Get name without extension
|
286
|
+
logger.info("Looking for artwork for %s", folder_path)
|
287
|
+
|
288
|
+
# Try to find cover image in the folder
|
289
|
+
from .media_tags import find_cover_image
|
290
|
+
artwork_path = find_cover_image(folder_path)
|
291
|
+
temp_artwork = None
|
292
|
+
|
293
|
+
# If no cover image found, try to extract it from one of the audio files
|
294
|
+
if not artwork_path:
|
295
|
+
# Get current task's audio files
|
296
|
+
for task_name, task_folder, task_files in process_tasks:
|
297
|
+
if task_folder == folder_path or os.path.normpath(task_folder) == os.path.normpath(folder_path):
|
298
|
+
if task_files and len(task_files) > 0:
|
299
|
+
# Try to extract from first file
|
300
|
+
from .media_tags import extract_artwork, ensure_mutagen
|
301
|
+
if ensure_mutagen(auto_install=args.auto_download):
|
302
|
+
temp_artwork = extract_artwork(task_files[0])
|
303
|
+
if temp_artwork:
|
304
|
+
artwork_path = temp_artwork
|
305
|
+
break
|
306
|
+
|
307
|
+
if artwork_path:
|
308
|
+
logger.info("Found artwork for %s: %s", folder_path, artwork_path)
|
309
|
+
artwork_upload_path = "/custom_img"
|
310
|
+
artwork_ext = os.path.splitext(artwork_path)[1]
|
311
|
+
|
312
|
+
# Create a temporary copy with the same name as the taf file
|
313
|
+
import shutil
|
314
|
+
renamed_artwork_path = None
|
315
|
+
try:
|
316
|
+
renamed_artwork_path = os.path.join(os.path.dirname(artwork_path),
|
317
|
+
f"{taf_name}{artwork_ext}")
|
318
|
+
|
319
|
+
if renamed_artwork_path != artwork_path:
|
320
|
+
shutil.copy2(artwork_path, renamed_artwork_path)
|
321
|
+
logger.debug("Created renamed artwork copy: %s", renamed_artwork_path)
|
322
|
+
|
323
|
+
logger.info("Uploading artwork to path: %s as %s%s",
|
324
|
+
artwork_upload_path, taf_name, artwork_ext)
|
325
|
+
|
326
|
+
artwork_upload_success = upload_to_teddycloud(
|
327
|
+
renamed_artwork_path, teddycloud_url, args.ignore_ssl_verify,
|
328
|
+
args.special_folder, artwork_upload_path, args.show_progress,
|
329
|
+
args.connection_timeout, args.read_timeout,
|
330
|
+
args.max_retries, args.retry_delay
|
331
|
+
)
|
332
|
+
|
333
|
+
if artwork_upload_success:
|
334
|
+
logger.info("Successfully uploaded artwork for %s", folder_path)
|
335
|
+
else:
|
336
|
+
logger.warning("Failed to upload artwork for %s", folder_path)
|
337
|
+
|
338
|
+
if renamed_artwork_path != artwork_path and os.path.exists(renamed_artwork_path):
|
339
|
+
try:
|
340
|
+
os.unlink(renamed_artwork_path)
|
341
|
+
logger.debug("Removed temporary renamed artwork file: %s", renamed_artwork_path)
|
342
|
+
except Exception as e:
|
343
|
+
logger.debug("Failed to remove temporary renamed artwork file: %s", e)
|
344
|
+
|
345
|
+
if temp_artwork and os.path.exists(temp_artwork) and temp_artwork != renamed_artwork_path:
|
346
|
+
try:
|
347
|
+
os.unlink(temp_artwork)
|
348
|
+
logger.debug("Removed temporary artwork file: %s", temp_artwork)
|
349
|
+
except Exception as e:
|
350
|
+
logger.debug("Failed to remove temporary artwork file: %s", e)
|
351
|
+
except Exception as e:
|
352
|
+
logger.error("Error during artwork renaming or upload: %s", e)
|
353
|
+
else:
|
354
|
+
logger.warning("No artwork found for %s", folder_path)
|
174
355
|
sys.exit(0)
|
175
356
|
|
176
357
|
# Handle directory or file input
|
@@ -214,7 +395,6 @@ def main():
|
|
214
395
|
print(f"{tag_name}: {tag_value}")
|
215
396
|
else:
|
216
397
|
print(f"\nFile {file_index + 1}: {os.path.basename(file_path)} - No tags found")
|
217
|
-
|
218
398
|
sys.exit(0)
|
219
399
|
|
220
400
|
# Use media tags for file naming if requested
|
@@ -324,13 +504,115 @@ def main():
|
|
324
504
|
|
325
505
|
if not out_filename.lower().endswith('.taf'):
|
326
506
|
out_filename += '.taf'
|
327
|
-
|
507
|
+
|
328
508
|
logger.info("Creating Tonie file: %s with %d input file(s)", out_filename, len(files))
|
329
509
|
create_tonie_file(out_filename, files, args.no_tonie_header, args.user_timestamp,
|
330
510
|
args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp,
|
331
511
|
args.auto_download, not args.use_legacy_tags)
|
332
512
|
logger.info("Successfully created Tonie file: %s", out_filename)
|
333
|
-
|
513
|
+
|
514
|
+
# Handle upload to TeddyCloud if requested
|
515
|
+
if args.upload:
|
516
|
+
teddycloud_url = args.upload
|
517
|
+
|
518
|
+
upload_success = upload_to_teddycloud(
|
519
|
+
out_filename, teddycloud_url, args.ignore_ssl_verify,
|
520
|
+
args.special_folder, args.path, args.show_progress,
|
521
|
+
args.connection_timeout, args.read_timeout,
|
522
|
+
args.max_retries, args.retry_delay
|
523
|
+
)
|
524
|
+
if not upload_success:
|
525
|
+
logger.error("Failed to upload %s to TeddyCloud", out_filename)
|
526
|
+
sys.exit(1)
|
527
|
+
else:
|
528
|
+
logger.info("Successfully uploaded %s to TeddyCloud", out_filename)
|
529
|
+
|
530
|
+
# Handle artwork upload if requested
|
531
|
+
if args.include_artwork:
|
532
|
+
logger.info("Looking for artwork to upload alongside the Tonie file")
|
533
|
+
artwork_path = None
|
534
|
+
|
535
|
+
# Try to find a cover image in the source directory first
|
536
|
+
source_dir = os.path.dirname(files[0]) if files else None
|
537
|
+
if source_dir:
|
538
|
+
from .media_tags import find_cover_image
|
539
|
+
artwork_path = find_cover_image(source_dir)
|
540
|
+
|
541
|
+
# If no cover in source directory, try to extract it from audio file
|
542
|
+
if not artwork_path and len(files) > 0:
|
543
|
+
from .media_tags import extract_artwork, ensure_mutagen
|
544
|
+
|
545
|
+
# Make sure mutagen is available for artwork extraction
|
546
|
+
if ensure_mutagen(auto_install=args.auto_download):
|
547
|
+
# Try to extract artwork from the first file
|
548
|
+
temp_artwork = extract_artwork(files[0])
|
549
|
+
if temp_artwork:
|
550
|
+
artwork_path = temp_artwork
|
551
|
+
# Note: this creates a temporary file that will be deleted after upload
|
552
|
+
|
553
|
+
# Upload the artwork if found
|
554
|
+
if artwork_path:
|
555
|
+
logger.info("Found artwork: %s", artwork_path)
|
556
|
+
|
557
|
+
# Create artwork upload path - keep same path but use "custom_img" folder
|
558
|
+
artwork_upload_path = args.path
|
559
|
+
if not artwork_upload_path:
|
560
|
+
artwork_upload_path = "/custom_img"
|
561
|
+
elif not artwork_upload_path.startswith("/custom_img"):
|
562
|
+
# Make sure we're using the custom_img folder
|
563
|
+
if artwork_upload_path.startswith("/"):
|
564
|
+
artwork_upload_path = "/custom_img" + artwork_upload_path
|
565
|
+
else:
|
566
|
+
artwork_upload_path = "/custom_img/" + artwork_upload_path
|
567
|
+
|
568
|
+
# Get the original artwork file extension
|
569
|
+
artwork_ext = os.path.splitext(artwork_path)[1]
|
570
|
+
|
571
|
+
# Create a temporary copy with the same name as the taf file
|
572
|
+
import shutil
|
573
|
+
renamed_artwork_path = None
|
574
|
+
try:
|
575
|
+
renamed_artwork_path = os.path.join(os.path.dirname(artwork_path),
|
576
|
+
f"{os.path.splitext(os.path.basename(out_filename))[0]}{artwork_ext}")
|
577
|
+
|
578
|
+
if renamed_artwork_path != artwork_path:
|
579
|
+
shutil.copy2(artwork_path, renamed_artwork_path)
|
580
|
+
logger.debug("Created renamed artwork copy: %s", renamed_artwork_path)
|
581
|
+
|
582
|
+
logger.info("Uploading artwork to path: %s as %s%s",
|
583
|
+
artwork_upload_path, os.path.splitext(os.path.basename(out_filename))[0], artwork_ext)
|
584
|
+
|
585
|
+
artwork_upload_success = upload_to_teddycloud(
|
586
|
+
renamed_artwork_path, teddycloud_url, args.ignore_ssl_verify,
|
587
|
+
args.special_folder, artwork_upload_path, args.show_progress,
|
588
|
+
args.connection_timeout, args.read_timeout,
|
589
|
+
args.max_retries, args.retry_delay
|
590
|
+
)
|
591
|
+
|
592
|
+
if artwork_upload_success:
|
593
|
+
logger.info("Successfully uploaded artwork")
|
594
|
+
else:
|
595
|
+
logger.warning("Failed to upload artwork")
|
596
|
+
|
597
|
+
# Clean up temporary renamed file
|
598
|
+
if renamed_artwork_path != artwork_path and os.path.exists(renamed_artwork_path):
|
599
|
+
try:
|
600
|
+
os.unlink(renamed_artwork_path)
|
601
|
+
logger.debug("Removed temporary renamed artwork file: %s", renamed_artwork_path)
|
602
|
+
except Exception as e:
|
603
|
+
logger.debug("Failed to remove temporary renamed artwork file: %s", e)
|
604
|
+
|
605
|
+
# Clean up temporary extracted artwork file if needed
|
606
|
+
if temp_artwork and os.path.exists(temp_artwork) and temp_artwork != renamed_artwork_path:
|
607
|
+
try:
|
608
|
+
os.unlink(temp_artwork)
|
609
|
+
logger.debug("Removed temporary artwork file: %s", temp_artwork)
|
610
|
+
except Exception as e:
|
611
|
+
logger.debug("Failed to remove temporary artwork file: %s", e)
|
612
|
+
except Exception as e:
|
613
|
+
logger.error("Error during artwork renaming or upload: %s", e)
|
614
|
+
else:
|
615
|
+
logger.warning("No artwork found to upload")
|
334
616
|
|
335
617
|
if __name__ == "__main__":
|
336
618
|
main()
|
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
528
|
+
logger.debug("Using previously downloaded %s: %s", dependency_name, existing_binary)
|
529
529
|
return existing_binary
|
530
530
|
except:
|
531
531
|
pass
|
TonieToolbox/media_tags.py
CHANGED
@@ -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
|
|
@@ -468,4 +471,167 @@ def format_metadata_filename(metadata: Dict[str, str], template: str = "{tracknu
|
|
468
471
|
return result
|
469
472
|
except Exception as e:
|
470
473
|
logger.error("Error formatting metadata: %s", str(e))
|
471
|
-
return ""
|
474
|
+
return ""
|
475
|
+
|
476
|
+
def extract_artwork(file_path: str, output_path: Optional[str] = None) -> Optional[str]:
|
477
|
+
"""
|
478
|
+
Extract artwork from an audio file.
|
479
|
+
|
480
|
+
Args:
|
481
|
+
file_path: Path to the audio file
|
482
|
+
output_path: Path where to save the extracted artwork.
|
483
|
+
If None, a temporary file will be created.
|
484
|
+
|
485
|
+
Returns:
|
486
|
+
Path to the extracted artwork file, or None if no artwork was found
|
487
|
+
"""
|
488
|
+
if not MUTAGEN_AVAILABLE:
|
489
|
+
logger.debug("Mutagen not available - cannot extract artwork")
|
490
|
+
return None
|
491
|
+
|
492
|
+
if not os.path.exists(file_path):
|
493
|
+
logger.error("File not found: %s", file_path)
|
494
|
+
return None
|
495
|
+
|
496
|
+
try:
|
497
|
+
file_ext = os.path.splitext(file_path.lower())[1]
|
498
|
+
artwork_data = None
|
499
|
+
mime_type = None
|
500
|
+
|
501
|
+
# Extract artwork based on file type
|
502
|
+
if file_ext == '.mp3':
|
503
|
+
audio = mutagen.File(file_path)
|
504
|
+
|
505
|
+
# Try to get artwork from APIC frames
|
506
|
+
if audio.tags:
|
507
|
+
for frame in audio.tags.values():
|
508
|
+
if frame.FrameID == 'APIC':
|
509
|
+
artwork_data = frame.data
|
510
|
+
mime_type = frame.mime
|
511
|
+
break
|
512
|
+
|
513
|
+
elif file_ext == '.flac':
|
514
|
+
audio = FLAC(file_path)
|
515
|
+
|
516
|
+
# Get pictures from FLAC
|
517
|
+
if audio.pictures:
|
518
|
+
artwork_data = audio.pictures[0].data
|
519
|
+
mime_type = audio.pictures[0].mime
|
520
|
+
|
521
|
+
elif file_ext in ['.m4a', '.mp4', '.aac']:
|
522
|
+
audio = MP4(file_path)
|
523
|
+
|
524
|
+
# Check 'covr' atom
|
525
|
+
if 'covr' in audio:
|
526
|
+
artwork_data = audio['covr'][0]
|
527
|
+
# Determine mime type based on data format
|
528
|
+
if isinstance(artwork_data, mutagen.mp4.MP4Cover):
|
529
|
+
if artwork_data.format == mutagen.mp4.MP4Cover.FORMAT_JPEG:
|
530
|
+
mime_type = 'image/jpeg'
|
531
|
+
elif artwork_data.format == mutagen.mp4.MP4Cover.FORMAT_PNG:
|
532
|
+
mime_type = 'image/png'
|
533
|
+
else:
|
534
|
+
mime_type = 'image/jpeg' # Default guess
|
535
|
+
|
536
|
+
elif file_ext == '.ogg':
|
537
|
+
try:
|
538
|
+
audio = OggVorbis(file_path)
|
539
|
+
except:
|
540
|
+
try:
|
541
|
+
audio = OggOpus(file_path)
|
542
|
+
except:
|
543
|
+
logger.debug("Could not determine OGG type for %s", file_path)
|
544
|
+
return None
|
545
|
+
|
546
|
+
# For OGG files, metadata pictures are more complex to extract
|
547
|
+
if 'metadata_block_picture' in audio:
|
548
|
+
picture_data = base64.b64decode(audio['metadata_block_picture'][0])
|
549
|
+
flac_picture = Picture(data=picture_data)
|
550
|
+
artwork_data = flac_picture.data
|
551
|
+
mime_type = flac_picture.mime
|
552
|
+
|
553
|
+
# If we found artwork data, save it to a file
|
554
|
+
if artwork_data:
|
555
|
+
# Determine file extension from mime type
|
556
|
+
if mime_type == 'image/jpeg':
|
557
|
+
ext = '.jpg'
|
558
|
+
elif mime_type == 'image/png':
|
559
|
+
ext = '.png'
|
560
|
+
else:
|
561
|
+
ext = '.jpg' # Default to jpg
|
562
|
+
|
563
|
+
# Create output path if not provided
|
564
|
+
if not output_path:
|
565
|
+
# Create a temporary file
|
566
|
+
temp_file = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
|
567
|
+
output_path = temp_file.name
|
568
|
+
temp_file.close()
|
569
|
+
elif not os.path.splitext(output_path)[1]:
|
570
|
+
# Add extension if not in the output path
|
571
|
+
output_path += ext
|
572
|
+
|
573
|
+
# Write artwork to file
|
574
|
+
with open(output_path, 'wb') as f:
|
575
|
+
f.write(artwork_data)
|
576
|
+
|
577
|
+
logger.info("Extracted artwork saved to %s", output_path)
|
578
|
+
return output_path
|
579
|
+
else:
|
580
|
+
logger.debug("No artwork found in file: %s", file_path)
|
581
|
+
return None
|
582
|
+
|
583
|
+
except Exception as e:
|
584
|
+
logger.debug("Error extracting artwork: %s", e)
|
585
|
+
return None
|
586
|
+
|
587
|
+
def find_cover_image(source_dir):
|
588
|
+
"""
|
589
|
+
Find a cover image in the source directory.
|
590
|
+
|
591
|
+
Args:
|
592
|
+
source_dir: Path to the directory to search for cover images
|
593
|
+
|
594
|
+
Returns:
|
595
|
+
str: Path to the found cover image, or None if not found
|
596
|
+
"""
|
597
|
+
if not os.path.isdir(source_dir):
|
598
|
+
return None
|
599
|
+
|
600
|
+
# Common cover image file names
|
601
|
+
cover_names = [
|
602
|
+
'cover', 'folder', 'album', 'front', 'artwork', 'image',
|
603
|
+
'albumart', 'albumartwork', 'booklet'
|
604
|
+
]
|
605
|
+
|
606
|
+
# Common image extensions
|
607
|
+
image_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif']
|
608
|
+
|
609
|
+
# Try different variations
|
610
|
+
for name in cover_names:
|
611
|
+
for ext in image_extensions:
|
612
|
+
# Try exact name match
|
613
|
+
cover_path = os.path.join(source_dir, name + ext)
|
614
|
+
if os.path.exists(cover_path):
|
615
|
+
logger.debug("Found cover image: %s", cover_path)
|
616
|
+
return cover_path
|
617
|
+
|
618
|
+
# Try case-insensitive match
|
619
|
+
for file in os.listdir(source_dir):
|
620
|
+
if file.lower() == (name + ext).lower():
|
621
|
+
cover_path = os.path.join(source_dir, file)
|
622
|
+
logger.debug("Found cover image: %s", cover_path)
|
623
|
+
return cover_path
|
624
|
+
|
625
|
+
# If no exact matches, try finding any file containing the cover names
|
626
|
+
for file in os.listdir(source_dir):
|
627
|
+
file_lower = file.lower()
|
628
|
+
file_ext = os.path.splitext(file_lower)[1]
|
629
|
+
if file_ext in image_extensions:
|
630
|
+
for name in cover_names:
|
631
|
+
if name in file_lower:
|
632
|
+
cover_path = os.path.join(source_dir, file)
|
633
|
+
logger.debug("Found cover image: %s", cover_path)
|
634
|
+
return cover_path
|
635
|
+
|
636
|
+
logger.debug("No cover image found in directory: %s", source_dir)
|
637
|
+
return None
|