karaoke-gen 0.50.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.

Potentially problematic release.


This version of karaoke-gen might be problematic. Click here for more details.

@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env python
2
+ import argparse
3
+ import logging
4
+ import pkg_resources
5
+ import os
6
+ import csv
7
+ import asyncio
8
+ import json
9
+ import sys
10
+ from karaoke_prep import KaraokePrep
11
+ from karaoke_prep.karaoke_finalise import KaraokeFinalise
12
+
13
+ # Global logger
14
+ logger = logging.getLogger(__name__)
15
+ logger.setLevel(logging.INFO) # Set initial log level
16
+
17
+
18
+ async def process_track_prep(row, args, logger, log_formatter):
19
+ """First phase: Process a track through prep stage only, without video rendering"""
20
+ original_dir = os.getcwd()
21
+ try:
22
+ artist = row["Artist"].strip()
23
+ title = row["Title"].strip()
24
+ guide_file = row["Mixed Audio Filename"].strip()
25
+ instrumental_file = row["Instrumental Audio Filename"].strip()
26
+
27
+ logger.info(f"Initial prep phase for track: {artist} - {title}")
28
+
29
+ kprep = KaraokePrep(
30
+ artist=artist,
31
+ title=title,
32
+ input_media=guide_file,
33
+ existing_instrumental=instrumental_file,
34
+ style_params_json=args.style_params_json,
35
+ logger=logger,
36
+ log_level=args.log_level,
37
+ dry_run=args.dry_run,
38
+ render_video=False, # First phase: no video rendering
39
+ create_track_subfolders=True,
40
+ )
41
+
42
+ tracks = await kprep.process()
43
+ return True
44
+ except Exception as e:
45
+ logger.error(f"Failed initial prep for {artist} - {title}: {str(e)}")
46
+ return False
47
+ finally:
48
+ os.chdir(original_dir)
49
+
50
+
51
+ async def process_track_render(row, args, logger, log_formatter):
52
+ """Phase 2: Process a track through karaoke-finalise."""
53
+ # First, load CDG styles if CDG generation is enabled
54
+ cdg_styles = None
55
+ if args.enable_cdg:
56
+ if not args.style_params_json:
57
+ # Raise ValueError instead of sys.exit
58
+ raise ValueError("CDG styles JSON file path (--style_params_json) is required when --enable_cdg is used")
59
+ try:
60
+ with open(args.style_params_json, "r") as f:
61
+ style_params = json.load(f) # Use json.load directly with file object
62
+ # Check if 'cdg' key exists
63
+ if "cdg" not in style_params:
64
+ raise ValueError(f"'cdg' key not found in style parameters file: {args.style_params_json}")
65
+ cdg_styles = style_params["cdg"]
66
+ except FileNotFoundError:
67
+ # Re-raise FileNotFoundError
68
+ raise FileNotFoundError(f"CDG styles configuration file not found: {args.style_params_json}")
69
+ except json.JSONDecodeError as e:
70
+ # Raise ValueError for invalid JSON
71
+ raise ValueError(f"Invalid JSON in CDG styles configuration file: {str(e)}")
72
+
73
+ original_dir = os.getcwd()
74
+ artist = row["Artist"].strip()
75
+ title = row["Title"].strip()
76
+ guide_file = row["Mixed Audio Filename"].strip()
77
+ instrumental_file = row["Instrumental Audio Filename"].strip()
78
+
79
+ try:
80
+ # Initialize KaraokeFinalise first (needed for test assertions)
81
+ kfinalise = KaraokeFinalise(
82
+ log_formatter=log_formatter,
83
+ log_level=args.log_level,
84
+ dry_run=args.dry_run,
85
+ enable_cdg=args.enable_cdg,
86
+ enable_txt=args.enable_txt,
87
+ cdg_styles=cdg_styles,
88
+ non_interactive=True
89
+ )
90
+
91
+ # Try to find the track directory
92
+ track_dir_found = False
93
+
94
+ # Try several directory naming patterns
95
+ possible_dirs = [
96
+ os.path.join(args.output_dir, f"{artist} - {title}"),
97
+ os.path.join(args.output_dir, f"{artist} - {title}"), # Original artist/title from row
98
+ os.path.join(args.output_dir, f"{artist} - {title}") # With space replace (same here)
99
+ ]
100
+
101
+ for track_dir in possible_dirs:
102
+ if os.path.exists(track_dir):
103
+ track_dir_found = True
104
+ break
105
+
106
+ if not track_dir_found:
107
+ logger.error(f"Track directory not found. Tried: {', '.join(possible_dirs)}")
108
+ return True # Return True to continue with other tracks
109
+
110
+ # First run KaraokePrep with video rendering enabled
111
+ # This is so the human can review all of the lyrics for the entire batch fairly quickly,
112
+ # then leave the script running to render the videos for all of them.
113
+ logger.info(f"Video rendering for track: {artist} - {title}")
114
+ kprep = KaraokePrep(
115
+ artist=artist,
116
+ title=title,
117
+ input_media=guide_file,
118
+ existing_instrumental=instrumental_file,
119
+ style_params_json=args.style_params_json,
120
+ logger=logger,
121
+ log_level=args.log_level,
122
+ dry_run=args.dry_run,
123
+ render_video=True, # Second phase: with video rendering
124
+ create_track_subfolders=True,
125
+ skip_transcription_review=True,
126
+ )
127
+
128
+ tracks = await kprep.process()
129
+
130
+ # Process with KaraokeFinalise in the track directory
131
+ for track_dir in possible_dirs:
132
+ if os.path.exists(track_dir):
133
+ try:
134
+ os.chdir(track_dir)
135
+ # Process with KaraokeFinalise
136
+ kfinalise.process()
137
+ return True
138
+ except Exception as e:
139
+ logger.error(f"Error during finalisation: {str(e)}")
140
+ raise # Re-raise to be caught by outer try/except
141
+ finally:
142
+ # Always go back to original directory
143
+ os.chdir(original_dir)
144
+
145
+ except Exception as e:
146
+ logger.error(f"Failed render/finalise for {artist} - {title}: {str(e)}")
147
+ os.chdir(original_dir) # Make sure we go back to original directory
148
+ return False
149
+
150
+
151
+ def update_csv_status(csv_path, row_index, new_status, dry_run=False):
152
+ """Update the status of a processed row in the CSV file.
153
+
154
+ Args:
155
+ csv_path (str): Path to the CSV file
156
+ row_index (int): Index of the row to update
157
+ new_status (str): New status to set
158
+ dry_run (bool): If True, log the update but don't modify the file
159
+
160
+ Returns:
161
+ bool: True if updated, False if in dry run mode or error occurred
162
+ """
163
+ if dry_run:
164
+ logger.info(f"DRY RUN: Would update row {row_index} in {csv_path} to status '{new_status}'")
165
+ return False
166
+
167
+ try:
168
+ # Read all rows
169
+ with open(csv_path, "r") as f:
170
+ reader = csv.DictReader(f)
171
+ rows = list(reader)
172
+
173
+ # Check if CSV has any rows
174
+ if not rows:
175
+ logger.error(f"CSV file {csv_path} is empty or has no data rows")
176
+ return False
177
+
178
+ # Update status for the processed row
179
+ if row_index < 0 or row_index >= len(rows):
180
+ logger.error(f"Row index {row_index} is out of range for CSV with {len(rows)} rows")
181
+ return False
182
+
183
+ rows[row_index]["Status"] = new_status
184
+
185
+ # Write back to CSV
186
+ fieldnames = rows[0].keys()
187
+ with open(csv_path, "w", newline="") as f:
188
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
189
+ writer.writeheader()
190
+ writer.writerows(rows)
191
+
192
+ return True
193
+
194
+ except Exception as e:
195
+ logger.error(f"Error updating CSV status: {str(e)}")
196
+ return False
197
+
198
+
199
+ def parse_arguments():
200
+ """Parse command line arguments"""
201
+ parser = argparse.ArgumentParser(
202
+ description="Process multiple karaoke tracks in bulk from a CSV file.",
203
+ formatter_class=lambda prog: argparse.RawTextHelpFormatter(prog, max_help_position=54),
204
+ )
205
+
206
+ # Basic information
207
+ parser.add_argument(
208
+ "input_csv",
209
+ help="Path to CSV file containing tracks to process. CSV should have columns: Artist,Title,Mixed Audio Filename,Instrumental Audio Filename,Status",
210
+ )
211
+
212
+ package_version = pkg_resources.get_distribution("karaoke-gen").version
213
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {package_version}")
214
+
215
+ # Required arguments
216
+ parser.add_argument(
217
+ "--style_params_json",
218
+ required=True,
219
+ help="Path to style parameters JSON file",
220
+ )
221
+ parser.add_argument(
222
+ "--output_dir",
223
+ default=".",
224
+ help="Optional: directory to write output files (default: <current dir>). Example: --output_dir=/app/karaoke",
225
+ )
226
+
227
+ # Finalise-specific arguments
228
+ parser.add_argument(
229
+ "--enable_cdg",
230
+ action="store_true",
231
+ help="Optional: Enable CDG ZIP generation during finalisation. Example: --enable_cdg",
232
+ )
233
+ parser.add_argument(
234
+ "--enable_txt",
235
+ action="store_true",
236
+ help="Optional: Enable TXT ZIP generation during finalisation. Example: --enable_txt",
237
+ )
238
+
239
+ # Logging & Debugging
240
+ parser.add_argument(
241
+ "--log_level",
242
+ default="info",
243
+ help="Optional: logging level, e.g. info, debug, warning (default: %(default)s). Example: --log_level=debug",
244
+ )
245
+ parser.add_argument(
246
+ "--dry_run",
247
+ action="store_true",
248
+ help="Optional: perform a dry run without making any changes (default: %(default)s). Example: --dry_run",
249
+ )
250
+
251
+ args = parser.parse_args()
252
+
253
+ # Convert input_csv to absolute path early
254
+ args.input_csv = os.path.abspath(args.input_csv)
255
+
256
+ # Validate and convert log level
257
+ if isinstance(args.log_level, str):
258
+ try:
259
+ log_level_int = getattr(logging, args.log_level.upper())
260
+ args.log_level = log_level_int # Store the numeric log level back in args
261
+ except AttributeError:
262
+ # Raise ValueError for invalid log level string
263
+ raise ValueError(f"Invalid log level string: {args.log_level}")
264
+ elif not isinstance(args.log_level, int):
265
+ # If it's neither string nor int, raise error
266
+ raise ValueError(f"Invalid log level type: {type(args.log_level)}")
267
+
268
+ return args
269
+
270
+
271
+ def _parse_and_validate_args():
272
+ """Parses arguments and performs initial validation."""
273
+ args = parse_arguments() # Calls the modified parse_arguments
274
+
275
+ # Validate input CSV existence (raises FileNotFoundError if invalid)
276
+ if not validate_input_csv(args.input_csv):
277
+ raise FileNotFoundError(f"Input CSV file not found: {args.input_csv}")
278
+
279
+ # Validate style params JSON existence if CDG is enabled
280
+ if args.enable_cdg:
281
+ if not args.style_params_json:
282
+ raise ValueError("CDG styles JSON file path (--style_params_json) is required when --enable_cdg is used")
283
+ if not os.path.isfile(args.style_params_json):
284
+ raise FileNotFoundError(f"CDG styles configuration file not found: {args.style_params_json}")
285
+ # Basic JSON validation can also happen here if desired, or deferred to process_track_render
286
+ try:
287
+ with open(args.style_params_json, 'r') as f:
288
+ json.load(f)
289
+ except json.JSONDecodeError as e:
290
+ raise ValueError(f"Invalid JSON in CDG styles configuration file: {args.style_params_json} - {e}")
291
+ except FileNotFoundError: # Should be caught above, but belt-and-suspenders
292
+ raise FileNotFoundError(f"CDG styles configuration file not found: {args.style_params_json}")
293
+
294
+ return args
295
+
296
+
297
+ def validate_input_csv(csv_path):
298
+ """Validate that the input CSV file exists.
299
+
300
+ Args:
301
+ csv_path (str): Path to the CSV file
302
+
303
+ Returns:
304
+ bool: True if the file exists, False otherwise
305
+ """
306
+ if not os.path.isfile(csv_path):
307
+ logger.error(f"Input CSV file not found: {csv_path}")
308
+ return False
309
+ return True
310
+
311
+
312
+ def _read_csv_file(csv_path):
313
+ """Reads the CSV file and returns rows as a list of dictionaries."""
314
+ try:
315
+ with open(csv_path, "r", newline='') as f: # Added newline=''
316
+ reader = csv.DictReader(f)
317
+ # Check for required columns before reading all rows
318
+ required_columns = {"Artist", "Title", "Mixed Audio Filename", "Instrumental Audio Filename", "Status"}
319
+ if not required_columns.issubset(reader.fieldnames):
320
+ missing = required_columns - set(reader.fieldnames)
321
+ raise ValueError(f"CSV file missing required columns: {', '.join(missing)}")
322
+ rows = list(reader)
323
+ if not rows:
324
+ logger.warning(f"CSV file {csv_path} is empty or contains only headers.")
325
+ return rows
326
+ except FileNotFoundError:
327
+ # This should ideally be caught earlier by validate_input_csv, but handle defensively
328
+ logger.error(f"CSV file not found during read: {csv_path}")
329
+ raise # Re-raise the exception
330
+ except Exception as e:
331
+ logger.error(f"Error reading CSV file {csv_path}: {e}")
332
+ raise # Re-raise other read errors
333
+
334
+
335
+ async def process_csv_rows(csv_path, rows, args, logger, log_formatter):
336
+ """Process all rows in a CSV file.
337
+
338
+ Args:
339
+ csv_path (str): Path to the CSV file
340
+ rows (list): List of CSV rows as dictionaries
341
+ args (argparse.Namespace): Command line arguments
342
+ logger (logging.Logger): Logger instance
343
+ log_formatter (logging.Formatter): Log formatter
344
+
345
+ Returns:
346
+ dict: A summary of the processing results
347
+ """
348
+ results = {
349
+ "prep_success": 0,
350
+ "prep_failed": 0,
351
+ "render_success": 0,
352
+ "render_failed": 0,
353
+ "skipped": 0
354
+ }
355
+
356
+ # Phase 1: Initial prep for all tracks
357
+ logger.info("Starting Phase 1: Initial prep for all tracks")
358
+ for i, row in enumerate(rows):
359
+ status = row["Status"].lower() if "Status" in row else ""
360
+ if status != "uploaded":
361
+ logger.info(f"Skipping {row.get('Artist', 'Unknown')} - {row.get('Title', 'Unknown')} (Status: {row.get('Status', 'Unknown')})")
362
+ results["skipped"] += 1
363
+ continue
364
+
365
+ success = await process_track_prep(row, args, logger, log_formatter)
366
+ if success:
367
+ results["prep_success"] += 1
368
+ if not args.dry_run:
369
+ update_csv_status(csv_path, i, "Prep_Complete", args.dry_run)
370
+ else:
371
+ results["prep_failed"] += 1
372
+ if not args.dry_run:
373
+ update_csv_status(csv_path, i, "Prep_Failed", args.dry_run)
374
+
375
+ # Phase 2: Render and finalise all tracks
376
+ logger.info("Starting Phase 2: Render and finalise for all tracks")
377
+ for i, row in enumerate(rows):
378
+ status = row["Status"].lower() if "Status" in row else ""
379
+ if status not in ["prep_complete", "uploaded"]:
380
+ logger.info(f"Skipping {row.get('Artist', 'Unknown')} - {row.get('Title', 'Unknown')} (Status: {row.get('Status', 'Unknown')})")
381
+ continue
382
+
383
+ success = await process_track_render(row, args, logger, log_formatter)
384
+ if success:
385
+ results["render_success"] += 1
386
+ if not args.dry_run:
387
+ update_csv_status(csv_path, i, "Completed", args.dry_run)
388
+ else:
389
+ results["render_failed"] += 1
390
+ if not args.dry_run:
391
+ update_csv_status(csv_path, i, "Render_Failed", args.dry_run)
392
+
393
+ return results
394
+
395
+
396
+ async def async_main():
397
+ """Main async function to process bulk tracks from CSV"""
398
+ # Parse and validate arguments first (raises exceptions on failure)
399
+ args = _parse_and_validate_args()
400
+
401
+ # Set log level based on validated args (logger should already be partially set up by main)
402
+ logger.setLevel(args.log_level)
403
+ logger.info(f"Log level set to {logging.getLevelName(args.log_level)}")
404
+ if args.dry_run:
405
+ logger.info("Dry run mode enabled. No changes will be made.")
406
+
407
+ logger.info(f"Starting bulk processing with input CSV: {args.input_csv}")
408
+
409
+ # Read CSV (raises exceptions on failure)
410
+ rows = _read_csv_file(args.input_csv)
411
+
412
+ # Check if log_formatter is available (should be set by main)
413
+ global log_formatter
414
+ if log_formatter is None:
415
+ # This case should ideally not happen if main() calls setup_logging correctly
416
+ logger.warning("Log formatter not found, setting up default.")
417
+ log_formatter = setup_logging(args.log_level)
418
+
419
+
420
+ # Process the CSV rows
421
+ results = await process_csv_rows(args.input_csv, rows, args, logger, log_formatter)
422
+
423
+ # Log summary
424
+ logger.info(f"Processing complete. Summary: {results}")
425
+ return results
426
+
427
+
428
+ def setup_logging(log_level=logging.INFO):
429
+ """Set up logging with the given log level.
430
+
431
+ Args:
432
+ log_level (int): Logging level (e.g., logging.INFO, logging.DEBUG)
433
+
434
+ Returns:
435
+ logging.Formatter: The log formatter for use by other functions
436
+ """
437
+ global log_formatter # Make log_formatter accessible to other functions
438
+ log_handler = logging.StreamHandler()
439
+ log_formatter = logging.Formatter(fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
440
+ log_handler.setFormatter(log_formatter)
441
+ logger.addHandler(log_handler)
442
+ logger.setLevel(log_level)
443
+ return log_formatter
444
+
445
+
446
+ def main():
447
+ """Main entry point for the CLI."""
448
+ log_formatter = None # Initialize log_formatter
449
+ try:
450
+ # Set up logging early to capture potential errors during setup/parsing
451
+ # Get initial args just for log level if provided, otherwise default
452
+ temp_args, _ = argparse.ArgumentParser(add_help=False).parse_known_args()
453
+ initial_log_level_str = getattr(temp_args, 'log_level', 'info')
454
+ try:
455
+ initial_log_level = getattr(logging, initial_log_level_str.upper())
456
+ except AttributeError:
457
+ initial_log_level = logging.INFO
458
+ print(f"Warning: Invalid initial log level '{initial_log_level_str}'. Using INFO.", file=sys.stderr)
459
+
460
+ log_formatter = setup_logging(initial_log_level)
461
+
462
+ # Run the async main function using asyncio
463
+ asyncio.run(async_main())
464
+ logger.info("Bulk processing finished successfully.")
465
+ sys.exit(0)
466
+ except (FileNotFoundError, ValueError, argparse.ArgumentError) as e:
467
+ # Log specific configuration/setup errors before exiting
468
+ if logger.handlers: # Check if logger was set up
469
+ logger.error(f"Configuration error: {str(e)}")
470
+ else: # Fallback if logging setup failed
471
+ print(f"Error: {str(e)}", file=sys.stderr)
472
+ sys.exit(1)
473
+ except Exception as e:
474
+ # Catch any other unexpected errors during processing
475
+ if logger.handlers:
476
+ logger.exception(f"An unexpected error occurred during bulk processing: {str(e)}") # Use exception for traceback
477
+ else:
478
+ print(f"An unexpected error occurred: {str(e)}", file=sys.stderr)
479
+ sys.exit(1)
480
+
481
+
482
+ if __name__ == "__main__":
483
+ main()