TonieToolbox 0.6.1__py3-none-any.whl → 0.6.4__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 CHANGED
@@ -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.4'
TonieToolbox/__main__.py CHANGED
@@ -10,19 +10,22 @@ 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
14
15
  from .dependency_manager import get_ffmpeg_binary, get_opus_binary, ensure_dependency
15
16
  from .logger import TRACE, setup_logging, get_logger
16
17
  from .filename_generator import guess_output_filename, apply_template_to_path,ensure_directory_exists
17
18
  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
19
+ from .recursive_processor import process_recursive_folders, find_audio_folders, get_all_audio_files_recursive
20
+ 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
21
  from .teddycloud import TeddyCloudClient
21
22
  from .tags import get_tags
22
23
  from .tonies_json import fetch_and_update_tonies_json_v1, fetch_and_update_tonies_json_v2
23
24
  from .artwork import upload_artwork
24
25
  from .integration import handle_integration, handle_config
25
26
 
27
+
28
+
26
29
  def main():
27
30
  """Entry point for the TonieToolbox application."""
28
31
  parser = argparse.ArgumentParser(description='Create Tonie compatible file from Ogg opus file(s).')
@@ -34,6 +37,8 @@ def main():
34
37
  help='Upload to TeddyCloud instance (e.g., https://teddycloud.example.com). Supports .taf, .jpg, .jpeg, .png files.')
35
38
  teddycloud_group.add_argument('--include-artwork', action='store_true',
36
39
  help='Upload cover artwork image alongside the Tonie file when using --upload')
40
+ teddycloud_group.add_argument('--assign-to-tag', action='store_true',
41
+ help='Assign the uploaded file to a specific tag ID')
37
42
  teddycloud_group.add_argument('--get-tags', action='store', metavar='URL',
38
43
  help='Get available tags from TeddyCloud instance')
39
44
  teddycloud_group.add_argument('--ignore-ssl-verify', action='store_true',
@@ -74,17 +79,23 @@ def main():
74
79
  # ------------- Parser - Librarys -------------
75
80
  parser.add_argument('-f', '--ffmpeg', help='specify location of ffmpeg', default=None)
76
81
  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)
82
+ parser.add_argument('-b', '--bitrate', type=int, help='set encoding bitrate in kbps for Opus & MP3 Conversion (default: 96)', default=96)
78
83
  parser.add_argument('-c', '--cbr', action='store_true', help='encode in cbr mode')
79
84
  parser.add_argument('--auto-download', action='store_true',
80
85
  help='automatically download ffmpeg and opusenc if not found')
81
- # ------------- Parser - TAF -------------
86
+ # ------------- Parser - TAF -------------
82
87
  parser.add_argument('-a', '--append-tonie-tag', metavar='TAG', action='store',
83
88
  help='append [TAG] to filename (must be an 8-character hex value)')
84
89
  parser.add_argument('-n', '--no-tonie-header', action='store_true', help='do not write Tonie header')
85
90
  parser.add_argument('-i', '--info', action='store_true', help='Check and display info about Tonie file')
91
+ parser.add_argument('-p', '--play', action='store_true', help='Play TAF audio file with interactive controls')
92
+ parser.add_argument('--play-ui', action='store_true', help='Play TAF audio file with user interface (future implementation)')
86
93
  parser.add_argument('-s', '--split', action='store_true', help='Split Tonie file into opus tracks')
87
94
  parser.add_argument('-r', '--recursive', action='store_true', help='Process folders recursively')
95
+ parser.add_argument('--files-to-taf', action='store_true',
96
+ help='Convert each audio file in a directory to individual .taf files')
97
+ parser.add_argument('--convert-to-separate-mp3', action='store_true', help='Convert Tonie file to individual MP3 tracks')
98
+ parser.add_argument('--convert-to-single-mp3', action='store_true', help='Convert Tonie file to a single MP3 file')
88
99
  parser.add_argument('-O', '--output-to-source', action='store_true',
89
100
  help='Save output files in the source directory instead of output directory')
90
101
  parser.add_argument('-fc', '--force-creation', action='store_true', default=False,
@@ -134,9 +145,7 @@ def main():
134
145
  log_level_group.add_argument('-Q', '--silent', action='store_true', help='Show only errors')
135
146
  log_group.add_argument('--log-file', action='store_true', default=False,
136
147
  help='Save logs to a timestamped file in .tonietoolbox folder')
137
- args = parser.parse_args()
138
-
139
- # ------------- Parser - Source Input -------------
148
+ args = parser.parse_args() # ------------- Parser - Source Input -------------
140
149
  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
150
  parser.error("the following arguments are required: SOURCE")
142
151
 
@@ -178,17 +187,25 @@ def main():
178
187
  logger.info("Update available but user chose to continue without updating.")
179
188
 
180
189
  # ------------- 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.")
190
+ # ------------- Librarys / Prereqs -------------
191
+ logger.debug("Checking for external dependencies")
192
+ ffmpeg_binary = args.ffmpeg
193
+ if ffmpeg_binary is None:
194
+ logger.debug("No FFmpeg specified, attempting to locate binary (auto_download=%s)", args.auto_download)
195
+ ffmpeg_binary = get_ffmpeg_binary(args.auto_download)
196
+ if ffmpeg_binary is None:
197
+ logger.error("Could not find FFmpeg. Please install FFmpeg or specify its location using --ffmpeg or use --auto-download")
191
198
  sys.exit(1)
199
+ logger.debug("Using FFmpeg binary: %s", ffmpeg_binary)
200
+
201
+ opus_binary = args.opusenc
202
+ if opus_binary is None:
203
+ logger.debug("No opusenc specified, attempting to locate binary (auto_download=%s)", args.auto_download)
204
+ opus_binary = get_opus_binary(args.auto_download)
205
+ if opus_binary is None:
206
+ logger.error("Could not find opusenc. Please install opus-tools or specify its location using --opusenc or use --auto-download")
207
+ sys.exit(1)
208
+ logger.debug("Using opusenc binary: %s", opus_binary)
192
209
 
193
210
  # ------------- Context Menu Integration -------------
194
211
  if args.install_integration or args.uninstall_integration:
@@ -207,10 +224,158 @@ def main():
207
224
  else:
208
225
  logger.error("FFmpeg and opusenc are required for context menu integration")
209
226
  sys.exit(1)
227
+
210
228
  if args.config_integration:
211
229
  logger.debug("Opening configuration file for editing")
212
230
  handle_config()
213
231
  sys.exit(0)
232
+ # ------------- Files to TAF Processing -------------
233
+ if args.files_to_taf:
234
+ if args.recursive:
235
+ logger.info("Processing individual files to separate TAF files recursively: %s", args.input_filename)
236
+ else:
237
+ logger.info("Processing individual files to separate TAF files: %s", args.input_filename)
238
+
239
+ if not os.path.isdir(args.input_filename):
240
+ logger.error("--files-to-taf requires a directory as input")
241
+ sys.exit(1)
242
+
243
+ # Use recursive file discovery if --recursive flag is also specified
244
+ if args.recursive:
245
+ audio_files = get_all_audio_files_recursive(args.input_filename)
246
+ else:
247
+ audio_files = get_input_files(args.input_filename)
248
+
249
+ if not audio_files:
250
+ search_type = "recursively" if args.recursive else "in directory"
251
+ logger.error("No audio files found %s: %s", search_type, args.input_filename)
252
+ sys.exit(1)
253
+
254
+ logger.info("Found %d audio files to convert", len(audio_files))
255
+
256
+ output_dir = args.input_filename if args.output_to_source else './output'
257
+
258
+ if not args.output_to_source and not os.path.exists(output_dir):
259
+ os.makedirs(output_dir, exist_ok=True)
260
+ logger.debug("Created output directory: %s", output_dir)
261
+
262
+ created_files = []
263
+ for file_index, audio_file in enumerate(audio_files):
264
+ # Generate output filename based on the original file
265
+ base_name = os.path.splitext(os.path.basename(audio_file))[0]
266
+
267
+ # Apply media tag naming if requested
268
+ if args.use_media_tags:
269
+ tags = get_file_tags(audio_file)
270
+ if tags:
271
+ template = args.name_template or "{artist} - {title}"
272
+ new_name = format_metadata_filename(tags, template)
273
+ if new_name:
274
+ logger.debug("Using media tags for file naming: %s -> %s", base_name, new_name)
275
+ base_name = new_name
276
+
277
+ # Apply tonie tag if specified
278
+ if args.append_tonie_tag:
279
+ hex_tag = args.append_tonie_tag
280
+ if not all(c in '0123456789abcdefABCDEF' for c in hex_tag) or len(hex_tag) != 8:
281
+ logger.error("TAG must be an 8-character hexadecimal value")
282
+ sys.exit(1)
283
+ base_name = append_to_filename(base_name, hex_tag)
284
+
285
+ output_filename = os.path.join(output_dir, f"{base_name}.taf")
286
+
287
+ # Check if file already exists
288
+ skip_creation = False
289
+ if os.path.exists(output_filename):
290
+ logger.warning("Output file already exists: %s", output_filename)
291
+ valid_taf = check_tonie_file_cli(output_filename)
292
+
293
+ if valid_taf and not args.force_creation:
294
+ logger.warning("Valid Tonie file exists, skipping: %s", output_filename)
295
+ skip_creation = True
296
+ else:
297
+ logger.info("Output file exists but is not valid, proceeding to create new one")
298
+
299
+ logger.info("[%d/%d] Converting: %s -> %s",
300
+ file_index + 1, len(audio_files), os.path.basename(audio_file), os.path.basename(output_filename))
301
+
302
+ if not skip_creation:
303
+ try:
304
+ create_tonie_file(output_filename, [audio_file], args.no_tonie_header, args.user_timestamp,
305
+ args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp,
306
+ args.auto_download, not args.use_legacy_tags,
307
+ no_mono_conversion=args.no_mono_conversion)
308
+ logger.info("Successfully created: %s", output_filename)
309
+ except Exception as e:
310
+ logger.error("Failed to create %s: %s", output_filename, str(e))
311
+ continue
312
+
313
+ created_files.append(output_filename)
314
+
315
+ # Handle upload if requested
316
+ if args.upload:
317
+ upload_path = args.path
318
+ if upload_path and '{' in upload_path and args.use_media_tags:
319
+ metadata = get_file_tags(audio_file)
320
+ if metadata:
321
+ formatted_path = apply_template_to_path(upload_path, metadata)
322
+ if formatted_path:
323
+ logger.info("Using dynamic upload path from template: %s", formatted_path)
324
+ upload_path = formatted_path
325
+ else:
326
+ logger.warning("Could not apply all tags to path template '%s'. Using as-is.", upload_path)
327
+
328
+ # Create directories recursively if path is provided
329
+ if upload_path:
330
+ logger.debug("Creating directory structure on server: %s", upload_path)
331
+ try:
332
+ client.create_directories_recursive(
333
+ path=upload_path,
334
+ special=args.special_folder
335
+ )
336
+ logger.debug("Successfully created directory structure on server")
337
+ except Exception as e:
338
+ logger.warning("Failed to create directory structure on server: %s", str(e))
339
+ logger.debug("Continuing with upload anyway, in case the directory already exists")
340
+
341
+ response = client.upload_file(
342
+ file_path=output_filename,
343
+ destination_path=upload_path,
344
+ special=args.special_folder,
345
+ )
346
+ upload_success = response.get('success', False)
347
+
348
+ if not upload_success:
349
+ logger.error("Failed to upload %s to TeddyCloud", output_filename)
350
+ else:
351
+ logger.info("Successfully uploaded %s to TeddyCloud", output_filename)
352
+
353
+ # Handle artwork upload
354
+ artwork_url = None
355
+ if args.include_artwork:
356
+ success, artwork_url = upload_artwork(client, output_filename, os.path.dirname(audio_file), [audio_file])
357
+ if success:
358
+ logger.info("Successfully uploaded artwork for %s", output_filename)
359
+ else:
360
+ logger.warning("Failed to upload artwork for %s", output_filename)
361
+
362
+ # Handle custom JSON creation
363
+ if args.create_custom_json:
364
+ json_output_dir = args.input_filename if args.output_to_source else output_dir
365
+ client_param = client if 'client' in locals() else None
366
+ if args.version_2:
367
+ logger.debug("Using version 2 of the Tonies JSON format")
368
+ success = fetch_and_update_tonies_json_v2(client_param, output_filename, [audio_file], artwork_url, json_output_dir)
369
+ else:
370
+ success = fetch_and_update_tonies_json_v1(client_param, output_filename, [audio_file], artwork_url, json_output_dir)
371
+ if success:
372
+ logger.info("Successfully updated Tonies JSON for %s", output_filename)
373
+ else:
374
+ logger.warning("Failed to update Tonies JSON for %s", output_filename)
375
+
376
+ logger.info("Files to TAF processing completed. Created %d Tonie files.", len(created_files))
377
+ sys.exit(0)
378
+
214
379
  # ------------- Normalize Path Input -------------
215
380
  if args.input_filename:
216
381
  logger.debug("Original input path: %s", args.input_filename)
@@ -258,7 +423,7 @@ def main():
258
423
  logger.error("No files found for pattern %s", args.input_filename)
259
424
  sys.exit(1)
260
425
  for file_index, file_path in enumerate(files):
261
- tags = get_file_tags(file_path)
426
+ tags = get_all_file_tags(file_path)
262
427
  if tags:
263
428
  print(f"\nFile {file_index + 1}: {os.path.basename(file_path)}")
264
429
  print("-" * 40)
@@ -323,6 +488,21 @@ def main():
323
488
  logger.info("Successfully uploaded %s to TeddyCloud", file_path)
324
489
  logger.debug("Upload response details: %s",
325
490
  {k: v for k, v in response.items() if k != 'success'})
491
+ if args.assign_to_tag:
492
+ tag_id = input("Enter the tag ID to assign the uploaded file: eg. 'E0:04:03:50:11:AA:7E:81': ").strip()
493
+ fileName = os.path.basename(file_path)
494
+ if upload_path:
495
+ libPath = f"lib://{upload_path}/{fileName}"
496
+ else:
497
+ libPath = f"lib://{fileName}"
498
+ logger.info("Assigning uploaded file %s to tag ID: %s", fileName, tag_id)
499
+ logger.debug("Library path for assignment: %s", libPath)
500
+ success = client.assign_tag_path(libPath, tag_id)
501
+ if success:
502
+ logger.info("Successfully assigned tag %s to %s", tag_id, fileName)
503
+ else:
504
+ logger.warning("Failed to assign tag %s to %s", tag_id, fileName)
505
+
326
506
  artwork_url = None
327
507
  if args.include_artwork and file_path.lower().endswith('.taf'):
328
508
  source_dir = os.path.dirname(file_path)
@@ -500,6 +680,23 @@ def main():
500
680
  logger.info("Comparing Tonie files: %s and %s", args.input_filename, args.compare)
501
681
  result = compare_taf_files(args.input_filename, args.compare, args.detailed_compare)
502
682
  sys.exit(0 if result else 1)
683
+ elif args.play:
684
+ logger.info("Playing Tonie file: %s", args.input_filename)
685
+ interactive_player(args.input_filename)
686
+ sys.exit(0)
687
+ elif args.play_ui:
688
+ logger.warning("Nothing to see here yet!")
689
+ logger.info("This is for future implementation of a minimal GUI player.")
690
+ logger.info("Playing Tonie file with user interface: %s", 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()}")