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.
- karaoke_gen-0.50.0.dist-info/LICENSE +21 -0
- karaoke_gen-0.50.0.dist-info/METADATA +140 -0
- karaoke_gen-0.50.0.dist-info/RECORD +23 -0
- karaoke_gen-0.50.0.dist-info/WHEEL +4 -0
- karaoke_gen-0.50.0.dist-info/entry_points.txt +4 -0
- karaoke_prep/__init__.py +1 -0
- karaoke_prep/audio_processor.py +396 -0
- karaoke_prep/config.py +134 -0
- karaoke_prep/file_handler.py +186 -0
- karaoke_prep/karaoke_finalise/__init__.py +1 -0
- karaoke_prep/karaoke_finalise/karaoke_finalise.py +1163 -0
- karaoke_prep/karaoke_prep.py +687 -0
- karaoke_prep/lyrics_processor.py +225 -0
- karaoke_prep/metadata.py +105 -0
- karaoke_prep/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_prep/resources/Montserrat-Bold.ttf +0 -0
- karaoke_prep/resources/Oswald-Bold.ttf +0 -0
- karaoke_prep/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_prep/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_prep/utils/__init__.py +18 -0
- karaoke_prep/utils/bulk_cli.py +483 -0
- karaoke_prep/utils/gen_cli.py +873 -0
- karaoke_prep/video_generator.py +424 -0
|
@@ -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
|