TonieToolbox 0.6.1__tar.gz → 0.6.5__tar.gz

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.
Files changed (41) hide show
  1. {tonietoolbox-0.6.1/TonieToolbox.egg-info → tonietoolbox-0.6.5}/PKG-INFO +6 -9
  2. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/__init__.py +1 -1
  3. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/__main__.py +218 -21
  4. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/audio_conversion.py +77 -0
  5. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/dependency_manager.py +60 -0
  6. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/integration.py +37 -1
  7. tonietoolbox-0.6.5/TonieToolbox/integration_kde.py +677 -0
  8. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/integration_windows.py +4 -2
  9. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/media_tags.py +95 -0
  10. tonietoolbox-0.6.5/TonieToolbox/player.py +638 -0
  11. tonietoolbox-0.6.5/TonieToolbox/player_gui.py +1212 -0
  12. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/recursive_processor.py +29 -1
  13. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/teddycloud.py +79 -4
  14. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/tonie_analysis.py +235 -1
  15. tonietoolbox-0.6.5/TonieToolbox/tonie_header_pb2.py +38 -0
  16. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5/TonieToolbox.egg-info}/PKG-INFO +6 -9
  17. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox.egg-info/SOURCES.txt +3 -0
  18. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox.egg-info/requires.txt +2 -2
  19. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/pyproject.toml +6 -9
  20. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/setup.py +7 -10
  21. tonietoolbox-0.6.1/TonieToolbox/tonie_header_pb2.py +0 -99
  22. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/LICENSE.md +0 -0
  23. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/MANIFEST.in +0 -0
  24. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/README.md +0 -0
  25. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/artwork.py +0 -0
  26. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/constants.py +0 -0
  27. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/filename_generator.py +0 -0
  28. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/integration_macos.py +0 -0
  29. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/integration_ubuntu.py +0 -0
  30. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/logger.py +0 -0
  31. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/ogg_page.py +0 -0
  32. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/opus_packet.py +0 -0
  33. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/tags.py +0 -0
  34. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/tonie_file.py +0 -0
  35. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/tonie_header.proto +0 -0
  36. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/tonies_json.py +0 -0
  37. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox/version_handler.py +0 -0
  38. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox.egg-info/dependency_links.txt +0 -0
  39. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox.egg-info/entry_points.txt +0 -0
  40. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/TonieToolbox.egg-info/top_level.txt +0 -0
  41. {tonietoolbox-0.6.1 → tonietoolbox-0.6.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: TonieToolbox
3
- Version: 0.6.1
3
+ Version: 0.6.5
4
4
  Summary: Convert audio files to Toniebox compatible format (.TAF) and interact with TeddyCloud.
5
5
  Home-page: https://github.com/Quentendo64/TonieToolbox
6
6
  Author: Quentendo64
@@ -8,19 +8,16 @@ Author-email: Quentendo64 <quentin@wohlfeil.at>
8
8
  License-Expression: GPL-3.0-or-later
9
9
  Project-URL: Homepage, https://github.com/Quentendo64/TonieToolbox
10
10
  Project-URL: Bug Tracker, https://github.com/Quentendo64/TonieToolbox/issues
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.6
13
- Classifier: Programming Language :: Python :: 3.7
14
- Classifier: Programming Language :: Python :: 3.8
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
17
14
  Classifier: Operating System :: OS Independent
18
15
  Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
19
16
  Requires-Python: >=3.6
20
17
  Description-Content-Type: text/markdown
21
18
  License-File: LICENSE.md
22
- Requires-Dist: protobuf<=3.19.0
23
- Requires-Dist: requests>=2.32.3
19
+ Requires-Dist: protobuf<=6.33.0
20
+ Requires-Dist: requests>=2.32.5
24
21
  Requires-Dist: mutagen>=1.47.0
25
22
  Requires-Dist: packaging>=25.0
26
23
  Requires-Dist: tqdm>=4.67.1
@@ -3,4 +3,4 @@
3
3
  TonieToolbox - Convert audio files to Tonie box compatible format
4
4
  """
5
5
 
6
- __version__ = '0.6.1'
6
+ __version__ = '0.6.5'
@@ -10,19 +10,23 @@ import logging
10
10
  from . import __version__
11
11
  from .audio_conversion import get_input_files, append_to_filename
12
12
  from .tonie_file import create_tonie_file
13
- from .tonie_analysis import check_tonie_file, check_tonie_file_cli, split_to_opus_files, compare_taf_files
13
+ from .tonie_analysis import check_tonie_file, check_tonie_file_cli, split_to_opus_files, compare_taf_files, extract_to_mp3_files, extract_full_audio_to_mp3
14
+ from .player import interactive_player
15
+ from .player_gui import gui_player
14
16
  from .dependency_manager import get_ffmpeg_binary, get_opus_binary, ensure_dependency
15
17
  from .logger import TRACE, setup_logging, get_logger
16
18
  from .filename_generator import guess_output_filename, apply_template_to_path,ensure_directory_exists
17
19
  from .version_handler import check_for_updates, clear_version_cache
18
- from .recursive_processor import process_recursive_folders
19
- from .media_tags import is_available as is_media_tags_available, ensure_mutagen, extract_album_info, format_metadata_filename, get_file_tags
20
+ from .recursive_processor import process_recursive_folders, find_audio_folders, get_all_audio_files_recursive
21
+ from .media_tags import is_available as is_media_tags_available, ensure_mutagen, extract_album_info, format_metadata_filename, get_file_tags, get_all_file_tags
20
22
  from .teddycloud import TeddyCloudClient
21
23
  from .tags import get_tags
22
24
  from .tonies_json import fetch_and_update_tonies_json_v1, fetch_and_update_tonies_json_v2
23
25
  from .artwork import upload_artwork
24
26
  from .integration import handle_integration, handle_config
25
27
 
28
+
29
+
26
30
  def main():
27
31
  """Entry point for the TonieToolbox application."""
28
32
  parser = argparse.ArgumentParser(description='Create Tonie compatible file from Ogg opus file(s).')
@@ -34,6 +38,8 @@ def main():
34
38
  help='Upload to TeddyCloud instance (e.g., https://teddycloud.example.com). Supports .taf, .jpg, .jpeg, .png files.')
35
39
  teddycloud_group.add_argument('--include-artwork', action='store_true',
36
40
  help='Upload cover artwork image alongside the Tonie file when using --upload')
41
+ teddycloud_group.add_argument('--assign-to-tag', action='store_true',
42
+ help='Assign the uploaded file to a specific tag ID')
37
43
  teddycloud_group.add_argument('--get-tags', action='store', metavar='URL',
38
44
  help='Get available tags from TeddyCloud instance')
39
45
  teddycloud_group.add_argument('--ignore-ssl-verify', action='store_true',
@@ -74,17 +80,23 @@ def main():
74
80
  # ------------- Parser - Librarys -------------
75
81
  parser.add_argument('-f', '--ffmpeg', help='specify location of ffmpeg', default=None)
76
82
  parser.add_argument('-o', '--opusenc', help='specify location of opusenc', default=None)
77
- parser.add_argument('-b', '--bitrate', type=int, help='set encoding bitrate in kbps (default: 96)', default=96)
83
+ parser.add_argument('-b', '--bitrate', type=int, help='set encoding bitrate in kbps for Opus & MP3 Conversion (default: 96)', default=96)
78
84
  parser.add_argument('-c', '--cbr', action='store_true', help='encode in cbr mode')
79
85
  parser.add_argument('--auto-download', action='store_true',
80
86
  help='automatically download ffmpeg and opusenc if not found')
81
- # ------------- Parser - TAF -------------
87
+ # ------------- Parser - TAF -------------
82
88
  parser.add_argument('-a', '--append-tonie-tag', metavar='TAG', action='store',
83
89
  help='append [TAG] to filename (must be an 8-character hex value)')
84
90
  parser.add_argument('-n', '--no-tonie-header', action='store_true', help='do not write Tonie header')
85
91
  parser.add_argument('-i', '--info', action='store_true', help='Check and display info about Tonie file')
92
+ parser.add_argument('-p', '--play', action='store_true', help='Play TAF audio file with interactive controls')
93
+ parser.add_argument('--play-ui', action='store_true', help='Play TAF audio file with minimal GUI interface (requires tkinter)')
86
94
  parser.add_argument('-s', '--split', action='store_true', help='Split Tonie file into opus tracks')
87
95
  parser.add_argument('-r', '--recursive', action='store_true', help='Process folders recursively')
96
+ parser.add_argument('--files-to-taf', action='store_true',
97
+ help='Convert each audio file in a directory to individual .taf files')
98
+ parser.add_argument('--convert-to-separate-mp3', action='store_true', help='Convert Tonie file to individual MP3 tracks')
99
+ parser.add_argument('--convert-to-single-mp3', action='store_true', help='Convert Tonie file to a single MP3 file')
88
100
  parser.add_argument('-O', '--output-to-source', action='store_true',
89
101
  help='Save output files in the source directory instead of output directory')
90
102
  parser.add_argument('-fc', '--force-creation', action='store_true', default=False,
@@ -134,9 +146,7 @@ def main():
134
146
  log_level_group.add_argument('-Q', '--silent', action='store_true', help='Show only errors')
135
147
  log_group.add_argument('--log-file', action='store_true', default=False,
136
148
  help='Save logs to a timestamped file in .tonietoolbox folder')
137
- args = parser.parse_args()
138
-
139
- # ------------- Parser - Source Input -------------
149
+ args = parser.parse_args() # ------------- Parser - Source Input -------------
140
150
  if args.input_filename is None and not (args.get_tags or args.upload or args.install_integration or args.uninstall_integration or args.config_integration or args.auto_download):
141
151
  parser.error("the following arguments are required: SOURCE")
142
152
 
@@ -178,17 +188,25 @@ def main():
178
188
  logger.info("Update available but user chose to continue without updating.")
179
189
 
180
190
  # ------------- Autodownload & Dependency Checks -------------
181
- if args.auto_download:
182
- logger.debug("Auto-download requested for ffmpeg and opusenc")
183
- ffmpeg_binary = get_ffmpeg_binary(auto_download=True)
184
- opus_binary = get_opus_binary(auto_download=True)
185
- if ffmpeg_binary and opus_binary:
186
- logger.info("FFmpeg and opusenc downloaded successfully.")
187
- if args.input_filename is None:
188
- sys.exit(0)
189
- else:
190
- logger.error("Failed to download ffmpeg or opusenc. Please install them manually.")
191
+ # ------------- Librarys / Prereqs -------------
192
+ logger.debug("Checking for external dependencies")
193
+ ffmpeg_binary = args.ffmpeg
194
+ if ffmpeg_binary is None:
195
+ logger.debug("No FFmpeg specified, attempting to locate binary (auto_download=%s)", args.auto_download)
196
+ ffmpeg_binary = get_ffmpeg_binary(args.auto_download)
197
+ if ffmpeg_binary is None:
198
+ logger.error("Could not find FFmpeg. Please install FFmpeg or specify its location using --ffmpeg or use --auto-download")
191
199
  sys.exit(1)
200
+ logger.debug("Using FFmpeg binary: %s", ffmpeg_binary)
201
+
202
+ opus_binary = args.opusenc
203
+ if opus_binary is None:
204
+ logger.debug("No opusenc specified, attempting to locate binary (auto_download=%s)", args.auto_download)
205
+ opus_binary = get_opus_binary(args.auto_download)
206
+ if opus_binary is None:
207
+ logger.error("Could not find opusenc. Please install opus-tools or specify its location using --opusenc or use --auto-download")
208
+ sys.exit(1)
209
+ logger.debug("Using opusenc binary: %s", opus_binary)
192
210
 
193
211
  # ------------- Context Menu Integration -------------
194
212
  if args.install_integration or args.uninstall_integration:
@@ -207,10 +225,158 @@ def main():
207
225
  else:
208
226
  logger.error("FFmpeg and opusenc are required for context menu integration")
209
227
  sys.exit(1)
228
+
210
229
  if args.config_integration:
211
230
  logger.debug("Opening configuration file for editing")
212
231
  handle_config()
213
232
  sys.exit(0)
233
+ # ------------- Files to TAF Processing -------------
234
+ if args.files_to_taf:
235
+ if args.recursive:
236
+ logger.info("Processing individual files to separate TAF files recursively: %s", args.input_filename)
237
+ else:
238
+ logger.info("Processing individual files to separate TAF files: %s", args.input_filename)
239
+
240
+ if not os.path.isdir(args.input_filename):
241
+ logger.error("--files-to-taf requires a directory as input")
242
+ sys.exit(1)
243
+
244
+ # Use recursive file discovery if --recursive flag is also specified
245
+ if args.recursive:
246
+ audio_files = get_all_audio_files_recursive(args.input_filename)
247
+ else:
248
+ audio_files = get_input_files(args.input_filename)
249
+
250
+ if not audio_files:
251
+ search_type = "recursively" if args.recursive else "in directory"
252
+ logger.error("No audio files found %s: %s", search_type, args.input_filename)
253
+ sys.exit(1)
254
+
255
+ logger.info("Found %d audio files to convert", len(audio_files))
256
+
257
+ output_dir = args.input_filename if args.output_to_source else './output'
258
+
259
+ if not args.output_to_source and not os.path.exists(output_dir):
260
+ os.makedirs(output_dir, exist_ok=True)
261
+ logger.debug("Created output directory: %s", output_dir)
262
+
263
+ created_files = []
264
+ for file_index, audio_file in enumerate(audio_files):
265
+ # Generate output filename based on the original file
266
+ base_name = os.path.splitext(os.path.basename(audio_file))[0]
267
+
268
+ # Apply media tag naming if requested
269
+ if args.use_media_tags:
270
+ tags = get_file_tags(audio_file)
271
+ if tags:
272
+ template = args.name_template or "{artist} - {title}"
273
+ new_name = format_metadata_filename(tags, template)
274
+ if new_name:
275
+ logger.debug("Using media tags for file naming: %s -> %s", base_name, new_name)
276
+ base_name = new_name
277
+
278
+ # Apply tonie tag if specified
279
+ if args.append_tonie_tag:
280
+ hex_tag = args.append_tonie_tag
281
+ if not all(c in '0123456789abcdefABCDEF' for c in hex_tag) or len(hex_tag) != 8:
282
+ logger.error("TAG must be an 8-character hexadecimal value")
283
+ sys.exit(1)
284
+ base_name = append_to_filename(base_name, hex_tag)
285
+
286
+ output_filename = os.path.join(output_dir, f"{base_name}.taf")
287
+
288
+ # Check if file already exists
289
+ skip_creation = False
290
+ if os.path.exists(output_filename):
291
+ logger.warning("Output file already exists: %s", output_filename)
292
+ valid_taf = check_tonie_file_cli(output_filename)
293
+
294
+ if valid_taf and not args.force_creation:
295
+ logger.warning("Valid Tonie file exists, skipping: %s", output_filename)
296
+ skip_creation = True
297
+ else:
298
+ logger.info("Output file exists but is not valid, proceeding to create new one")
299
+
300
+ logger.info("[%d/%d] Converting: %s -> %s",
301
+ file_index + 1, len(audio_files), os.path.basename(audio_file), os.path.basename(output_filename))
302
+
303
+ if not skip_creation:
304
+ try:
305
+ create_tonie_file(output_filename, [audio_file], args.no_tonie_header, args.user_timestamp,
306
+ args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp,
307
+ args.auto_download, not args.use_legacy_tags,
308
+ no_mono_conversion=args.no_mono_conversion)
309
+ logger.info("Successfully created: %s", output_filename)
310
+ except Exception as e:
311
+ logger.error("Failed to create %s: %s", output_filename, str(e))
312
+ continue
313
+
314
+ created_files.append(output_filename)
315
+
316
+ # Handle upload if requested
317
+ if args.upload:
318
+ upload_path = args.path
319
+ if upload_path and '{' in upload_path and args.use_media_tags:
320
+ metadata = get_file_tags(audio_file)
321
+ if metadata:
322
+ formatted_path = apply_template_to_path(upload_path, metadata)
323
+ if formatted_path:
324
+ logger.info("Using dynamic upload path from template: %s", formatted_path)
325
+ upload_path = formatted_path
326
+ else:
327
+ logger.warning("Could not apply all tags to path template '%s'. Using as-is.", upload_path)
328
+
329
+ # Create directories recursively if path is provided
330
+ if upload_path:
331
+ logger.debug("Creating directory structure on server: %s", upload_path)
332
+ try:
333
+ client.create_directories_recursive(
334
+ path=upload_path,
335
+ special=args.special_folder
336
+ )
337
+ logger.debug("Successfully created directory structure on server")
338
+ except Exception as e:
339
+ logger.warning("Failed to create directory structure on server: %s", str(e))
340
+ logger.debug("Continuing with upload anyway, in case the directory already exists")
341
+
342
+ response = client.upload_file(
343
+ file_path=output_filename,
344
+ destination_path=upload_path,
345
+ special=args.special_folder,
346
+ )
347
+ upload_success = response.get('success', False)
348
+
349
+ if not upload_success:
350
+ logger.error("Failed to upload %s to TeddyCloud", output_filename)
351
+ else:
352
+ logger.info("Successfully uploaded %s to TeddyCloud", output_filename)
353
+
354
+ # Handle artwork upload
355
+ artwork_url = None
356
+ if args.include_artwork:
357
+ success, artwork_url = upload_artwork(client, output_filename, os.path.dirname(audio_file), [audio_file])
358
+ if success:
359
+ logger.info("Successfully uploaded artwork for %s", output_filename)
360
+ else:
361
+ logger.warning("Failed to upload artwork for %s", output_filename)
362
+
363
+ # Handle custom JSON creation
364
+ if args.create_custom_json:
365
+ json_output_dir = args.input_filename if args.output_to_source else output_dir
366
+ client_param = client if 'client' in locals() else None
367
+ if args.version_2:
368
+ logger.debug("Using version 2 of the Tonies JSON format")
369
+ success = fetch_and_update_tonies_json_v2(client_param, output_filename, [audio_file], artwork_url, json_output_dir)
370
+ else:
371
+ success = fetch_and_update_tonies_json_v1(client_param, output_filename, [audio_file], artwork_url, json_output_dir)
372
+ if success:
373
+ logger.info("Successfully updated Tonies JSON for %s", output_filename)
374
+ else:
375
+ logger.warning("Failed to update Tonies JSON for %s", output_filename)
376
+
377
+ logger.info("Files to TAF processing completed. Created %d Tonie files.", len(created_files))
378
+ sys.exit(0)
379
+
214
380
  # ------------- Normalize Path Input -------------
215
381
  if args.input_filename:
216
382
  logger.debug("Original input path: %s", args.input_filename)
@@ -258,7 +424,7 @@ def main():
258
424
  logger.error("No files found for pattern %s", args.input_filename)
259
425
  sys.exit(1)
260
426
  for file_index, file_path in enumerate(files):
261
- tags = get_file_tags(file_path)
427
+ tags = get_all_file_tags(file_path)
262
428
  if tags:
263
429
  print(f"\nFile {file_index + 1}: {os.path.basename(file_path)}")
264
430
  print("-" * 40)
@@ -323,6 +489,21 @@ def main():
323
489
  logger.info("Successfully uploaded %s to TeddyCloud", file_path)
324
490
  logger.debug("Upload response details: %s",
325
491
  {k: v for k, v in response.items() if k != 'success'})
492
+ if args.assign_to_tag:
493
+ tag_id = input("Enter the tag ID to assign the uploaded file: eg. 'E0:04:03:50:11:AA:7E:81': ").strip()
494
+ fileName = os.path.basename(file_path)
495
+ if upload_path:
496
+ libPath = f"lib://{upload_path}/{fileName}"
497
+ else:
498
+ libPath = f"lib://{fileName}"
499
+ logger.info("Assigning uploaded file %s to tag ID: %s", fileName, tag_id)
500
+ logger.debug("Library path for assignment: %s", libPath)
501
+ success = client.assign_tag_path(libPath, tag_id)
502
+ if success:
503
+ logger.info("Successfully assigned tag %s to %s", tag_id, fileName)
504
+ else:
505
+ logger.warning("Failed to assign tag %s to %s", tag_id, fileName)
506
+
326
507
  artwork_url = None
327
508
  if args.include_artwork and file_path.lower().endswith('.taf'):
328
509
  source_dir = os.path.dirname(file_path)
@@ -500,6 +681,22 @@ def main():
500
681
  logger.info("Comparing Tonie files: %s and %s", args.input_filename, args.compare)
501
682
  result = compare_taf_files(args.input_filename, args.compare, args.detailed_compare)
502
683
  sys.exit(0 if result else 1)
684
+ elif args.play:
685
+ logger.info("Playing Tonie file: %s", args.input_filename)
686
+ interactive_player(args.input_filename)
687
+ sys.exit(0)
688
+ elif args.play_ui:
689
+ logger.info("Starting GUI player for Tonie file: %s", args.input_filename)
690
+ gui_player(args.input_filename)
691
+ sys.exit(0)
692
+ elif args.convert_to_separate_mp3:
693
+ logger.info("Converting Tonie file to separate MP3 tracks: %s", args.input_filename)
694
+ extract_to_mp3_files(args.input_filename, args.output_filename, args.bitrate)
695
+ sys.exit(0)
696
+ elif args.convert_to_single_mp3:
697
+ logger.info("Converting Tonie file to single MP3: %s", args.input_filename)
698
+ extract_full_audio_to_mp3(args.input_filename, args.output_filename, args.bitrate)
699
+ sys.exit(0)
503
700
 
504
701
  files = get_input_files(args.input_filename)
505
702
  logger.debug("Found %d files to process", len(files))
@@ -600,7 +797,7 @@ def main():
600
797
  out_filename = formatted_path
601
798
  logger.info("Using template path for output: %s", out_filename)
602
799
  else:
603
- logger.warning("Could not apply template to path. Using default output location.")
800
+ logger.warning("Could not apply template to path. Using default output handling.")
604
801
  # Fall back to default output handling
605
802
  if guessed_name:
606
803
  logger.debug("Using guessed name for output: %s", guessed_name)
@@ -615,7 +812,7 @@ def main():
615
812
  out_filename = os.path.join(output_dir, guessed_name)
616
813
  logger.debug("Using default output location: %s", out_filename)
617
814
  else:
618
- logger.warning("No metadata available to apply to template path. Using default output location.")
815
+ logger.warning("No metadata available to apply to template path. Using default output handling.")
619
816
  # Fall back to default output handling
620
817
  elif guessed_name:
621
818
  logger.debug("Using guessed name for output: %s", guessed_name)
@@ -208,6 +208,83 @@ def get_opus_tempfile(
208
208
  return tmp_file, None
209
209
 
210
210
 
211
+ def convert_opus_to_mp3(
212
+ opus_data: bytes,
213
+ output_path: str,
214
+ ffmpeg_binary: str = None,
215
+ bitrate: int = 128,
216
+ auto_download: bool = False
217
+ ) -> bool:
218
+ """
219
+ Convert Opus audio data to MP3 format using FFmpeg.
220
+
221
+ Args:
222
+ opus_data (bytes): Raw Opus audio data (OGG container with Opus codec)
223
+ output_path (str): Path where to save the MP3 file
224
+ ffmpeg_binary (str | None): Path to the ffmpeg binary. If None, will be auto-detected or downloaded.
225
+ bitrate (int): Bitrate for the MP3 encoding in kbps
226
+ auto_download (bool): Whether to automatically download dependencies if not found
227
+ Returns:
228
+ bool: True if conversion was successful, False otherwise
229
+ """
230
+ logger.trace("Entering convert_opus_to_mp3(output_path=%s, bitrate=%d, auto_download=%s)",
231
+ output_path, bitrate, auto_download)
232
+
233
+ logger.debug("Converting Opus data to MP3 format (bitrate: %d kbps)", bitrate)
234
+
235
+ if ffmpeg_binary is None:
236
+ logger.debug("FFmpeg not specified, attempting to auto-detect")
237
+ ffmpeg_binary = get_ffmpeg_binary(auto_download)
238
+ if ffmpeg_binary is None:
239
+ logger.error("Could not find FFmpeg binary. Use --auto-download to enable automatic installation")
240
+ raise RuntimeError("Could not find FFmpeg binary. Use --auto-download to enable automatic installation")
241
+ logger.debug("Found FFmpeg at: %s", ffmpeg_binary)
242
+
243
+ try:
244
+ logger.debug("Starting FFmpeg process for Opus to MP3 conversion")
245
+ ffmpeg_cmd = [
246
+ ffmpeg_binary, "-hide_banner", "-loglevel", "warning",
247
+ "-i", "-", # Read from stdin
248
+ "-acodec", "libmp3lame", # Use LAME MP3 encoder
249
+ "-b:a", f"{bitrate}k", # Set bitrate
250
+ "-y", # Overwrite output file
251
+ output_path
252
+ ]
253
+ logger.trace("FFmpeg command: %s", ffmpeg_cmd)
254
+
255
+ ffmpeg_process = subprocess.Popen(
256
+ ffmpeg_cmd,
257
+ stdin=subprocess.PIPE,
258
+ stdout=subprocess.PIPE,
259
+ stderr=subprocess.PIPE
260
+ )
261
+
262
+ # Write Opus data to FFmpeg stdin
263
+ stdout, stderr = ffmpeg_process.communicate(input=opus_data)
264
+
265
+ logger.debug("FFmpeg process completed with return code: %d", ffmpeg_process.returncode)
266
+
267
+ if ffmpeg_process.returncode != 0:
268
+ logger.error("FFmpeg conversion failed with return code %d", ffmpeg_process.returncode)
269
+ if stderr:
270
+ logger.error("FFmpeg error output: %s", stderr.decode('utf-8', errors='replace'))
271
+ return False
272
+
273
+ # Verify the output file was created
274
+ if os.path.exists(output_path) and os.path.getsize(output_path) > 0:
275
+ logger.debug("Successfully created MP3 file: %s (size: %d bytes)",
276
+ output_path, os.path.getsize(output_path))
277
+ logger.trace("Exiting convert_opus_to_mp3() with success")
278
+ return True
279
+ else:
280
+ logger.error("MP3 file was not created or is empty: %s", output_path)
281
+ return False
282
+
283
+ except Exception as e:
284
+ logger.error("Error during Opus to MP3 conversion: %s", str(e))
285
+ return False
286
+
287
+
211
288
  def filter_directories(glob_list: list[str]) -> list[str]:
212
289
  """
213
290
  Filter a list of glob results to include only audio files that can be handled by ffmpeg.
@@ -945,6 +945,66 @@ def get_ffmpeg_binary(auto_download=False):
945
945
  logger.warning("FFmpeg is not available and --auto-download is not used.")
946
946
  return None
947
947
 
948
+ def get_ffplay_binary(auto_download=False):
949
+ """
950
+ Get the path to the FFplay binary, downloading it if necessary and allowed.
951
+ FFplay is typically included with FFmpeg installations.
952
+
953
+ Args:
954
+ auto_download (bool): Whether to automatically download FFplay if not found (defaults to False)
955
+
956
+ Returns:
957
+ str: Path to the FFplay binary, or None if not available
958
+ """
959
+ logger.debug("Getting FFplay binary")
960
+
961
+ # Define the expected binary path
962
+ local_dir = os.path.join(get_user_data_dir(), 'libs', 'ffmpeg')
963
+ if sys.platform == 'win32':
964
+ binary_path = os.path.join(local_dir, 'ffplay.exe')
965
+ else:
966
+ binary_path = os.path.join(local_dir, 'ffplay')
967
+
968
+ # Check if binary exists
969
+ if os.path.exists(binary_path) and os.path.isfile(binary_path):
970
+ logger.debug("FFplay binary found at %s", binary_path)
971
+ return binary_path
972
+
973
+ # Check if a system-wide FFplay is available
974
+ try:
975
+ if sys.platform == 'win32':
976
+ # On Windows, look for ffplay in PATH
977
+ from shutil import which
978
+ system_binary = which('ffplay')
979
+ if system_binary:
980
+ logger.debug("System-wide FFplay found at %s", system_binary)
981
+ return system_binary
982
+ else:
983
+ # On Unix-like systems, use 'which' command
984
+ system_binary = subprocess.check_output(['which', 'ffplay']).decode('utf-8').strip()
985
+ if system_binary:
986
+ logger.debug("System-wide FFplay found at %s", system_binary)
987
+ return system_binary
988
+ except (subprocess.SubprocessError, FileNotFoundError):
989
+ logger.debug("No system-wide FFplay found")
990
+
991
+ # If FFplay is not found but auto_download is enabled, try to get FFmpeg first
992
+ # since FFplay is usually bundled with FFmpeg
993
+ if auto_download:
994
+ logger.info("FFplay not found, attempting to get FFmpeg (which includes FFplay)")
995
+ ffmpeg_path = get_ffmpeg_binary(auto_download=True)
996
+ if ffmpeg_path:
997
+ # Check if ffplay was included in the FFmpeg download
998
+ if os.path.exists(binary_path) and os.path.isfile(binary_path):
999
+ logger.info("FFplay found after FFmpeg download: %s", binary_path)
1000
+ return binary_path
1001
+
1002
+ logger.warning("FFplay not available even after FFmpeg download")
1003
+ return None
1004
+ else:
1005
+ logger.warning("FFplay is not available and --auto-download is not used.")
1006
+ return None
1007
+
948
1008
  def get_opus_binary(auto_download=False):
949
1009
  """
950
1010
  Get the path to the Opus binary, downloading it if necessary and allowed.
@@ -46,7 +46,31 @@ def handle_integration(args):
46
46
  elif platform.system() == 'Darwin':
47
47
  raise NotImplementedError("Context menu integration is not supported on MacOS YET. But Soon™")
48
48
  elif platform.system() == 'Linux':
49
- raise NotImplementedError("Context menu integration is not supported on Linux YET. But Soon™")
49
+ # Check if we're running in KDE
50
+ kde_session = os.environ.get('KDE_SESSION_VERSION') or os.environ.get('KDE_FULL_SESSION')
51
+ desktop_session = os.environ.get('DESKTOP_SESSION', '').lower()
52
+ xdg_current_desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').lower()
53
+
54
+ if kde_session or 'kde' in desktop_session or 'kde' in xdg_current_desktop:
55
+ from .integration_kde import KDEServiceMenuIntegration as ContextMenuIntegration
56
+ if args.install_integration:
57
+ success = ContextMenuIntegration.install()
58
+ if success:
59
+ logger.info("Integration installed successfully.")
60
+ return True
61
+ else:
62
+ logger.error("Integration installation failed.")
63
+ return False
64
+ elif args.uninstall_integration:
65
+ success = ContextMenuIntegration.uninstall()
66
+ if success:
67
+ logger.info("Integration uninstalled successfully.")
68
+ return True
69
+ else:
70
+ logger.error("Integration uninstallation failed.")
71
+ return False
72
+ else:
73
+ raise NotImplementedError("Context menu integration is currently only supported on KDE. Other Linux desktop environments are not supported yet.")
50
74
  else:
51
75
  raise NotImplementedError(f"Context menu integration is not supported on this OS: {platform.system()}")
52
76
 
@@ -68,6 +92,18 @@ def handle_config():
68
92
  context_menu._apply_config_template()
69
93
  subprocess.call(["open", config_path])
70
94
  elif platform.system() == "Linux":
95
+ kde_session = os.environ.get('KDE_SESSION_VERSION') or os.environ.get('KDE_FULL_SESSION')
96
+ desktop_session = os.environ.get('DESKTOP_SESSION', '').lower()
97
+ xdg_current_desktop = os.environ.get('XDG_CURRENT_DESKTOP', '').lower()
98
+
99
+ if kde_session or 'kde' in desktop_session or 'kde' in xdg_current_desktop:
100
+ try:
101
+ from .integration_kde import KDEServiceMenuIntegration as ContextMenuIntegration
102
+ context_menu = ContextMenuIntegration()
103
+ context_menu._apply_config_template()
104
+ except Exception as e:
105
+ logger.warning(f"Could not create config template: {e}")
106
+
71
107
  subprocess.call(["xdg-open", config_path])
72
108
  else:
73
109
  logger.error(f"Unsupported OS: {platform.system()}")