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,1163 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import shlex
5
+ import logging
6
+ import zipfile
7
+ import shutil
8
+ import re
9
+ import requests
10
+ import pickle
11
+ from lyrics_converter import LyricsConverter
12
+ from thefuzz import fuzz
13
+ from googleapiclient.discovery import build
14
+ from google_auth_oauthlib.flow import InstalledAppFlow
15
+ from google.auth.transport.requests import Request
16
+ from googleapiclient.http import MediaFileUpload
17
+ import subprocess
18
+ import time
19
+ from google.oauth2.credentials import Credentials
20
+ import base64
21
+ from email.mime.text import MIMEText
22
+ from lyrics_transcriber.output.cdg import CDGGenerator
23
+
24
+
25
+ class KaraokeFinalise:
26
+ def __init__(
27
+ self,
28
+ logger=None,
29
+ log_level=logging.DEBUG,
30
+ log_formatter=None,
31
+ dry_run=False,
32
+ instrumental_format="flac",
33
+ enable_cdg=False,
34
+ enable_txt=False,
35
+ brand_prefix=None,
36
+ organised_dir=None,
37
+ organised_dir_rclone_root=None,
38
+ public_share_dir=None,
39
+ youtube_client_secrets_file=None,
40
+ youtube_description_file=None,
41
+ rclone_destination=None,
42
+ discord_webhook_url=None,
43
+ email_template_file=None,
44
+ cdg_styles=None,
45
+ keep_brand_code=False,
46
+ non_interactive=False,
47
+ ):
48
+ self.log_level = log_level
49
+ self.log_formatter = log_formatter
50
+
51
+ if logger is None:
52
+ self.logger = logging.getLogger(__name__)
53
+ self.logger.setLevel(log_level)
54
+
55
+ self.log_handler = logging.StreamHandler()
56
+
57
+ if self.log_formatter is None:
58
+ self.log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s")
59
+
60
+ self.log_handler.setFormatter(self.log_formatter)
61
+ self.logger.addHandler(self.log_handler)
62
+ else:
63
+ self.logger = logger
64
+
65
+ self.logger.debug(
66
+ f"KaraokeFinalise instantiating, dry_run: {dry_run}, brand_prefix: {brand_prefix}, organised_dir: {organised_dir}, public_share_dir: {public_share_dir}, rclone_destination: {rclone_destination}"
67
+ )
68
+
69
+ # Path to the Windows PyInstaller frozen bundled ffmpeg.exe, or the system-installed FFmpeg binary on Mac/Linux
70
+ ffmpeg_path = os.path.join(sys._MEIPASS, "ffmpeg.exe") if getattr(sys, "frozen", False) else "ffmpeg"
71
+
72
+ self.ffmpeg_base_command = f"{ffmpeg_path} -hide_banner -nostats"
73
+
74
+ if self.log_level == logging.DEBUG:
75
+ self.ffmpeg_base_command += " -loglevel verbose"
76
+ else:
77
+ self.ffmpeg_base_command += " -loglevel fatal"
78
+
79
+ self.dry_run = dry_run
80
+ self.instrumental_format = instrumental_format
81
+
82
+ self.brand_prefix = brand_prefix
83
+ self.organised_dir = organised_dir
84
+ self.organised_dir_rclone_root = organised_dir_rclone_root
85
+
86
+ self.public_share_dir = public_share_dir
87
+ self.youtube_client_secrets_file = youtube_client_secrets_file
88
+ self.youtube_description_file = youtube_description_file
89
+ self.rclone_destination = rclone_destination
90
+ self.discord_webhook_url = discord_webhook_url
91
+ self.enable_cdg = enable_cdg
92
+ self.enable_txt = enable_txt
93
+
94
+ self.youtube_upload_enabled = False
95
+ self.discord_notication_enabled = False
96
+ self.folder_organisation_enabled = False
97
+ self.public_share_copy_enabled = False
98
+ self.public_share_rclone_enabled = False
99
+
100
+ self.skip_notifications = False
101
+ self.non_interactive = non_interactive
102
+
103
+ self.suffixes = {
104
+ "title_mov": " (Title).mov",
105
+ "title_jpg": " (Title).jpg",
106
+ "end_mov": " (End).mov",
107
+ "end_jpg": " (End).jpg",
108
+ "with_vocals_mov": " (With Vocals).mov",
109
+ "with_vocals_mp4": " (With Vocals).mp4",
110
+ "with_vocals_mkv": " (With Vocals).mkv",
111
+ "karaoke_lrc": " (Karaoke).lrc",
112
+ "karaoke_txt": " (Karaoke).txt",
113
+ "karaoke_mp4": " (Karaoke).mp4",
114
+ "karaoke_cdg": " (Karaoke).cdg",
115
+ "karaoke_mp3": " (Karaoke).mp3",
116
+ "final_karaoke_lossless_mp4": " (Final Karaoke Lossless 4k).mp4",
117
+ "final_karaoke_lossless_mkv": " (Final Karaoke Lossless 4k).mkv",
118
+ "final_karaoke_lossy_mp4": " (Final Karaoke Lossy 4k).mp4",
119
+ "final_karaoke_lossy_720p_mp4": " (Final Karaoke Lossy 720p).mp4",
120
+ "final_karaoke_cdg_zip": " (Final Karaoke CDG).zip",
121
+ "final_karaoke_txt_zip": " (Final Karaoke TXT).zip",
122
+ }
123
+
124
+ self.youtube_url_prefix = "https://www.youtube.com/watch?v="
125
+
126
+ self.youtube_url = None
127
+ self.brand_code = None
128
+ self.new_brand_code_dir = None
129
+ self.new_brand_code_dir_path = None
130
+ self.brand_code_dir_sharing_link = None
131
+
132
+ self.email_template_file = email_template_file
133
+ self.gmail_service = None
134
+
135
+ self.cdg_styles = cdg_styles
136
+
137
+ # Determine best available AAC codec
138
+ self.aac_codec = self.detect_best_aac_codec()
139
+
140
+ self.keep_brand_code = keep_brand_code
141
+
142
+ # MP4 output flags for better compatibility and streaming
143
+ self.mp4_flags = "-pix_fmt yuv420p -movflags +faststart+frag_keyframe+empty_moov"
144
+
145
+ # Update ffmpeg base command to include -y if non-interactive
146
+ if self.non_interactive:
147
+ self.ffmpeg_base_command += " -y"
148
+
149
+ def check_input_files_exist(self, base_name, with_vocals_file, instrumental_audio_file):
150
+ self.logger.info(f"Checking required input files exist...")
151
+
152
+ input_files = {
153
+ "title_mov": f"{base_name}{self.suffixes['title_mov']}",
154
+ "title_jpg": f"{base_name}{self.suffixes['title_jpg']}",
155
+ "instrumental_audio": instrumental_audio_file,
156
+ "with_vocals_mov": with_vocals_file,
157
+ }
158
+
159
+ optional_input_files = {
160
+ "end_mov": f"{base_name}{self.suffixes['end_mov']}",
161
+ "end_jpg": f"{base_name}{self.suffixes['end_jpg']}",
162
+ }
163
+
164
+ if self.enable_cdg or self.enable_txt:
165
+ input_files["karaoke_lrc"] = f"{base_name}{self.suffixes['karaoke_lrc']}"
166
+
167
+ for key, file_path in input_files.items():
168
+ if not os.path.isfile(file_path):
169
+ raise Exception(f"Input file {key} not found: {file_path}")
170
+
171
+ self.logger.info(f" Input file {key} found: {file_path}")
172
+
173
+ for key, file_path in optional_input_files.items():
174
+ if not os.path.isfile(file_path):
175
+ self.logger.info(f" Optional input file {key} not found: {file_path}")
176
+
177
+ self.logger.info(f" Input file {key} found, adding to input_files: {file_path}")
178
+ input_files[key] = file_path
179
+
180
+ return input_files
181
+
182
+ def prepare_output_filenames(self, base_name):
183
+ output_files = {
184
+ "karaoke_mp4": f"{base_name}{self.suffixes['karaoke_mp4']}",
185
+ "karaoke_mp3": f"{base_name}{self.suffixes['karaoke_mp3']}",
186
+ "karaoke_cdg": f"{base_name}{self.suffixes['karaoke_cdg']}",
187
+ "with_vocals_mp4": f"{base_name}{self.suffixes['with_vocals_mp4']}",
188
+ "final_karaoke_lossless_mp4": f"{base_name}{self.suffixes['final_karaoke_lossless_mp4']}",
189
+ "final_karaoke_lossless_mkv": f"{base_name}{self.suffixes['final_karaoke_lossless_mkv']}",
190
+ "final_karaoke_lossy_mp4": f"{base_name}{self.suffixes['final_karaoke_lossy_mp4']}",
191
+ "final_karaoke_lossy_720p_mp4": f"{base_name}{self.suffixes['final_karaoke_lossy_720p_mp4']}",
192
+ }
193
+
194
+ if self.enable_cdg:
195
+ output_files["final_karaoke_cdg_zip"] = f"{base_name}{self.suffixes['final_karaoke_cdg_zip']}"
196
+
197
+ if self.enable_txt:
198
+ output_files["karaoke_txt"] = f"{base_name}{self.suffixes['karaoke_txt']}"
199
+ output_files["final_karaoke_txt_zip"] = f"{base_name}{self.suffixes['final_karaoke_txt_zip']}"
200
+
201
+ return output_files
202
+
203
+ def prompt_user_confirmation_or_raise_exception(self, prompt_message, exit_message, allow_empty=False):
204
+ if self.non_interactive:
205
+ self.logger.info(f"Non-interactive mode, automatically confirming: {prompt_message}")
206
+ return True
207
+
208
+ if not self.prompt_user_bool(prompt_message, allow_empty=allow_empty):
209
+ self.logger.error(exit_message)
210
+ raise Exception(exit_message)
211
+
212
+ def prompt_user_bool(self, prompt_message, allow_empty=False):
213
+ if self.non_interactive:
214
+ self.logger.info(f"Non-interactive mode, automatically answering yes to: {prompt_message}")
215
+ return True
216
+
217
+ options_string = "[y]/n" if allow_empty else "y/[n]"
218
+ accept_responses = ["y", "yes"]
219
+ if allow_empty:
220
+ accept_responses.append("")
221
+
222
+ print()
223
+ response = input(f"{prompt_message} {options_string} ").strip().lower()
224
+ return response in accept_responses
225
+
226
+ def validate_input_parameters_for_features(self):
227
+ self.logger.info(f"Validating input parameters for enabled features...")
228
+
229
+ current_directory = os.getcwd()
230
+ self.logger.info(f"Current directory to process: {current_directory}")
231
+
232
+ # Enable youtube upload if client secrets file is provided and is valid JSON
233
+ if self.youtube_client_secrets_file is not None and self.youtube_description_file is not None:
234
+ if not os.path.isfile(self.youtube_client_secrets_file):
235
+ raise Exception(f"YouTube client secrets file does not exist: {self.youtube_client_secrets_file}")
236
+
237
+ if not os.path.isfile(self.youtube_description_file):
238
+ raise Exception(f"YouTube description file does not exist: {self.youtube_description_file}")
239
+
240
+ # Test parsing the file as JSON to check it's valid
241
+ try:
242
+ with open(self.youtube_client_secrets_file, "r") as f:
243
+ json.load(f)
244
+ except json.JSONDecodeError as e:
245
+ raise Exception(f"YouTube client secrets file is not valid JSON: {self.youtube_client_secrets_file}") from e
246
+
247
+ self.logger.debug(f"YouTube upload checks passed, enabling YouTube upload")
248
+ self.youtube_upload_enabled = True
249
+
250
+ # Enable discord notifications if webhook URL is provided and is valid URL
251
+ if self.discord_webhook_url is not None:
252
+ if not self.discord_webhook_url.startswith("https://discord.com/api/webhooks/"):
253
+ raise Exception(f"Discord webhook URL is not valid: {self.discord_webhook_url}")
254
+
255
+ self.logger.debug(f"Discord webhook URL checks passed, enabling Discord notifications")
256
+ self.discord_notication_enabled = True
257
+
258
+ # Enable folder organisation if brand prefix and target directory are provided and target directory is valid
259
+ if self.brand_prefix is not None and self.organised_dir is not None:
260
+ if not os.path.isdir(self.organised_dir):
261
+ raise Exception(f"Target directory does not exist: {self.organised_dir}")
262
+
263
+ self.logger.debug(f"Brand prefix and target directory provided, enabling folder organisation")
264
+ self.folder_organisation_enabled = True
265
+
266
+ # Enable public share copy if public share directory is provided and is valid directory with MP4 and CDG subdirectories
267
+ if self.public_share_dir is not None:
268
+ if not os.path.isdir(self.public_share_dir):
269
+ raise Exception(f"Public share directory does not exist: {self.public_share_dir}")
270
+
271
+ if not os.path.isdir(os.path.join(self.public_share_dir, "MP4")):
272
+ raise Exception(f"Public share directory does not contain MP4 subdirectory: {self.public_share_dir}")
273
+
274
+ if not os.path.isdir(os.path.join(self.public_share_dir, "CDG")):
275
+ raise Exception(f"Public share directory does not contain CDG subdirectory: {self.public_share_dir}")
276
+
277
+ self.logger.debug(f"Public share directory checks passed, enabling public share copy")
278
+ self.public_share_copy_enabled = True
279
+
280
+ # Enable public share rclone if rclone destination is provided
281
+ if self.rclone_destination is not None:
282
+ self.logger.debug(f"Rclone destination provided, enabling rclone sync")
283
+ self.public_share_rclone_enabled = True
284
+
285
+ # Tell user which features are enabled, prompt them to confirm before proceeding
286
+ self.logger.info(f"Enabled features:")
287
+ self.logger.info(f" CDG ZIP creation: {self.enable_cdg}")
288
+ self.logger.info(f" TXT ZIP creation: {self.enable_txt}")
289
+ self.logger.info(f" YouTube upload: {self.youtube_upload_enabled}")
290
+ self.logger.info(f" Discord notifications: {self.discord_notication_enabled}")
291
+ self.logger.info(f" Folder organisation: {self.folder_organisation_enabled}")
292
+ self.logger.info(f" Public share copy: {self.public_share_copy_enabled}")
293
+ self.logger.info(f" Public share rclone: {self.public_share_rclone_enabled}")
294
+
295
+ self.prompt_user_confirmation_or_raise_exception(
296
+ f"Confirm features enabled log messages above match your expectations for finalisation?",
297
+ "Refusing to proceed without user confirmation they're happy with enabled features.",
298
+ allow_empty=True,
299
+ )
300
+
301
+ def authenticate_youtube(self):
302
+ """Authenticate and return a YouTube service object."""
303
+ credentials = None
304
+ youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
305
+
306
+ # Token file stores the user's access and refresh tokens for YouTube.
307
+ if os.path.exists(youtube_token_file):
308
+ with open(youtube_token_file, "rb") as token:
309
+ credentials = pickle.load(token)
310
+
311
+ # If there are no valid credentials, let the user log in.
312
+ if not credentials or not credentials.valid:
313
+ if credentials and credentials.expired and credentials.refresh_token:
314
+ credentials.refresh(Request())
315
+ else:
316
+ flow = InstalledAppFlow.from_client_secrets_file(
317
+ self.youtube_client_secrets_file, scopes=["https://www.googleapis.com/auth/youtube"]
318
+ )
319
+ credentials = flow.run_local_server(port=0) # This will open a browser for authentication
320
+
321
+ # Save the credentials for the next run
322
+ with open(youtube_token_file, "wb") as token:
323
+ pickle.dump(credentials, token)
324
+
325
+ return build("youtube", "v3", credentials=credentials)
326
+
327
+ def get_channel_id(self):
328
+ youtube = self.authenticate_youtube()
329
+
330
+ # Get the authenticated user's channel
331
+ request = youtube.channels().list(part="snippet", mine=True)
332
+ response = request.execute()
333
+
334
+ # Extract the channel ID
335
+ if "items" in response:
336
+ channel_id = response["items"][0]["id"]
337
+ return channel_id
338
+ else:
339
+ return None
340
+
341
+ def check_if_video_title_exists_on_youtube_channel(self, youtube_title):
342
+ youtube = self.authenticate_youtube()
343
+ channel_id = self.get_channel_id()
344
+
345
+ self.logger.info(f"Searching YouTube channel {channel_id} for title: {youtube_title}")
346
+ request = youtube.search().list(part="snippet", channelId=channel_id, q=youtube_title, type="video", maxResults=10)
347
+ response = request.execute()
348
+
349
+ # Check if any videos were found
350
+ if "items" in response and len(response["items"]) > 0:
351
+ for item in response["items"]:
352
+ found_title = item["snippet"]["title"]
353
+ similarity_score = fuzz.ratio(youtube_title.lower(), found_title.lower())
354
+ if similarity_score >= 70: # 70% similarity
355
+ found_id = item["id"]["videoId"]
356
+ self.logger.info(
357
+ f"Potential match found on YouTube channel with ID: {found_id} and title: {found_title} (similarity: {similarity_score}%)"
358
+ )
359
+
360
+ # In non-interactive mode, automatically confirm if similarity is high enough
361
+ if self.non_interactive:
362
+ self.logger.info(f"Non-interactive mode, automatically confirming match with similarity score {similarity_score}%")
363
+ self.youtube_video_id = found_id
364
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
365
+ self.skip_notifications = True
366
+ return True
367
+
368
+ confirmation = input(f"Is '{found_title}' the video you are finalising? (y/n): ").strip().lower()
369
+ if confirmation == "y":
370
+ self.youtube_video_id = found_id
371
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
372
+ self.skip_notifications = True
373
+ return True
374
+
375
+ self.logger.info(f"No matching video found with title: {youtube_title}")
376
+ return False
377
+
378
+ def delete_youtube_video(self, video_id):
379
+ """
380
+ Delete a YouTube video by its ID.
381
+
382
+ Args:
383
+ video_id: The YouTube video ID to delete
384
+
385
+ Returns:
386
+ True if successful, False otherwise
387
+ """
388
+ self.logger.info(f"Deleting YouTube video with ID: {video_id}")
389
+
390
+ if self.dry_run:
391
+ self.logger.info(f"DRY RUN: Would delete YouTube video with ID: {video_id}")
392
+ return True
393
+
394
+ try:
395
+ youtube = self.authenticate_youtube()
396
+ youtube.videos().delete(id=video_id).execute()
397
+ self.logger.info(f"Successfully deleted YouTube video with ID: {video_id}")
398
+ return True
399
+ except Exception as e:
400
+ self.logger.error(f"Failed to delete YouTube video with ID {video_id}: {e}")
401
+ return False
402
+
403
+ def truncate_to_nearest_word(self, title, max_length):
404
+ if len(title) <= max_length:
405
+ return title
406
+ truncated_title = title[:max_length].rsplit(" ", 1)[0]
407
+ if len(truncated_title) < max_length:
408
+ truncated_title += " ..."
409
+ return truncated_title
410
+
411
+ def upload_final_mp4_to_youtube_with_title_thumbnail(self, artist, title, input_files, output_files, replace_existing=False):
412
+ self.logger.info(f"Uploading final MKV to YouTube with title thumbnail...")
413
+ if self.dry_run:
414
+ self.logger.info(
415
+ f'DRY RUN: Would upload {output_files["final_karaoke_lossless_mkv"]} to YouTube with thumbnail {input_files["title_jpg"]} using client secrets file: {self.youtube_client_secrets_file}'
416
+ )
417
+ else:
418
+ youtube_title = f"{artist} - {title} (Karaoke)"
419
+
420
+ # Truncate title to the nearest whole word and add ellipsis if needed
421
+ max_length = 95
422
+ youtube_title = self.truncate_to_nearest_word(youtube_title, max_length)
423
+
424
+ if self.check_if_video_title_exists_on_youtube_channel(youtube_title):
425
+ if replace_existing:
426
+ self.logger.info(f"Video already exists on YouTube, deleting before re-upload: {self.youtube_url}")
427
+ if self.delete_youtube_video(self.youtube_video_id):
428
+ self.logger.info(f"Successfully deleted existing video, proceeding with upload")
429
+ # Reset the video ID and URL since we're uploading a new one
430
+ self.youtube_video_id = None
431
+ self.youtube_url = None
432
+ else:
433
+ self.logger.error(f"Failed to delete existing video, aborting upload")
434
+ return
435
+ else:
436
+ self.logger.warning(f"Video already exists on YouTube, skipping upload: {self.youtube_url}")
437
+ return
438
+
439
+ youtube_description = f"Karaoke version of {artist} - {title} created using karaoke-gen python package."
440
+ if self.youtube_description_file is not None:
441
+ with open(self.youtube_description_file, "r") as f:
442
+ youtube_description = f.read()
443
+
444
+ youtube_category_id = "10" # Category ID for Music
445
+ youtube_keywords = ["karaoke", "music", "singing", "instrumental", "lyrics", artist, title]
446
+
447
+ self.logger.info(f"Authenticating with YouTube...")
448
+ # Upload video to YouTube and set thumbnail.
449
+ youtube = self.authenticate_youtube()
450
+
451
+ body = {
452
+ "snippet": {
453
+ "title": youtube_title,
454
+ "description": youtube_description,
455
+ "tags": youtube_keywords,
456
+ "categoryId": youtube_category_id,
457
+ },
458
+ "status": {"privacyStatus": "public"},
459
+ }
460
+
461
+ # Use MediaFileUpload to handle the video file - using the MKV with FLAC audio
462
+ media_file = MediaFileUpload(output_files["final_karaoke_lossless_mkv"], mimetype="video/x-matroska", resumable=True)
463
+
464
+ # Call the API's videos.insert method to create and upload the video.
465
+ self.logger.info(f"Uploading final MKV to YouTube...")
466
+ request = youtube.videos().insert(part="snippet,status", body=body, media_body=media_file)
467
+ response = request.execute()
468
+
469
+ self.youtube_video_id = response.get("id")
470
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
471
+ self.logger.info(f"Uploaded video to YouTube: {self.youtube_url}")
472
+
473
+ # Uploading the thumbnail
474
+ if input_files["title_jpg"]:
475
+ media_thumbnail = MediaFileUpload(input_files["title_jpg"], mimetype="image/jpeg")
476
+ youtube.thumbnails().set(videoId=self.youtube_video_id, media_body=media_thumbnail).execute()
477
+ self.logger.info(f"Uploaded thumbnail for video ID {self.youtube_video_id}")
478
+
479
+ def get_next_brand_code(self):
480
+ """
481
+ Calculate the next sequence number based on existing directories in the organised_dir.
482
+ Assumes directories are named with the format: BRAND-XXXX Artist - Title
483
+ """
484
+ max_num = 0
485
+ pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
486
+
487
+ if not os.path.isdir(self.organised_dir):
488
+ raise Exception(f"Target directory does not exist: {self.organised_dir}")
489
+
490
+ for dir_name in os.listdir(self.organised_dir):
491
+ match = pattern.match(dir_name)
492
+ if match:
493
+ num = int(match.group(1))
494
+ max_num = max(max_num, num)
495
+
496
+ self.logger.info(f"Next sequence number for brand {self.brand_prefix} calculated as: {max_num + 1}")
497
+ next_seq_number = max_num + 1
498
+
499
+ return f"{self.brand_prefix}-{next_seq_number:04d}"
500
+
501
+ def post_discord_message(self, message, webhook_url):
502
+ """Post a message to a Discord channel via webhook."""
503
+ data = {"content": message}
504
+ response = requests.post(webhook_url, json=data)
505
+ response.raise_for_status() # This will raise an exception if the request failed
506
+ self.logger.info("Message posted to Discord")
507
+
508
+ def find_with_vocals_file(self):
509
+ self.logger.info("Finding input file ending in (With Vocals).mov/.mp4/.mkv or (Karaoke).mov/.mp4/.mkv")
510
+
511
+ # Define all possible suffixes for with vocals files
512
+ with_vocals_suffixes = [
513
+ self.suffixes["with_vocals_mov"],
514
+ self.suffixes["with_vocals_mp4"],
515
+ self.suffixes["with_vocals_mkv"],
516
+ ]
517
+
518
+ # First try to find a properly named with vocals file in any supported format
519
+ with_vocals_files = [f for f in os.listdir(".") if any(f.endswith(suffix) for suffix in with_vocals_suffixes)]
520
+
521
+ if with_vocals_files:
522
+ self.logger.info(f"Found with vocals file: {with_vocals_files[0]}")
523
+ return with_vocals_files[0]
524
+
525
+ # If no with vocals file found, look for potentially misnamed karaoke files
526
+ karaoke_suffixes = [" (Karaoke).mov", " (Karaoke).mp4", " (Karaoke).mkv"]
527
+ karaoke_files = [f for f in os.listdir(".") if any(f.endswith(suffix) for suffix in karaoke_suffixes)]
528
+
529
+ if karaoke_files:
530
+ for file in karaoke_files:
531
+ # Get the current extension
532
+ current_ext = os.path.splitext(file)[1].lower() # Convert to lowercase
533
+ base_without_suffix = file.replace(f" (Karaoke){current_ext}", "")
534
+
535
+ # Map file extension to suffix dictionary key
536
+ ext_to_suffix = {".mov": "with_vocals_mov", ".mp4": "with_vocals_mp4", ".mkv": "with_vocals_mkv"}
537
+
538
+ if current_ext in ext_to_suffix:
539
+ new_file = f"{base_without_suffix}{self.suffixes[ext_to_suffix[current_ext]]}"
540
+
541
+ self.prompt_user_confirmation_or_raise_exception(
542
+ f"Found '{file}' but no '(With Vocals)', rename to {new_file} for vocal input?",
543
+ "Unable to proceed without With Vocals file or user confirmation of rename.",
544
+ allow_empty=True,
545
+ )
546
+
547
+ os.rename(file, new_file)
548
+ self.logger.info(f"Renamed '{file}' to '{new_file}'")
549
+ return new_file
550
+ else:
551
+ self.logger.warning(f"Unsupported file extension: {current_ext}")
552
+
553
+ raise Exception("No suitable files found for processing.")
554
+
555
+ def choose_instrumental_audio_file(self, base_name):
556
+ self.logger.info(f"Choosing instrumental audio file to use as karaoke audio...")
557
+
558
+ search_string = " (Instrumental"
559
+ self.logger.info(f"Searching for files in current directory containing {search_string}")
560
+
561
+ all_instrumental_files = [f for f in os.listdir(".") if search_string in f]
562
+ flac_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".flac"))
563
+ mp3_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".mp3"))
564
+ wav_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".wav"))
565
+
566
+ self.logger.debug(f"FLAC files found: {flac_files}")
567
+ self.logger.debug(f"MP3 files found: {mp3_files}")
568
+ self.logger.debug(f"WAV files found: {wav_files}")
569
+
570
+ # Filter out MP3 files if their FLAC or WAV counterpart exists
571
+ # Filter out WAV files if their FLAC counterpart exists
572
+ filtered_files = [
573
+ f
574
+ for f in all_instrumental_files
575
+ if f.endswith(".flac")
576
+ or (f.endswith(".wav") and f.rsplit(".", 1)[0] not in flac_files)
577
+ or (f.endswith(".mp3") and f.rsplit(".", 1)[0] not in flac_files and f.rsplit(".", 1)[0] not in wav_files)
578
+ ]
579
+
580
+ self.logger.debug(f"Filtered instrumental files: {filtered_files}")
581
+
582
+ if not filtered_files:
583
+ raise Exception(f"No instrumental audio files found containing {search_string}")
584
+
585
+ if len(filtered_files) == 1:
586
+ return filtered_files[0]
587
+
588
+ # In non-interactive mode, always choose the first option
589
+ if self.non_interactive:
590
+ self.logger.info(f"Non-interactive mode, automatically choosing first instrumental file: {filtered_files[0]}")
591
+ return filtered_files[0]
592
+
593
+ # Sort the remaining instrumental options alphabetically
594
+ filtered_files.sort(reverse=True)
595
+
596
+ self.logger.info(f"Found multiple files containing {search_string}:")
597
+ for i, file in enumerate(filtered_files):
598
+ self.logger.info(f" {i+1}: {file}")
599
+
600
+ print()
601
+ response = input(f"Choose instrumental audio file to use as karaoke audio: [1]/{len(filtered_files)}: ").strip().lower()
602
+ if response == "":
603
+ response = "1"
604
+
605
+ try:
606
+ response = int(response)
607
+ except ValueError:
608
+ raise Exception(f"Invalid response to instrumental audio file choice prompt: {response}")
609
+
610
+ if response < 1 or response > len(filtered_files):
611
+ raise Exception(f"Invalid response to instrumental audio file choice prompt: {response}")
612
+
613
+ return filtered_files[response - 1]
614
+
615
+ def get_names_from_withvocals(self, with_vocals_file):
616
+ self.logger.info(f"Getting artist and title from {with_vocals_file}")
617
+
618
+ # Remove both possible suffixes and their extensions
619
+ base_name = with_vocals_file
620
+ for suffix_key in ["with_vocals_mov", "with_vocals_mp4", "with_vocals_mkv"]:
621
+ suffix = self.suffixes[suffix_key]
622
+ if suffix in base_name:
623
+ base_name = base_name.replace(suffix, "")
624
+ break
625
+
626
+ # If we didn't find a match above, try removing just the extension
627
+ if base_name == with_vocals_file:
628
+ base_name = os.path.splitext(base_name)[0]
629
+
630
+ artist, title = base_name.split(" - ", 1)
631
+ return base_name, artist, title
632
+
633
+ def execute_command(self, command, description):
634
+ self.logger.info(description)
635
+ if self.dry_run:
636
+ self.logger.info(f"DRY RUN: Would run command: {command}")
637
+ else:
638
+ self.logger.info(f"Running command: {command}")
639
+ os.system(command)
640
+
641
+ def remux_with_instrumental(self, with_vocals_file, instrumental_audio, output_file):
642
+ """Remux the video with instrumental audio to create karaoke version"""
643
+ # fmt: off
644
+ ffmpeg_command = (
645
+ f'{self.ffmpeg_base_command} -an -i "{with_vocals_file}" '
646
+ f'-vn -i "{instrumental_audio}" -c:v copy -c:a pcm_s16le "{output_file}"'
647
+ )
648
+ # fmt: on
649
+ self.execute_command(ffmpeg_command, "Remuxing video with instrumental audio")
650
+
651
+ def convert_mov_to_mp4(self, input_file, output_file):
652
+ """Convert MOV file to MP4 format"""
653
+ # fmt: off
654
+ ffmpeg_command = (
655
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
656
+ f'-c:v libx264 -c:a {self.aac_codec} {self.mp4_flags} "{output_file}"'
657
+ )
658
+ # fmt: on
659
+ self.execute_command(ffmpeg_command, "Converting MOV video to MP4")
660
+
661
+ def encode_lossless_mp4(self, title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_file):
662
+ """Create the final MP4 with PCM audio (lossless)"""
663
+ # fmt: off
664
+ ffmpeg_command = (
665
+ f"{self.ffmpeg_base_command} -i {title_mov_file} -i {karaoke_mp4_file} {env_mov_input} "
666
+ f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
667
+ f'{self.mp4_flags} "{output_file}"'
668
+ )
669
+ # fmt: on
670
+ self.execute_command(ffmpeg_command, "Creating MP4 version with PCM audio")
671
+
672
+ def encode_lossy_mp4(self, input_file, output_file):
673
+ """Create MP4 with AAC audio (lossy, for wider compatibility)"""
674
+ # fmt: off
675
+ ffmpeg_command = (
676
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
677
+ f'-c:v copy -c:a {self.aac_codec} -b:a 320k {self.mp4_flags} "{output_file}"'
678
+ )
679
+ # fmt: on
680
+ self.execute_command(ffmpeg_command, "Creating MP4 version with AAC audio")
681
+
682
+ def encode_lossless_mkv(self, input_file, output_file):
683
+ """Create MKV with FLAC audio (for YouTube)"""
684
+ # fmt: off
685
+ ffmpeg_command = (
686
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
687
+ f'-c:v copy -c:a flac "{output_file}"'
688
+ )
689
+ # fmt: on
690
+ self.execute_command(ffmpeg_command, "Creating MKV version with FLAC audio for YouTube")
691
+
692
+ def encode_720p_version(self, input_file, output_file):
693
+ """Create 720p MP4 with AAC audio (for smaller file size)"""
694
+ # fmt: off
695
+ ffmpeg_command = (
696
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
697
+ f'-c:v libx264 -vf "scale=1280:720" -b:v 200k -preset medium -tune animation '
698
+ f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
699
+ )
700
+ # fmt: on
701
+ self.execute_command(ffmpeg_command, "Encoding 720p version of the final video")
702
+
703
+ def prepare_concat_filter(self, input_files):
704
+ """Prepare the concat filter and additional input for end credits if present"""
705
+ env_mov_input = ""
706
+ ffmpeg_filter = '-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0]concat=n=2:v=1:a=1[outv][outa]"'
707
+
708
+ if "end_mov" in input_files and os.path.isfile(input_files["end_mov"]):
709
+ self.logger.info(f"Found end_mov file: {input_files['end_mov']}, including in final MP4")
710
+ end_mov_file = shlex.quote(os.path.abspath(input_files["end_mov"]))
711
+ env_mov_input = f"-i {end_mov_file}"
712
+ ffmpeg_filter = '-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0][2:v:0][2:a:0]concat=n=3:v=1:a=1[outv][outa]"'
713
+
714
+ return env_mov_input, ffmpeg_filter
715
+
716
+ def remux_and_encode_output_video_files(self, with_vocals_file, input_files, output_files):
717
+ self.logger.info(f"Remuxing and encoding output video files...")
718
+
719
+ # Check if output files already exist
720
+ if os.path.isfile(output_files["final_karaoke_lossless_mp4"]) and os.path.isfile(output_files["final_karaoke_lossless_mkv"]):
721
+ if not self.prompt_user_bool(
722
+ f"Found existing Final Karaoke output files. Overwrite (y) or skip (n)?",
723
+ ):
724
+ self.logger.info(f"Skipping Karaoke MP4 remux and Final video renders, existing files will be used.")
725
+ return
726
+
727
+ # Create karaoke version with instrumental audio
728
+ self.remux_with_instrumental(with_vocals_file, input_files["instrumental_audio"], output_files["karaoke_mp4"])
729
+
730
+ # Convert the with vocals video to MP4 if needed
731
+ if not with_vocals_file.endswith(".mp4"):
732
+ self.convert_mov_to_mp4(with_vocals_file, output_files["with_vocals_mp4"])
733
+
734
+ # Delete the with vocals mov after successfully converting it to mp4
735
+ if not self.dry_run and os.path.isfile(with_vocals_file):
736
+ self.logger.info(f"Deleting with vocals MOV file: {with_vocals_file}")
737
+ os.remove(with_vocals_file)
738
+
739
+ # Quote file paths to handle special characters
740
+ title_mov_file = shlex.quote(os.path.abspath(input_files["title_mov"]))
741
+ karaoke_mp4_file = shlex.quote(os.path.abspath(output_files["karaoke_mp4"]))
742
+
743
+ # Prepare concat filter for combining videos
744
+ env_mov_input, ffmpeg_filter = self.prepare_concat_filter(input_files)
745
+
746
+ # Create all output versions
747
+ self.encode_lossless_mp4(title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_files["final_karaoke_lossless_mp4"])
748
+ self.encode_lossy_mp4(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_mp4"])
749
+ self.encode_lossless_mkv(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossless_mkv"])
750
+ self.encode_720p_version(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_720p_mp4"])
751
+
752
+ # Prompt user to check final video files before proceeding
753
+ self.prompt_user_confirmation_or_raise_exception(
754
+ f"Final video files created:\n"
755
+ f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
756
+ f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
757
+ f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
758
+ f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
759
+ f"Please check them! Proceed?",
760
+ "Refusing to proceed without user confirmation they're happy with the Final videos.",
761
+ allow_empty=True,
762
+ )
763
+
764
+ def create_cdg_zip_file(self, input_files, output_files, artist, title):
765
+ self.logger.info(f"Creating CDG and MP3 files, then zipping them...")
766
+
767
+ # Check if CDG file already exists, if so, ask user to overwrite or skip
768
+ if os.path.isfile(output_files["final_karaoke_cdg_zip"]):
769
+ if not self.prompt_user_bool(
770
+ f"Found existing CDG ZIP file: {output_files['final_karaoke_cdg_zip']}. Overwrite (y) or skip (n)?",
771
+ ):
772
+ self.logger.info(f"Skipping CDG ZIP file creation, existing file will be used.")
773
+ return
774
+
775
+ # Check if individual MP3 and CDG files already exist
776
+ if os.path.isfile(output_files["karaoke_mp3"]) and os.path.isfile(output_files["karaoke_cdg"]):
777
+ self.logger.info(f"Found existing MP3 and CDG files, creating ZIP file directly")
778
+ if not self.dry_run:
779
+ with zipfile.ZipFile(output_files["final_karaoke_cdg_zip"], "w") as zipf:
780
+ zipf.write(output_files["karaoke_mp3"], os.path.basename(output_files["karaoke_mp3"]))
781
+ zipf.write(output_files["karaoke_cdg"], os.path.basename(output_files["karaoke_cdg"]))
782
+ self.logger.info(f"Created CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
783
+ return
784
+
785
+ # Generate CDG and MP3 files if they don't exist
786
+ if self.dry_run:
787
+ self.logger.info(f"DRY RUN: Would generate CDG and MP3 files")
788
+ else:
789
+ self.logger.info(f"Generating CDG and MP3 files")
790
+
791
+ if self.cdg_styles is None:
792
+ raise ValueError("CDG styles configuration is required when enable_cdg is True")
793
+
794
+ generator = CDGGenerator(output_dir=os.getcwd(), logger=self.logger)
795
+ cdg_file, mp3_file, zip_file = generator.generate_cdg_from_lrc(
796
+ lrc_file=input_files["karaoke_lrc"],
797
+ audio_file=input_files["instrumental_audio"],
798
+ title=title,
799
+ artist=artist,
800
+ cdg_styles=self.cdg_styles,
801
+ )
802
+
803
+ # Rename the generated ZIP file to match our expected naming convention
804
+ if os.path.isfile(zip_file):
805
+ os.rename(zip_file, output_files["final_karaoke_cdg_zip"])
806
+ self.logger.info(f"Renamed CDG ZIP file from {zip_file} to {output_files['final_karaoke_cdg_zip']}")
807
+
808
+ if not os.path.isfile(output_files["final_karaoke_cdg_zip"]):
809
+ self.logger.error(f"Failed to find any CDG ZIP file. Listing directory contents:")
810
+ for file in os.listdir():
811
+ self.logger.error(f" - {file}")
812
+ raise Exception(f"Failed to create CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
813
+
814
+ self.logger.info(f"CDG ZIP file created: {output_files['final_karaoke_cdg_zip']}")
815
+
816
+ # Extract the CDG ZIP file
817
+ self.logger.info(f"Extracting CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
818
+ with zipfile.ZipFile(output_files["final_karaoke_cdg_zip"], "r") as zip_ref:
819
+ zip_ref.extractall()
820
+
821
+ if os.path.isfile(output_files["karaoke_mp3"]):
822
+ self.logger.info(f"Found extracted MP3 file: {output_files['karaoke_mp3']}")
823
+ else:
824
+ self.logger.error("Failed to find extracted MP3 file")
825
+ raise Exception("Failed to extract MP3 file from CDG ZIP")
826
+
827
+ def create_txt_zip_file(self, input_files, output_files):
828
+ self.logger.info(f"Creating TXT ZIP file...")
829
+
830
+ # Check if TXT file already exists, if so, ask user to overwrite or skip
831
+ if os.path.isfile(output_files["final_karaoke_txt_zip"]):
832
+ if not self.prompt_user_bool(
833
+ f"Found existing TXT ZIP file: {output_files['final_karaoke_txt_zip']}. Overwrite (y) or skip (n)?",
834
+ ):
835
+ self.logger.info(f"Skipping TXT ZIP file creation, existing file will be used.")
836
+ return
837
+
838
+ # Create the ZIP file containing the MP3 and TXT files
839
+ if self.dry_run:
840
+ self.logger.info(f"DRY RUN: Would create TXT ZIP file: {output_files['final_karaoke_txt_zip']}")
841
+ else:
842
+ self.logger.info(f"Running karaoke-converter to convert MidiCo LRC file {input_files['karaoke_lrc']} to TXT format")
843
+ txt_converter = LyricsConverter(output_format="txt", filepath=input_files["karaoke_lrc"])
844
+ converted_txt = txt_converter.convert_file()
845
+
846
+ with open(output_files["karaoke_txt"], "w") as txt_file:
847
+ txt_file.write(converted_txt)
848
+ self.logger.info(f"TXT file written: {output_files['karaoke_txt']}")
849
+
850
+ self.logger.info(f"Creating ZIP file containing {output_files['karaoke_mp3']} and {output_files['karaoke_txt']}")
851
+ with zipfile.ZipFile(output_files["final_karaoke_txt_zip"], "w") as zipf:
852
+ zipf.write(output_files["karaoke_mp3"], os.path.basename(output_files["karaoke_mp3"]))
853
+ zipf.write(output_files["karaoke_txt"], os.path.basename(output_files["karaoke_txt"]))
854
+
855
+ if not os.path.isfile(output_files["final_karaoke_txt_zip"]):
856
+ raise Exception(f"Failed to create TXT ZIP file: {output_files['final_karaoke_txt_zip']}")
857
+
858
+ self.logger.info(f"TXT ZIP file created: {output_files['final_karaoke_txt_zip']}")
859
+
860
+ def move_files_to_brand_code_folder(self, brand_code, artist, title, output_files):
861
+ self.logger.info(f"Moving files to new brand-prefixed directory...")
862
+
863
+ self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
864
+ self.new_brand_code_dir_path = os.path.join(self.organised_dir, self.new_brand_code_dir)
865
+
866
+ # self.prompt_user_confirmation_or_raise_exception(
867
+ # f"Move files to new brand-prefixed directory {self.new_brand_code_dir_path} and delete current dir?",
868
+ # "Refusing to move files without user confirmation of move.",
869
+ # allow_empty=True,
870
+ # )
871
+
872
+ orig_dir = os.getcwd()
873
+ os.chdir(os.path.dirname(orig_dir))
874
+ self.logger.info(f"Changed dir to parent directory: {os.getcwd()}")
875
+
876
+ if self.dry_run:
877
+ self.logger.info(f"DRY RUN: Would move original directory {orig_dir} to: {self.new_brand_code_dir_path}")
878
+ else:
879
+ os.rename(orig_dir, self.new_brand_code_dir_path)
880
+
881
+ # Update output_files dictionary with the new paths after moving
882
+ self.logger.info(f"Updating output file paths to reflect move to {self.new_brand_code_dir_path}")
883
+ for key in output_files:
884
+ if output_files[key]: # Check if the path exists (e.g., optional files)
885
+ old_basename = os.path.basename(output_files[key])
886
+ new_path = os.path.join(self.new_brand_code_dir_path, old_basename)
887
+ output_files[key] = new_path
888
+ self.logger.debug(f" Updated {key}: {new_path}")
889
+
890
+ def copy_final_files_to_public_share_dirs(self, brand_code, base_name, output_files):
891
+ self.logger.info(f"Copying final MP4, 720p MP4, and ZIP to public share directory...")
892
+
893
+ # Validate public_share_dir is a valid folder with MP4, MP4-720p, and CDG subdirectories
894
+ if not os.path.isdir(self.public_share_dir):
895
+ raise Exception(f"Public share directory does not exist: {self.public_share_dir}")
896
+
897
+ if not os.path.isdir(os.path.join(self.public_share_dir, "MP4")):
898
+ raise Exception(f"Public share directory does not contain MP4 subdirectory: {self.public_share_dir}")
899
+
900
+ if not os.path.isdir(os.path.join(self.public_share_dir, "MP4-720p")):
901
+ raise Exception(f"Public share directory does not contain MP4-720p subdirectory: {self.public_share_dir}")
902
+
903
+ if not os.path.isdir(os.path.join(self.public_share_dir, "CDG")):
904
+ raise Exception(f"Public share directory does not contain CDG subdirectory: {self.public_share_dir}")
905
+
906
+ if brand_code is None:
907
+ raise Exception(f"New track prefix was not set, refusing to copy to public share directory")
908
+
909
+ dest_mp4_dir = os.path.join(self.public_share_dir, "MP4")
910
+ dest_720p_dir = os.path.join(self.public_share_dir, "MP4-720p")
911
+ dest_cdg_dir = os.path.join(self.public_share_dir, "CDG")
912
+ os.makedirs(dest_mp4_dir, exist_ok=True)
913
+ os.makedirs(dest_720p_dir, exist_ok=True)
914
+ os.makedirs(dest_cdg_dir, exist_ok=True)
915
+
916
+ dest_mp4_file = os.path.join(dest_mp4_dir, f"{brand_code} - {base_name}.mp4")
917
+ dest_720p_mp4_file = os.path.join(dest_720p_dir, f"{brand_code} - {base_name}.mp4")
918
+ dest_zip_file = os.path.join(dest_cdg_dir, f"{brand_code} - {base_name}.zip")
919
+
920
+ if self.dry_run:
921
+ self.logger.info(
922
+ f"DRY RUN: Would copy final MP4, 720p MP4, and ZIP to {dest_mp4_file}, {dest_720p_mp4_file}, and {dest_zip_file}"
923
+ )
924
+ else:
925
+ shutil.copy2(output_files["final_karaoke_lossy_mp4"], dest_mp4_file) # Changed to use lossy MP4
926
+ shutil.copy2(output_files["final_karaoke_lossy_720p_mp4"], dest_720p_mp4_file)
927
+ shutil.copy2(output_files["final_karaoke_cdg_zip"], dest_zip_file)
928
+ self.logger.info(f"Copied final files to public share directory")
929
+
930
+ def sync_public_share_dir_to_rclone_destination(self):
931
+ self.logger.info(f"Syncing public share directory to rclone destination...")
932
+
933
+ # Delete .DS_Store files recursively before syncing
934
+ for root, dirs, files in os.walk(self.public_share_dir):
935
+ for file in files:
936
+ if file == ".DS_Store":
937
+ file_path = os.path.join(root, file)
938
+ os.remove(file_path)
939
+ self.logger.info(f"Deleted .DS_Store file: {file_path}")
940
+
941
+ rclone_cmd = f"rclone sync -v '{self.public_share_dir}' '{self.rclone_destination}'"
942
+ self.execute_command(rclone_cmd, "Syncing with cloud destination")
943
+
944
+ def post_discord_notification(self):
945
+ self.logger.info(f"Posting Discord notification...")
946
+
947
+ if self.skip_notifications:
948
+ self.logger.info(f"Skipping Discord notification as video was previously uploaded to YouTube")
949
+ return
950
+
951
+ if self.dry_run:
952
+ self.logger.info(
953
+ f"DRY RUN: Would post Discord notification for youtube URL {self.youtube_url} using webhook URL: {self.discord_webhook_url}"
954
+ )
955
+ else:
956
+ discord_message = f"New upload: {self.youtube_url}"
957
+ self.post_discord_message(discord_message, self.discord_webhook_url)
958
+
959
+ def generate_organised_folder_sharing_link(self):
960
+ self.logger.info(f"Getting Organised Folder sharing link for new brand code directory...")
961
+
962
+ rclone_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
963
+ rclone_link_cmd = f"rclone link {shlex.quote(rclone_dest)}"
964
+
965
+ if self.dry_run:
966
+ self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
967
+ return "https://file-sharing-service.com/example"
968
+
969
+ # Add a 5-second delay to allow dropbox to index the folder before generating a link
970
+ self.logger.info("Waiting 5 seconds before generating link...")
971
+ time.sleep(5)
972
+
973
+ try:
974
+ self.logger.info(f"Running command: {rclone_link_cmd}")
975
+ result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
976
+ self.brand_code_dir_sharing_link = result.stdout.strip()
977
+ self.logger.info(f"Got organised folder sharing link: {self.brand_code_dir_sharing_link}")
978
+ except subprocess.CalledProcessError as e:
979
+ self.logger.error(f"Failed to get organised folder sharing link. Exit code: {e.returncode}")
980
+ self.logger.error(f"Command output (stdout): {e.stdout}")
981
+ self.logger.error(f"Command output (stderr): {e.stderr}")
982
+ self.logger.error(f"Full exception: {e}")
983
+
984
+ def get_existing_brand_code(self):
985
+ """Extract brand code from current directory name"""
986
+ current_dir = os.path.basename(os.getcwd())
987
+ if " - " not in current_dir:
988
+ raise Exception(f"Current directory '{current_dir}' does not match expected format 'BRAND-XXXX - Artist - Title'")
989
+
990
+ brand_code = current_dir.split(" - ")[0]
991
+ if not brand_code or "-" not in brand_code:
992
+ raise Exception(f"Could not extract valid brand code from directory name '{current_dir}'")
993
+
994
+ self.logger.info(f"Using existing brand code: {brand_code}")
995
+ return brand_code
996
+
997
+ def execute_optional_features(self, artist, title, base_name, input_files, output_files, replace_existing=False):
998
+ self.logger.info(f"Executing optional features...")
999
+
1000
+ if self.youtube_upload_enabled:
1001
+ try:
1002
+ self.upload_final_mp4_to_youtube_with_title_thumbnail(artist, title, input_files, output_files, replace_existing)
1003
+ except Exception as e:
1004
+ self.logger.error(f"Failed to upload video to YouTube: {e}")
1005
+ print("Please manually upload the video to YouTube.")
1006
+ print()
1007
+ self.youtube_video_id = input("Enter the manually uploaded YouTube video ID: ").strip()
1008
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
1009
+ self.logger.info(f"Using manually provided YouTube video ID: {self.youtube_video_id}")
1010
+
1011
+ if self.discord_notication_enabled:
1012
+ self.post_discord_notification()
1013
+
1014
+ if self.folder_organisation_enabled:
1015
+ if self.keep_brand_code:
1016
+ self.brand_code = self.get_existing_brand_code()
1017
+ self.new_brand_code_dir = os.path.basename(os.getcwd())
1018
+ self.new_brand_code_dir_path = os.getcwd()
1019
+ else:
1020
+ self.brand_code = self.get_next_brand_code()
1021
+ self.move_files_to_brand_code_folder(self.brand_code, artist, title, output_files)
1022
+ # Update output file paths after moving
1023
+ for key in output_files:
1024
+ output_files[key] = os.path.join(self.new_brand_code_dir_path, os.path.basename(output_files[key]))
1025
+
1026
+ if self.public_share_copy_enabled:
1027
+ self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
1028
+
1029
+ if self.public_share_rclone_enabled:
1030
+ self.sync_public_share_dir_to_rclone_destination()
1031
+
1032
+ self.generate_organised_folder_sharing_link()
1033
+
1034
+ def authenticate_gmail(self):
1035
+ """Authenticate and return a Gmail service object."""
1036
+ creds = None
1037
+ gmail_token_file = "/tmp/karaoke-finalise-gmail-token.pickle"
1038
+
1039
+ if os.path.exists(gmail_token_file):
1040
+ with open(gmail_token_file, "rb") as token:
1041
+ creds = pickle.load(token)
1042
+
1043
+ if not creds or not creds.valid:
1044
+ if creds and creds.expired and creds.refresh_token:
1045
+ creds.refresh(Request())
1046
+ else:
1047
+ flow = InstalledAppFlow.from_client_secrets_file(
1048
+ self.youtube_client_secrets_file, ["https://www.googleapis.com/auth/gmail.compose"]
1049
+ )
1050
+ creds = flow.run_local_server(port=0)
1051
+ with open(gmail_token_file, "wb") as token:
1052
+ pickle.dump(creds, token)
1053
+
1054
+ return build("gmail", "v1", credentials=creds)
1055
+
1056
+ def draft_completion_email(self, artist, title, youtube_url, dropbox_url):
1057
+ if not self.email_template_file:
1058
+ self.logger.info("Email template file not provided, skipping email draft creation.")
1059
+ return
1060
+
1061
+ with open(self.email_template_file, "r") as f:
1062
+ template = f.read()
1063
+
1064
+ email_body = template.format(youtube_url=youtube_url, dropbox_url=dropbox_url)
1065
+
1066
+ subject = f"{self.brand_code}: {artist} - {title}"
1067
+
1068
+ if self.dry_run:
1069
+ self.logger.info(f"DRY RUN: Would create email draft with subject: {subject}")
1070
+ self.logger.info(f"DRY RUN: Email body:\n{email_body}")
1071
+ else:
1072
+ if not self.gmail_service:
1073
+ self.gmail_service = self.authenticate_gmail()
1074
+
1075
+ message = MIMEText(email_body)
1076
+ message["subject"] = subject
1077
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
1078
+ draft = self.gmail_service.users().drafts().create(userId="me", body={"message": {"raw": raw_message}}).execute()
1079
+ self.logger.info(f"Email draft created with ID: {draft['id']}")
1080
+
1081
+ def test_email_template(self):
1082
+ if not self.email_template_file:
1083
+ self.logger.error("Email template file not provided. Use --email_template_file to specify the file path.")
1084
+ return
1085
+
1086
+ fake_artist = "Test Artist"
1087
+ fake_title = "Test Song"
1088
+ fake_youtube_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
1089
+ fake_dropbox_url = "https://www.dropbox.com/sh/fake/folder/link"
1090
+ fake_brand_code = "TEST-0001"
1091
+
1092
+ self.brand_code = fake_brand_code
1093
+ self.draft_completion_email(fake_artist, fake_title, fake_youtube_url, fake_dropbox_url)
1094
+
1095
+ self.logger.info("Email template test complete. Check your Gmail drafts for the test email.")
1096
+
1097
+ def detect_best_aac_codec(self):
1098
+ """Detect the best available AAC codec (aac_at > libfdk_aac > aac)"""
1099
+ self.logger.info("Detecting best available AAC codec...")
1100
+
1101
+ codec_check_command = f"{self.ffmpeg_base_command} -codecs"
1102
+ result = os.popen(codec_check_command).read()
1103
+
1104
+ if "aac_at" in result:
1105
+ self.logger.info("Using aac_at codec (best quality)")
1106
+ return "aac_at"
1107
+ elif "libfdk_aac" in result:
1108
+ self.logger.info("Using libfdk_aac codec (good quality)")
1109
+ return "libfdk_aac"
1110
+ else:
1111
+ self.logger.info("Using built-in aac codec (basic quality)")
1112
+ return "aac"
1113
+
1114
+ def process(self, replace_existing=False):
1115
+ if self.dry_run:
1116
+ self.logger.warning("Dry run enabled. No actions will be performed.")
1117
+
1118
+ # Check required input files and parameters exist, get user to confirm features before proceeding
1119
+ self.validate_input_parameters_for_features()
1120
+
1121
+ with_vocals_file = self.find_with_vocals_file()
1122
+ base_name, artist, title = self.get_names_from_withvocals(with_vocals_file)
1123
+
1124
+ instrumental_audio_file = self.choose_instrumental_audio_file(base_name)
1125
+
1126
+ input_files = self.check_input_files_exist(base_name, with_vocals_file, instrumental_audio_file)
1127
+ output_files = self.prepare_output_filenames(base_name)
1128
+
1129
+ if self.enable_cdg:
1130
+ self.create_cdg_zip_file(input_files, output_files, artist, title)
1131
+
1132
+ if self.enable_txt:
1133
+ self.create_txt_zip_file(input_files, output_files)
1134
+
1135
+ self.remux_and_encode_output_video_files(with_vocals_file, input_files, output_files)
1136
+
1137
+ self.execute_optional_features(artist, title, base_name, input_files, output_files, replace_existing)
1138
+
1139
+ result = {
1140
+ "artist": artist,
1141
+ "title": title,
1142
+ "video_with_vocals": output_files["with_vocals_mp4"],
1143
+ "video_with_instrumental": output_files["karaoke_mp4"],
1144
+ "final_video": output_files["final_karaoke_lossless_mp4"],
1145
+ "final_video_mkv": output_files["final_karaoke_lossless_mkv"],
1146
+ "final_video_lossy": output_files["final_karaoke_lossy_mp4"],
1147
+ "final_video_720p": output_files["final_karaoke_lossy_720p_mp4"],
1148
+ "youtube_url": self.youtube_url,
1149
+ "brand_code": self.brand_code,
1150
+ "new_brand_code_dir_path": self.new_brand_code_dir_path,
1151
+ "brand_code_dir_sharing_link": self.brand_code_dir_sharing_link,
1152
+ }
1153
+
1154
+ if self.enable_cdg:
1155
+ result["final_karaoke_cdg_zip"] = output_files["final_karaoke_cdg_zip"]
1156
+
1157
+ if self.enable_txt:
1158
+ result["final_karaoke_txt_zip"] = output_files["final_karaoke_txt_zip"]
1159
+
1160
+ if self.email_template_file:
1161
+ self.draft_completion_email(artist, title, result["youtube_url"], result["brand_code_dir_sharing_link"])
1162
+
1163
+ return result