karaoke-gen 0.55.0__tar.gz → 0.57.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of karaoke-gen might be problematic. Click here for more details.
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/PKG-INFO +5 -3
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/audio_processor.py +13 -13
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/file_handler.py +50 -14
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/karaoke_finalise/karaoke_finalise.py +552 -55
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/karaoke_gen.py +11 -4
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/lyrics_processor.py +49 -4
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/metadata.py +71 -21
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/pyproject.toml +6 -4
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/LICENSE +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/README.md +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/__init__.py +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/config.py +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/karaoke_finalise/__init__.py +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Oswald-Bold.ttf +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/utils/__init__.py +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/utils/bulk_cli.py +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/utils/gen_cli.py +0 -0
- {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/video_generator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: karaoke-gen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.57.0
|
|
4
4
|
Summary: Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens.
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Andrew Beveridge
|
|
@@ -25,14 +25,16 @@ Requires-Dist: google-auth-httplib2
|
|
|
25
25
|
Requires-Dist: google-auth-oauthlib
|
|
26
26
|
Requires-Dist: kbputils (>=0.0.16,<0.0.17)
|
|
27
27
|
Requires-Dist: lyrics-converter (>=0.2.1)
|
|
28
|
-
Requires-Dist: lyrics-transcriber (>=0.
|
|
28
|
+
Requires-Dist: lyrics-transcriber (>=0.61)
|
|
29
29
|
Requires-Dist: lyricsgenius (>=3)
|
|
30
|
+
Requires-Dist: modal (>=1.0.5,<2.0.0)
|
|
30
31
|
Requires-Dist: numpy (>=2)
|
|
31
32
|
Requires-Dist: pillow (>=10.1)
|
|
32
33
|
Requires-Dist: psutil (>=7.0.0,<8.0.0)
|
|
33
34
|
Requires-Dist: pyinstaller (>=6.3)
|
|
34
35
|
Requires-Dist: pyperclip
|
|
35
|
-
Requires-Dist: pytest-asyncio
|
|
36
|
+
Requires-Dist: pytest-asyncio
|
|
37
|
+
Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
|
|
36
38
|
Requires-Dist: requests (>=2)
|
|
37
39
|
Requires-Dist: thefuzz (>=0.22)
|
|
38
40
|
Requires-Dist: toml (>=0.10)
|
|
@@ -72,11 +72,11 @@ class AudioProcessor:
|
|
|
72
72
|
|
|
73
73
|
for file in output_files:
|
|
74
74
|
if "(Vocals)" in file:
|
|
75
|
-
self.logger.info(f"
|
|
76
|
-
|
|
75
|
+
self.logger.info(f"Moving Vocals file {file} to {vocals_path}")
|
|
76
|
+
shutil.move(file, vocals_path)
|
|
77
77
|
elif "(Instrumental)" in file:
|
|
78
|
-
self.logger.info(f"
|
|
79
|
-
|
|
78
|
+
self.logger.info(f"Moving Instrumental file {file} to {instrumental_path}")
|
|
79
|
+
shutil.move(file, instrumental_path)
|
|
80
80
|
elif model_name in file:
|
|
81
81
|
# Example filename 1: "Freddie Jackson - All I'll Ever Ask (feat. Najee) (Local)_(Piano)_htdemucs_6s.flac"
|
|
82
82
|
# Example filename 2: "Freddie Jackson - All I'll Ever Ask (feat. Najee) (Local)_(Guitar)_htdemucs_6s.flac"
|
|
@@ -86,8 +86,8 @@ class AudioProcessor:
|
|
|
86
86
|
stem_name = stem_name.strip("()") # Remove parentheses if present
|
|
87
87
|
|
|
88
88
|
other_stem_path = os.path.join(track_output_dir, f"{artist_title} ({stem_name} {model_name}).{self.lossless_output_format}")
|
|
89
|
-
self.logger.info(f"
|
|
90
|
-
|
|
89
|
+
self.logger.info(f"Moving other stem file {file} to {other_stem_path}")
|
|
90
|
+
shutil.move(file, other_stem_path)
|
|
91
91
|
|
|
92
92
|
elif model_name_no_extension in file:
|
|
93
93
|
# Example filename 1: "Freddie Jackson - All I'll Ever Ask (feat. Najee) (Local)_(Piano)_htdemucs_6s.flac"
|
|
@@ -98,8 +98,8 @@ class AudioProcessor:
|
|
|
98
98
|
stem_name = stem_name.strip("()") # Remove parentheses if present
|
|
99
99
|
|
|
100
100
|
other_stem_path = os.path.join(track_output_dir, f"{artist_title} ({stem_name} {model_name}).{self.lossless_output_format}")
|
|
101
|
-
self.logger.info(f"
|
|
102
|
-
|
|
101
|
+
self.logger.info(f"Moving other stem file {file} to {other_stem_path}")
|
|
102
|
+
shutil.move(file, other_stem_path)
|
|
103
103
|
|
|
104
104
|
self.logger.info(f"Separation complete! Output file(s): {vocals_path} {instrumental_path}")
|
|
105
105
|
|
|
@@ -262,10 +262,10 @@ class AudioProcessor:
|
|
|
262
262
|
|
|
263
263
|
for file in clean_output_files:
|
|
264
264
|
if "(Vocals)" in file and not self._file_exists(vocals_path):
|
|
265
|
-
|
|
265
|
+
shutil.move(file, vocals_path)
|
|
266
266
|
result["vocals"] = vocals_path
|
|
267
267
|
elif "(Instrumental)" in file and not self._file_exists(instrumental_path):
|
|
268
|
-
|
|
268
|
+
shutil.move(file, instrumental_path)
|
|
269
269
|
result["instrumental"] = instrumental_path
|
|
270
270
|
else:
|
|
271
271
|
result["vocals"] = vocals_path
|
|
@@ -298,7 +298,7 @@ class AudioProcessor:
|
|
|
298
298
|
new_filename = f"{artist_title} ({stem_name} {model}).{self.lossless_output_format}"
|
|
299
299
|
other_stem_path = os.path.join(stems_dir, new_filename)
|
|
300
300
|
if not self._file_exists(other_stem_path):
|
|
301
|
-
|
|
301
|
+
shutil.move(file, other_stem_path)
|
|
302
302
|
result[model][stem_name] = other_stem_path
|
|
303
303
|
|
|
304
304
|
return result
|
|
@@ -318,10 +318,10 @@ class AudioProcessor:
|
|
|
318
318
|
|
|
319
319
|
for file in backing_vocals_output:
|
|
320
320
|
if "(Vocals)" in file and not self._file_exists(lead_vocals_path):
|
|
321
|
-
|
|
321
|
+
shutil.move(file, lead_vocals_path)
|
|
322
322
|
result[model]["lead_vocals"] = lead_vocals_path
|
|
323
323
|
elif "(Instrumental)" in file and not self._file_exists(backing_vocals_path):
|
|
324
|
-
|
|
324
|
+
shutil.move(file, backing_vocals_path)
|
|
325
325
|
result[model]["backing_vocals"] = backing_vocals_path
|
|
326
326
|
else:
|
|
327
327
|
result[model]["lead_vocals"] = lead_vocals_path
|
|
@@ -39,28 +39,64 @@ class FileHandler:
|
|
|
39
39
|
|
|
40
40
|
return copied_file_name
|
|
41
41
|
|
|
42
|
-
def download_video(self, url, output_filename_no_extension):
|
|
42
|
+
def download_video(self, url, output_filename_no_extension, cookies_str=None):
|
|
43
43
|
self.logger.debug(f"Downloading media from URL {url} to filename {output_filename_no_extension} + (as yet) unknown extension")
|
|
44
44
|
|
|
45
45
|
ydl_opts = {
|
|
46
46
|
"quiet": True,
|
|
47
47
|
"format": "bv*+ba/b", # if a combined video + audio format is better than the best video-only format use the combined format
|
|
48
48
|
"outtmpl": f"{output_filename_no_extension}.%(ext)s",
|
|
49
|
-
|
|
49
|
+
# Enhanced anti-detection options
|
|
50
|
+
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
51
|
+
"referer": "https://www.youtube.com/",
|
|
52
|
+
"sleep_interval": 1,
|
|
53
|
+
"max_sleep_interval": 3,
|
|
54
|
+
"fragment_retries": 3,
|
|
55
|
+
"extractor_retries": 3,
|
|
56
|
+
"retries": 3,
|
|
57
|
+
# Headers to appear more human
|
|
58
|
+
"http_headers": {
|
|
59
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
60
|
+
"Accept-Language": "en-us,en;q=0.5",
|
|
61
|
+
"Accept-Encoding": "gzip, deflate",
|
|
62
|
+
"DNT": "1",
|
|
63
|
+
"Connection": "keep-alive",
|
|
64
|
+
"Upgrade-Insecure-Requests": "1",
|
|
65
|
+
},
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
#
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
# Add cookies if provided
|
|
69
|
+
if cookies_str:
|
|
70
|
+
self.logger.info("Using provided cookies for enhanced YouTube download access")
|
|
71
|
+
# Save cookies to a temporary file
|
|
72
|
+
import tempfile
|
|
73
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
|
74
|
+
f.write(cookies_str)
|
|
75
|
+
ydl_opts['cookiefile'] = f.name
|
|
76
|
+
else:
|
|
77
|
+
self.logger.info("No cookies provided for download - attempting standard download")
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with ydl(ydl_opts) as ydl_instance:
|
|
81
|
+
ydl_instance.download([url])
|
|
82
|
+
|
|
83
|
+
# Search for the file with any extension
|
|
84
|
+
downloaded_files = glob.glob(f"{output_filename_no_extension}.*")
|
|
85
|
+
if downloaded_files:
|
|
86
|
+
downloaded_file_name = downloaded_files[0] # Assume the first match is the correct one
|
|
87
|
+
self.logger.info(f"Download finished, returning downloaded filename: {downloaded_file_name}")
|
|
88
|
+
return downloaded_file_name
|
|
89
|
+
else:
|
|
90
|
+
self.logger.error("No files found matching the download pattern.")
|
|
91
|
+
return None
|
|
92
|
+
finally:
|
|
93
|
+
# Clean up temporary cookie file if it was created
|
|
94
|
+
if cookies_str and 'cookiefile' in ydl_opts:
|
|
95
|
+
try:
|
|
96
|
+
import os
|
|
97
|
+
os.unlink(ydl_opts['cookiefile'])
|
|
98
|
+
except:
|
|
99
|
+
pass
|
|
64
100
|
|
|
65
101
|
def extract_still_image_from_video(self, input_filename, output_filename_no_extension):
|
|
66
102
|
output_filename = output_filename_no_extension + ".png"
|
|
@@ -44,6 +44,8 @@ class KaraokeFinalise:
|
|
|
44
44
|
cdg_styles=None,
|
|
45
45
|
keep_brand_code=False,
|
|
46
46
|
non_interactive=False,
|
|
47
|
+
user_youtube_credentials=None, # Add support for pre-stored credentials
|
|
48
|
+
server_side_mode=False, # New parameter for server-side deployment
|
|
47
49
|
):
|
|
48
50
|
self.log_level = log_level
|
|
49
51
|
self.log_formatter = log_formatter
|
|
@@ -99,6 +101,8 @@ class KaraokeFinalise:
|
|
|
99
101
|
|
|
100
102
|
self.skip_notifications = False
|
|
101
103
|
self.non_interactive = non_interactive
|
|
104
|
+
self.user_youtube_credentials = user_youtube_credentials
|
|
105
|
+
self.server_side_mode = server_side_mode
|
|
102
106
|
|
|
103
107
|
self.suffixes = {
|
|
104
108
|
"title_mov": " (Title).mov",
|
|
@@ -146,6 +150,10 @@ class KaraokeFinalise:
|
|
|
146
150
|
if self.non_interactive:
|
|
147
151
|
self.ffmpeg_base_command += " -y"
|
|
148
152
|
|
|
153
|
+
# Detect and configure hardware acceleration
|
|
154
|
+
self.nvenc_available = self.detect_nvenc_support()
|
|
155
|
+
self.configure_hardware_acceleration()
|
|
156
|
+
|
|
149
157
|
def check_input_files_exist(self, base_name, with_vocals_file, instrumental_audio_file):
|
|
150
158
|
self.logger.info(f"Checking required input files exist...")
|
|
151
159
|
|
|
@@ -256,12 +264,17 @@ class KaraokeFinalise:
|
|
|
256
264
|
self.discord_notication_enabled = True
|
|
257
265
|
|
|
258
266
|
# Enable folder organisation if brand prefix and target directory are provided and target directory is valid
|
|
267
|
+
# In server-side mode, we skip the local folder organization but may still need brand codes
|
|
259
268
|
if self.brand_prefix is not None and self.organised_dir is not None:
|
|
260
|
-
if not os.path.isdir(self.organised_dir):
|
|
269
|
+
if not self.server_side_mode and not os.path.isdir(self.organised_dir):
|
|
261
270
|
raise Exception(f"Target directory does not exist: {self.organised_dir}")
|
|
262
271
|
|
|
263
|
-
self.
|
|
264
|
-
|
|
272
|
+
if not self.server_side_mode:
|
|
273
|
+
self.logger.debug(f"Brand prefix and target directory provided, enabling local folder organisation")
|
|
274
|
+
self.folder_organisation_enabled = True
|
|
275
|
+
else:
|
|
276
|
+
self.logger.debug(f"Server-side mode: brand prefix provided for remote organization")
|
|
277
|
+
self.folder_organisation_enabled = False # Disable local folder organization in server mode
|
|
265
278
|
|
|
266
279
|
# Enable public share copy if public share directory is provided and is valid directory with MP4 and CDG subdirectories
|
|
267
280
|
if self.public_share_dir is not None:
|
|
@@ -292,18 +305,61 @@ class KaraokeFinalise:
|
|
|
292
305
|
self.logger.info(f" Public share copy: {self.public_share_copy_enabled}")
|
|
293
306
|
self.logger.info(f" Public share rclone: {self.public_share_rclone_enabled}")
|
|
294
307
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
308
|
+
# Skip user confirmation in non-interactive mode for Modal deployment
|
|
309
|
+
if not self.non_interactive:
|
|
310
|
+
self.prompt_user_confirmation_or_raise_exception(
|
|
311
|
+
f"Confirm features enabled log messages above match your expectations for finalisation?",
|
|
312
|
+
"Refusing to proceed without user confirmation they're happy with enabled features.",
|
|
313
|
+
allow_empty=True,
|
|
314
|
+
)
|
|
315
|
+
else:
|
|
316
|
+
self.logger.info("Non-interactive mode: automatically confirming enabled features")
|
|
300
317
|
|
|
301
318
|
def authenticate_youtube(self):
|
|
302
|
-
"""Authenticate and return
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
319
|
+
"""Authenticate with YouTube and return service object."""
|
|
320
|
+
from google.auth.transport.requests import Request
|
|
321
|
+
from google.oauth2.credentials import Credentials
|
|
322
|
+
from googleapiclient.discovery import build
|
|
323
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
324
|
+
import pickle
|
|
325
|
+
import os
|
|
326
|
+
|
|
327
|
+
# Check if we have pre-stored credentials (for non-interactive mode)
|
|
328
|
+
if self.user_youtube_credentials and self.non_interactive:
|
|
329
|
+
try:
|
|
330
|
+
# Create credentials object from stored data
|
|
331
|
+
credentials = Credentials(
|
|
332
|
+
token=self.user_youtube_credentials['token'],
|
|
333
|
+
refresh_token=self.user_youtube_credentials.get('refresh_token'),
|
|
334
|
+
token_uri=self.user_youtube_credentials.get('token_uri'),
|
|
335
|
+
client_id=self.user_youtube_credentials.get('client_id'),
|
|
336
|
+
client_secret=self.user_youtube_credentials.get('client_secret'),
|
|
337
|
+
scopes=self.user_youtube_credentials.get('scopes')
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Refresh token if needed
|
|
341
|
+
if credentials.expired and credentials.refresh_token:
|
|
342
|
+
credentials.refresh(Request())
|
|
343
|
+
|
|
344
|
+
# Build YouTube service with credentials
|
|
345
|
+
youtube = build('youtube', 'v3', credentials=credentials)
|
|
346
|
+
self.logger.info("Successfully authenticated with YouTube using pre-stored credentials")
|
|
347
|
+
return youtube
|
|
348
|
+
|
|
349
|
+
except Exception as e:
|
|
350
|
+
self.logger.error(f"Failed to authenticate with pre-stored credentials: {str(e)}")
|
|
351
|
+
# Fall through to original authentication if pre-stored credentials fail
|
|
352
|
+
|
|
353
|
+
# Original authentication code for interactive mode
|
|
354
|
+
if self.non_interactive:
|
|
355
|
+
raise Exception("YouTube authentication required but running in non-interactive mode. Please pre-authenticate or disable YouTube upload.")
|
|
356
|
+
|
|
306
357
|
# Token file stores the user's access and refresh tokens for YouTube.
|
|
358
|
+
youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
|
|
359
|
+
|
|
360
|
+
credentials = None
|
|
361
|
+
|
|
362
|
+
# Check if we have saved credentials
|
|
307
363
|
if os.path.exists(youtube_token_file):
|
|
308
364
|
with open(youtube_token_file, "rb") as token:
|
|
309
365
|
credentials = pickle.load(token)
|
|
@@ -313,6 +369,9 @@ class KaraokeFinalise:
|
|
|
313
369
|
if credentials and credentials.expired and credentials.refresh_token:
|
|
314
370
|
credentials.refresh(Request())
|
|
315
371
|
else:
|
|
372
|
+
if self.non_interactive:
|
|
373
|
+
raise Exception("YouTube authentication required but running in non-interactive mode. Please pre-authenticate or disable YouTube upload.")
|
|
374
|
+
|
|
316
375
|
flow = InstalledAppFlow.from_client_secrets_file(
|
|
317
376
|
self.youtube_client_secrets_file, scopes=["https://www.googleapis.com/auth/youtube"]
|
|
318
377
|
)
|
|
@@ -631,74 +690,130 @@ class KaraokeFinalise:
|
|
|
631
690
|
return base_name, artist, title
|
|
632
691
|
|
|
633
692
|
def execute_command(self, command, description):
|
|
634
|
-
|
|
693
|
+
"""Execute a shell command and log the output. For general commands (rclone, etc.)"""
|
|
694
|
+
self.logger.info(f"{description}")
|
|
695
|
+
self.logger.debug(f"Executing command: {command}")
|
|
696
|
+
|
|
635
697
|
if self.dry_run:
|
|
636
|
-
self.logger.info(f"DRY RUN: Would
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
698
|
+
self.logger.info(f"DRY RUN: Would execute: {command}")
|
|
699
|
+
return
|
|
700
|
+
|
|
701
|
+
try:
|
|
702
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=600)
|
|
703
|
+
|
|
704
|
+
# Log command output for debugging
|
|
705
|
+
if result.stdout and result.stdout.strip():
|
|
706
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
707
|
+
if result.stderr and result.stderr.strip():
|
|
708
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
709
|
+
|
|
710
|
+
if result.returncode != 0:
|
|
711
|
+
error_msg = f"Command failed with exit code {result.returncode}"
|
|
712
|
+
self.logger.error(error_msg)
|
|
713
|
+
self.logger.error(f"Command: {command}")
|
|
714
|
+
if result.stdout:
|
|
715
|
+
self.logger.error(f"STDOUT: {result.stdout}")
|
|
716
|
+
if result.stderr:
|
|
717
|
+
self.logger.error(f"STDERR: {result.stderr}")
|
|
718
|
+
raise Exception(f"{error_msg}: {command}")
|
|
719
|
+
else:
|
|
720
|
+
self.logger.info(f"✓ Command completed successfully")
|
|
721
|
+
|
|
722
|
+
except subprocess.TimeoutExpired:
|
|
723
|
+
error_msg = f"Command timed out after 600 seconds"
|
|
724
|
+
self.logger.error(error_msg)
|
|
725
|
+
raise Exception(f"{error_msg}: {command}")
|
|
726
|
+
except Exception as e:
|
|
727
|
+
if "Command failed" not in str(e):
|
|
728
|
+
error_msg = f"Command failed with exception: {e}"
|
|
729
|
+
self.logger.error(error_msg)
|
|
730
|
+
raise Exception(f"{error_msg}: {command}")
|
|
731
|
+
else:
|
|
732
|
+
raise
|
|
640
733
|
|
|
641
734
|
def remux_with_instrumental(self, with_vocals_file, instrumental_audio, output_file):
|
|
642
735
|
"""Remux the video with instrumental audio to create karaoke version"""
|
|
643
|
-
#
|
|
736
|
+
# This operation is primarily I/O bound (remuxing), so hardware acceleration doesn't provide significant benefit
|
|
737
|
+
# Keep the existing approach but use the new execute method
|
|
644
738
|
ffmpeg_command = (
|
|
645
739
|
f'{self.ffmpeg_base_command} -an -i "{with_vocals_file}" '
|
|
646
740
|
f'-vn -i "{instrumental_audio}" -c:v copy -c:a pcm_s16le "{output_file}"'
|
|
647
741
|
)
|
|
648
|
-
# fmt: on
|
|
649
742
|
self.execute_command(ffmpeg_command, "Remuxing video with instrumental audio")
|
|
650
743
|
|
|
651
744
|
def convert_mov_to_mp4(self, input_file, output_file):
|
|
652
|
-
"""Convert MOV file to MP4 format"""
|
|
653
|
-
#
|
|
654
|
-
|
|
745
|
+
"""Convert MOV file to MP4 format with hardware acceleration support"""
|
|
746
|
+
# Hardware-accelerated version
|
|
747
|
+
gpu_command = (
|
|
748
|
+
f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
|
|
749
|
+
f'-c:v {self.video_encoder} {self.get_nvenc_quality_settings("high")} -c:a {self.aac_codec} {self.mp4_flags} "{output_file}"'
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
# Software fallback version
|
|
753
|
+
cpu_command = (
|
|
655
754
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
656
755
|
f'-c:v libx264 -c:a {self.aac_codec} {self.mp4_flags} "{output_file}"'
|
|
657
756
|
)
|
|
658
|
-
|
|
659
|
-
self.
|
|
757
|
+
|
|
758
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Converting MOV video to MP4")
|
|
660
759
|
|
|
661
760
|
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
|
-
#
|
|
664
|
-
|
|
761
|
+
"""Create the final MP4 with PCM audio (lossless) using hardware acceleration when available"""
|
|
762
|
+
# Hardware-accelerated version
|
|
763
|
+
gpu_command = (
|
|
764
|
+
f"{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i {title_mov_file} "
|
|
765
|
+
f"{self.hwaccel_decode_flags} -i {karaoke_mp4_file} {env_mov_input} "
|
|
766
|
+
f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v {self.video_encoder} '
|
|
767
|
+
f'{self.get_nvenc_quality_settings("lossless")} -c:a pcm_s16le {self.mp4_flags} "{output_file}"'
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# Software fallback version
|
|
771
|
+
cpu_command = (
|
|
665
772
|
f"{self.ffmpeg_base_command} -i {title_mov_file} -i {karaoke_mp4_file} {env_mov_input} "
|
|
666
773
|
f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
|
|
667
774
|
f'{self.mp4_flags} "{output_file}"'
|
|
668
775
|
)
|
|
669
|
-
|
|
670
|
-
self.
|
|
776
|
+
|
|
777
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Creating MP4 version with PCM audio")
|
|
671
778
|
|
|
672
779
|
def encode_lossy_mp4(self, input_file, output_file):
|
|
673
780
|
"""Create MP4 with AAC audio (lossy, for wider compatibility)"""
|
|
674
|
-
#
|
|
781
|
+
# This is primarily an audio re-encoding operation, video is copied
|
|
782
|
+
# Hardware acceleration doesn't provide significant benefit for copy operations
|
|
675
783
|
ffmpeg_command = (
|
|
676
784
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
677
785
|
f'-c:v copy -c:a {self.aac_codec} -b:a 320k {self.mp4_flags} "{output_file}"'
|
|
678
786
|
)
|
|
679
|
-
# fmt: on
|
|
680
787
|
self.execute_command(ffmpeg_command, "Creating MP4 version with AAC audio")
|
|
681
788
|
|
|
682
789
|
def encode_lossless_mkv(self, input_file, output_file):
|
|
683
790
|
"""Create MKV with FLAC audio (for YouTube)"""
|
|
684
|
-
#
|
|
791
|
+
# This is primarily an audio re-encoding operation, video is copied
|
|
792
|
+
# Hardware acceleration doesn't provide significant benefit for copy operations
|
|
685
793
|
ffmpeg_command = (
|
|
686
794
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
687
795
|
f'-c:v copy -c:a flac "{output_file}"'
|
|
688
796
|
)
|
|
689
|
-
# fmt: on
|
|
690
797
|
self.execute_command(ffmpeg_command, "Creating MKV version with FLAC audio for YouTube")
|
|
691
798
|
|
|
692
799
|
def encode_720p_version(self, input_file, output_file):
|
|
693
|
-
"""Create 720p MP4 with AAC audio (for smaller file size)"""
|
|
694
|
-
#
|
|
695
|
-
|
|
800
|
+
"""Create 720p MP4 with AAC audio (for smaller file size) using hardware acceleration when available"""
|
|
801
|
+
# Hardware-accelerated version with GPU scaling and encoding
|
|
802
|
+
gpu_command = (
|
|
803
|
+
f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
|
|
804
|
+
f'-c:v {self.video_encoder} -vf "{self.scale_filter}=1280:720" '
|
|
805
|
+
f'{self.get_nvenc_quality_settings("medium")} -b:v 2000k '
|
|
806
|
+
f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# Software fallback version
|
|
810
|
+
cpu_command = (
|
|
696
811
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
697
|
-
f'-c:v libx264 -vf "scale=1280:720" -b:v
|
|
812
|
+
f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
|
|
698
813
|
f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
|
|
699
814
|
)
|
|
700
|
-
|
|
701
|
-
self.
|
|
815
|
+
|
|
816
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Encoding 720p version of the final video")
|
|
702
817
|
|
|
703
818
|
def prepare_concat_filter(self, input_files):
|
|
704
819
|
"""Prepare the concat filter and additional input for end credits if present"""
|
|
@@ -749,17 +864,21 @@ class KaraokeFinalise:
|
|
|
749
864
|
self.encode_lossless_mkv(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossless_mkv"])
|
|
750
865
|
self.encode_720p_version(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_720p_mp4"])
|
|
751
866
|
|
|
752
|
-
#
|
|
753
|
-
self.
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
867
|
+
# Skip user confirmation in non-interactive mode for Modal deployment
|
|
868
|
+
if not self.non_interactive:
|
|
869
|
+
# Prompt user to check final video files before proceeding
|
|
870
|
+
self.prompt_user_confirmation_or_raise_exception(
|
|
871
|
+
f"Final video files created:\n"
|
|
872
|
+
f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
|
|
873
|
+
f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
|
|
874
|
+
f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
|
|
875
|
+
f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
|
|
876
|
+
f"Please check them! Proceed?",
|
|
877
|
+
"Refusing to proceed without user confirmation they're happy with the Final videos.",
|
|
878
|
+
allow_empty=True,
|
|
879
|
+
)
|
|
880
|
+
else:
|
|
881
|
+
self.logger.info("Non-interactive mode: automatically confirming final video files")
|
|
763
882
|
|
|
764
883
|
def create_cdg_zip_file(self, input_files, output_files, artist, title):
|
|
765
884
|
self.logger.info(f"Creating CDG and MP3 files, then zipping them...")
|
|
@@ -935,9 +1054,9 @@ class KaraokeFinalise:
|
|
|
935
1054
|
self.logger.info(f"Copied final files to public share directory")
|
|
936
1055
|
|
|
937
1056
|
def sync_public_share_dir_to_rclone_destination(self):
|
|
938
|
-
self.logger.info(f"
|
|
1057
|
+
self.logger.info(f"Copying public share directory to rclone destination...")
|
|
939
1058
|
|
|
940
|
-
# Delete .DS_Store files recursively before
|
|
1059
|
+
# Delete .DS_Store files recursively before copying
|
|
941
1060
|
for root, dirs, files in os.walk(self.public_share_dir):
|
|
942
1061
|
for file in files:
|
|
943
1062
|
if file == ".DS_Store":
|
|
@@ -945,8 +1064,8 @@ class KaraokeFinalise:
|
|
|
945
1064
|
os.remove(file_path)
|
|
946
1065
|
self.logger.info(f"Deleted .DS_Store file: {file_path}")
|
|
947
1066
|
|
|
948
|
-
rclone_cmd = f"rclone
|
|
949
|
-
self.execute_command(rclone_cmd, "
|
|
1067
|
+
rclone_cmd = f"rclone copy -v {shlex.quote(self.public_share_dir)} {shlex.quote(self.rclone_destination)}"
|
|
1068
|
+
self.execute_command(rclone_cmd, "Copying to cloud destination")
|
|
950
1069
|
|
|
951
1070
|
def post_discord_notification(self):
|
|
952
1071
|
self.logger.info(f"Posting Discord notification...")
|
|
@@ -980,6 +1099,13 @@ class KaraokeFinalise:
|
|
|
980
1099
|
try:
|
|
981
1100
|
self.logger.info(f"Running command: {rclone_link_cmd}")
|
|
982
1101
|
result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
|
|
1102
|
+
|
|
1103
|
+
# Log command output for debugging
|
|
1104
|
+
if result.stdout and result.stdout.strip():
|
|
1105
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
1106
|
+
if result.stderr and result.stderr.strip():
|
|
1107
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
1108
|
+
|
|
983
1109
|
self.brand_code_dir_sharing_link = result.stdout.strip()
|
|
984
1110
|
self.logger.info(f"Got organised folder sharing link: {self.brand_code_dir_sharing_link}")
|
|
985
1111
|
except subprocess.CalledProcessError as e:
|
|
@@ -988,6 +1114,125 @@ class KaraokeFinalise:
|
|
|
988
1114
|
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
989
1115
|
self.logger.error(f"Full exception: {e}")
|
|
990
1116
|
|
|
1117
|
+
def get_next_brand_code_server_side(self):
|
|
1118
|
+
"""
|
|
1119
|
+
Calculate the next sequence number based on existing directories in the remote organised_dir using rclone.
|
|
1120
|
+
Assumes directories are named with the format: BRAND-XXXX Artist - Title
|
|
1121
|
+
"""
|
|
1122
|
+
if not self.organised_dir_rclone_root:
|
|
1123
|
+
raise Exception("organised_dir_rclone_root not configured for server-side brand code generation")
|
|
1124
|
+
|
|
1125
|
+
self.logger.info(f"Getting next brand code from remote organized directory: {self.organised_dir_rclone_root}")
|
|
1126
|
+
|
|
1127
|
+
max_num = 0
|
|
1128
|
+
pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
|
|
1129
|
+
|
|
1130
|
+
# Use rclone lsf --dirs-only for clean, machine-readable directory listing
|
|
1131
|
+
rclone_list_cmd = f"rclone lsf --dirs-only {shlex.quote(self.organised_dir_rclone_root)}"
|
|
1132
|
+
|
|
1133
|
+
if self.dry_run:
|
|
1134
|
+
self.logger.info(f"DRY RUN: Would run: {rclone_list_cmd}")
|
|
1135
|
+
return f"{self.brand_prefix}-0001"
|
|
1136
|
+
|
|
1137
|
+
try:
|
|
1138
|
+
self.logger.info(f"Running command: {rclone_list_cmd}")
|
|
1139
|
+
result = subprocess.run(rclone_list_cmd, shell=True, check=True, capture_output=True, text=True)
|
|
1140
|
+
|
|
1141
|
+
# Log command output for debugging
|
|
1142
|
+
if result.stdout and result.stdout.strip():
|
|
1143
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
1144
|
+
if result.stderr and result.stderr.strip():
|
|
1145
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
1146
|
+
|
|
1147
|
+
# Parse the output to find matching directories
|
|
1148
|
+
matching_dirs = []
|
|
1149
|
+
for line_num, line in enumerate(result.stdout.strip().split('\n')):
|
|
1150
|
+
if line.strip():
|
|
1151
|
+
# Remove trailing slash and whitespace
|
|
1152
|
+
dir_name = line.strip().rstrip('/')
|
|
1153
|
+
|
|
1154
|
+
# Check if directory matches our brand pattern
|
|
1155
|
+
match = pattern.match(dir_name)
|
|
1156
|
+
if match:
|
|
1157
|
+
num = int(match.group(1))
|
|
1158
|
+
max_num = max(max_num, num)
|
|
1159
|
+
matching_dirs.append((dir_name, num))
|
|
1160
|
+
|
|
1161
|
+
self.logger.info(f"Found {len(matching_dirs)} matching directories with pattern {self.brand_prefix}-XXXX")
|
|
1162
|
+
|
|
1163
|
+
next_seq_number = max_num + 1
|
|
1164
|
+
brand_code = f"{self.brand_prefix}-{next_seq_number:04d}"
|
|
1165
|
+
|
|
1166
|
+
self.logger.info(f"Highest existing number: {max_num}, next sequence number for brand {self.brand_prefix} calculated as: {next_seq_number}")
|
|
1167
|
+
return brand_code
|
|
1168
|
+
|
|
1169
|
+
except subprocess.CalledProcessError as e:
|
|
1170
|
+
self.logger.error(f"Failed to list remote organized directory. Exit code: {e.returncode}")
|
|
1171
|
+
self.logger.error(f"Command output (stdout): {e.stdout}")
|
|
1172
|
+
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
1173
|
+
raise Exception(f"Failed to get brand code from remote directory: {e}")
|
|
1174
|
+
|
|
1175
|
+
def upload_files_to_organized_folder_server_side(self, brand_code, artist, title):
|
|
1176
|
+
"""
|
|
1177
|
+
Upload all files from current directory to the remote organized folder using rclone.
|
|
1178
|
+
Creates a brand-prefixed directory in the remote organized folder.
|
|
1179
|
+
"""
|
|
1180
|
+
if not self.organised_dir_rclone_root:
|
|
1181
|
+
raise Exception("organised_dir_rclone_root not configured for server-side file upload")
|
|
1182
|
+
|
|
1183
|
+
self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
|
|
1184
|
+
remote_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
|
|
1185
|
+
|
|
1186
|
+
self.logger.info(f"Uploading files to remote organized directory: {remote_dest}")
|
|
1187
|
+
|
|
1188
|
+
# Get current directory path to upload
|
|
1189
|
+
current_dir = os.getcwd()
|
|
1190
|
+
|
|
1191
|
+
# Use rclone copy to upload the entire current directory to the remote destination
|
|
1192
|
+
rclone_upload_cmd = f"rclone copy -v {shlex.quote(current_dir)} {shlex.quote(remote_dest)}"
|
|
1193
|
+
|
|
1194
|
+
if self.dry_run:
|
|
1195
|
+
self.logger.info(f"DRY RUN: Would upload current directory to: {remote_dest}")
|
|
1196
|
+
self.logger.info(f"DRY RUN: Command: {rclone_upload_cmd}")
|
|
1197
|
+
else:
|
|
1198
|
+
self.execute_command(rclone_upload_cmd, f"Uploading files to organized folder: {remote_dest}")
|
|
1199
|
+
|
|
1200
|
+
# Generate a sharing link for the uploaded folder
|
|
1201
|
+
self.generate_organised_folder_sharing_link_server_side(remote_dest)
|
|
1202
|
+
|
|
1203
|
+
def generate_organised_folder_sharing_link_server_side(self, remote_path):
|
|
1204
|
+
"""Generate a sharing link for the remote organized folder using rclone."""
|
|
1205
|
+
self.logger.info(f"Getting sharing link for remote organized folder: {remote_path}")
|
|
1206
|
+
|
|
1207
|
+
rclone_link_cmd = f"rclone link {shlex.quote(remote_path)}"
|
|
1208
|
+
|
|
1209
|
+
if self.dry_run:
|
|
1210
|
+
self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
|
|
1211
|
+
self.brand_code_dir_sharing_link = "https://file-sharing-service.com/example"
|
|
1212
|
+
return
|
|
1213
|
+
|
|
1214
|
+
# Add a 10-second delay to allow the remote service to index the folder before generating a link
|
|
1215
|
+
self.logger.info("Waiting 10 seconds before generating link...")
|
|
1216
|
+
time.sleep(10)
|
|
1217
|
+
|
|
1218
|
+
try:
|
|
1219
|
+
self.logger.info(f"Running command: {rclone_link_cmd}")
|
|
1220
|
+
result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
|
|
1221
|
+
|
|
1222
|
+
# Log command output for debugging
|
|
1223
|
+
if result.stdout and result.stdout.strip():
|
|
1224
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
1225
|
+
if result.stderr and result.stderr.strip():
|
|
1226
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
1227
|
+
|
|
1228
|
+
self.brand_code_dir_sharing_link = result.stdout.strip()
|
|
1229
|
+
self.logger.info(f"Got organized folder sharing link: {self.brand_code_dir_sharing_link}")
|
|
1230
|
+
except subprocess.CalledProcessError as e:
|
|
1231
|
+
self.logger.error(f"Failed to get organized folder sharing link. Exit code: {e.returncode}")
|
|
1232
|
+
self.logger.error(f"Command output (stdout): {e.stdout}")
|
|
1233
|
+
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
1234
|
+
self.logger.error(f"Full exception: {e}")
|
|
1235
|
+
|
|
991
1236
|
def get_existing_brand_code(self):
|
|
992
1237
|
"""Extract brand code from current directory name"""
|
|
993
1238
|
current_dir = os.path.basename(os.getcwd())
|
|
@@ -1018,7 +1263,30 @@ class KaraokeFinalise:
|
|
|
1018
1263
|
if self.discord_notication_enabled:
|
|
1019
1264
|
self.post_discord_notification()
|
|
1020
1265
|
|
|
1021
|
-
|
|
1266
|
+
# Handle folder organization - different logic for server-side vs local mode
|
|
1267
|
+
if self.server_side_mode and self.brand_prefix and self.organised_dir_rclone_root:
|
|
1268
|
+
self.logger.info("Executing server-side organization...")
|
|
1269
|
+
|
|
1270
|
+
# Generate brand code from remote directory listing
|
|
1271
|
+
if self.keep_brand_code:
|
|
1272
|
+
self.brand_code = self.get_existing_brand_code()
|
|
1273
|
+
else:
|
|
1274
|
+
self.brand_code = self.get_next_brand_code_server_side()
|
|
1275
|
+
|
|
1276
|
+
# Upload files to organized folder via rclone
|
|
1277
|
+
self.upload_files_to_organized_folder_server_side(self.brand_code, artist, title)
|
|
1278
|
+
|
|
1279
|
+
# Copy files to public share if enabled
|
|
1280
|
+
if self.public_share_copy_enabled:
|
|
1281
|
+
self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
|
|
1282
|
+
|
|
1283
|
+
# Sync public share to cloud destination if enabled
|
|
1284
|
+
if self.public_share_rclone_enabled:
|
|
1285
|
+
self.sync_public_share_dir_to_rclone_destination()
|
|
1286
|
+
|
|
1287
|
+
elif self.folder_organisation_enabled:
|
|
1288
|
+
self.logger.info("Executing local folder organization...")
|
|
1289
|
+
|
|
1022
1290
|
if self.keep_brand_code:
|
|
1023
1291
|
self.brand_code = self.get_existing_brand_code()
|
|
1024
1292
|
self.new_brand_code_dir = os.path.basename(os.getcwd())
|
|
@@ -1037,6 +1305,27 @@ class KaraokeFinalise:
|
|
|
1037
1305
|
self.sync_public_share_dir_to_rclone_destination()
|
|
1038
1306
|
|
|
1039
1307
|
self.generate_organised_folder_sharing_link()
|
|
1308
|
+
|
|
1309
|
+
elif self.public_share_copy_enabled or self.public_share_rclone_enabled:
|
|
1310
|
+
# If only public share features are enabled (no folder organization), we still need a brand code
|
|
1311
|
+
self.logger.info("No folder organization enabled, but public share features require brand code...")
|
|
1312
|
+
if self.brand_prefix:
|
|
1313
|
+
if self.server_side_mode and self.organised_dir_rclone_root:
|
|
1314
|
+
self.brand_code = self.get_next_brand_code_server_side()
|
|
1315
|
+
elif not self.server_side_mode and self.organised_dir:
|
|
1316
|
+
self.brand_code = self.get_next_brand_code()
|
|
1317
|
+
else:
|
|
1318
|
+
# Fallback to timestamp-based brand code if no organized directory configured
|
|
1319
|
+
import datetime
|
|
1320
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
1321
|
+
self.brand_code = f"{self.brand_prefix}-{timestamp}"
|
|
1322
|
+
self.logger.warning(f"No organized directory configured, using timestamp-based brand code: {self.brand_code}")
|
|
1323
|
+
|
|
1324
|
+
if self.public_share_copy_enabled:
|
|
1325
|
+
self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
|
|
1326
|
+
|
|
1327
|
+
if self.public_share_rclone_enabled:
|
|
1328
|
+
self.sync_public_share_dir_to_rclone_destination()
|
|
1040
1329
|
|
|
1041
1330
|
def authenticate_gmail(self):
|
|
1042
1331
|
"""Authenticate and return a Gmail service object."""
|
|
@@ -1051,6 +1340,9 @@ class KaraokeFinalise:
|
|
|
1051
1340
|
if creds and creds.expired and creds.refresh_token:
|
|
1052
1341
|
creds.refresh(Request())
|
|
1053
1342
|
else:
|
|
1343
|
+
if self.non_interactive:
|
|
1344
|
+
raise Exception("Gmail authentication required but running in non-interactive mode. Please pre-authenticate or disable email drafts.")
|
|
1345
|
+
|
|
1054
1346
|
flow = InstalledAppFlow.from_client_secrets_file(
|
|
1055
1347
|
self.youtube_client_secrets_file, ["https://www.googleapis.com/auth/gmail.compose"]
|
|
1056
1348
|
)
|
|
@@ -1061,6 +1353,11 @@ class KaraokeFinalise:
|
|
|
1061
1353
|
return build("gmail", "v1", credentials=creds)
|
|
1062
1354
|
|
|
1063
1355
|
def draft_completion_email(self, artist, title, youtube_url, dropbox_url):
|
|
1356
|
+
# Completely disable email drafts in server-side mode
|
|
1357
|
+
if self.server_side_mode:
|
|
1358
|
+
self.logger.info("Server-side mode: skipping email draft creation")
|
|
1359
|
+
return
|
|
1360
|
+
|
|
1064
1361
|
if not self.email_template_file:
|
|
1065
1362
|
self.logger.info("Email template file not provided, skipping email draft creation.")
|
|
1066
1363
|
return
|
|
@@ -1118,6 +1415,206 @@ class KaraokeFinalise:
|
|
|
1118
1415
|
self.logger.info("Using built-in aac codec (basic quality)")
|
|
1119
1416
|
return "aac"
|
|
1120
1417
|
|
|
1418
|
+
def detect_nvenc_support(self):
|
|
1419
|
+
"""Detect if NVENC hardware encoding is available with comprehensive checks."""
|
|
1420
|
+
try:
|
|
1421
|
+
self.logger.info("🔍 Detecting NVENC hardware acceleration support...")
|
|
1422
|
+
|
|
1423
|
+
if self.dry_run:
|
|
1424
|
+
self.logger.info("DRY RUN: Assuming NVENC is available")
|
|
1425
|
+
return True
|
|
1426
|
+
|
|
1427
|
+
import subprocess
|
|
1428
|
+
import os
|
|
1429
|
+
import shutil
|
|
1430
|
+
|
|
1431
|
+
# Step 1: Check for nvidia-smi (indicates NVIDIA driver presence)
|
|
1432
|
+
try:
|
|
1433
|
+
nvidia_smi_result = subprocess.run(["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
|
|
1434
|
+
capture_output=True, text=True, timeout=10)
|
|
1435
|
+
if nvidia_smi_result.returncode == 0:
|
|
1436
|
+
gpu_info = nvidia_smi_result.stdout.strip()
|
|
1437
|
+
self.logger.info(f"✓ NVIDIA GPU detected: {gpu_info}")
|
|
1438
|
+
else:
|
|
1439
|
+
self.logger.warning("⚠️ nvidia-smi not available or no NVIDIA GPU detected")
|
|
1440
|
+
return False
|
|
1441
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.CalledProcessError):
|
|
1442
|
+
self.logger.warning("⚠️ nvidia-smi not available or failed")
|
|
1443
|
+
return False
|
|
1444
|
+
|
|
1445
|
+
# Step 2: Check for NVENC encoders in FFmpeg
|
|
1446
|
+
try:
|
|
1447
|
+
encoders_cmd = f"{self.ffmpeg_base_command} -hide_banner -encoders 2>/dev/null | grep nvenc"
|
|
1448
|
+
encoders_result = subprocess.run(encoders_cmd, shell=True, capture_output=True, text=True, timeout=10)
|
|
1449
|
+
if encoders_result.returncode == 0 and "nvenc" in encoders_result.stdout:
|
|
1450
|
+
nvenc_encoders = [line.strip() for line in encoders_result.stdout.split('\n') if 'nvenc' in line]
|
|
1451
|
+
self.logger.info("✓ Found NVENC encoders in FFmpeg:")
|
|
1452
|
+
for encoder in nvenc_encoders:
|
|
1453
|
+
if encoder:
|
|
1454
|
+
self.logger.info(f" {encoder}")
|
|
1455
|
+
else:
|
|
1456
|
+
self.logger.warning("⚠️ No NVENC encoders found in FFmpeg")
|
|
1457
|
+
return False
|
|
1458
|
+
except Exception as e:
|
|
1459
|
+
self.logger.warning(f"⚠️ Failed to check FFmpeg NVENC encoders: {e}")
|
|
1460
|
+
return False
|
|
1461
|
+
|
|
1462
|
+
# Step 3: Check for libcuda.so.1 (critical for NVENC)
|
|
1463
|
+
try:
|
|
1464
|
+
libcuda_check = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10)
|
|
1465
|
+
if libcuda_check.returncode == 0 and "libcuda.so.1" in libcuda_check.stdout:
|
|
1466
|
+
self.logger.info("✅ libcuda.so.1 found in system libraries")
|
|
1467
|
+
else:
|
|
1468
|
+
self.logger.warning("❌ libcuda.so.1 NOT found in system libraries")
|
|
1469
|
+
self.logger.warning("💡 This usually indicates the CUDA runtime image is needed instead of devel")
|
|
1470
|
+
return False
|
|
1471
|
+
except Exception as e:
|
|
1472
|
+
self.logger.warning(f"⚠️ Failed to check for libcuda.so.1: {e}")
|
|
1473
|
+
return False
|
|
1474
|
+
|
|
1475
|
+
# Step 4: Test h264_nvenc encoder with simple test
|
|
1476
|
+
self.logger.info("🧪 Testing h264_nvenc encoder...")
|
|
1477
|
+
test_cmd = f"{self.ffmpeg_base_command} -hide_banner -loglevel warning -f lavfi -i testsrc=duration=1:size=320x240:rate=1 -c:v h264_nvenc -f null -"
|
|
1478
|
+
self.logger.debug(f"Running test command: {test_cmd}")
|
|
1479
|
+
|
|
1480
|
+
try:
|
|
1481
|
+
result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
1482
|
+
|
|
1483
|
+
if result.returncode == 0:
|
|
1484
|
+
self.logger.info("✅ NVENC hardware encoding available for video generation")
|
|
1485
|
+
self.logger.info(f"Test command succeeded. Output: {result.stderr if result.stderr else '...'}")
|
|
1486
|
+
return True
|
|
1487
|
+
else:
|
|
1488
|
+
self.logger.warning(f"❌ NVENC test failed with exit code {result.returncode}")
|
|
1489
|
+
if result.stderr:
|
|
1490
|
+
self.logger.warning(f"Error output: {result.stderr}")
|
|
1491
|
+
if "Cannot load libcuda.so.1" in result.stderr:
|
|
1492
|
+
self.logger.warning("💡 Root cause: libcuda.so.1 cannot be loaded by NVENC")
|
|
1493
|
+
self.logger.warning("💡 Solution: Use nvidia/cuda:*-devel-* image instead of runtime")
|
|
1494
|
+
return False
|
|
1495
|
+
|
|
1496
|
+
except subprocess.TimeoutExpired:
|
|
1497
|
+
self.logger.warning("❌ NVENC test timed out")
|
|
1498
|
+
return False
|
|
1499
|
+
|
|
1500
|
+
except Exception as e:
|
|
1501
|
+
self.logger.warning(f"❌ Failed to detect NVENC support: {e}, falling back to software encoding")
|
|
1502
|
+
return False
|
|
1503
|
+
|
|
1504
|
+
def configure_hardware_acceleration(self):
|
|
1505
|
+
"""Configure hardware acceleration settings based on detected capabilities."""
|
|
1506
|
+
if self.nvenc_available:
|
|
1507
|
+
self.video_encoder = "h264_nvenc"
|
|
1508
|
+
# Use simpler hardware acceleration that works with complex filter chains
|
|
1509
|
+
# Remove -hwaccel_output_format cuda as it causes pixel format conversion issues
|
|
1510
|
+
self.hwaccel_decode_flags = "-hwaccel cuda"
|
|
1511
|
+
self.scale_filter = "scale" # Use CPU scaling for complex filter chains
|
|
1512
|
+
self.logger.info("Configured for NVIDIA hardware acceleration (simplified for filter compatibility)")
|
|
1513
|
+
else:
|
|
1514
|
+
self.video_encoder = "libx264"
|
|
1515
|
+
self.hwaccel_decode_flags = ""
|
|
1516
|
+
self.scale_filter = "scale"
|
|
1517
|
+
self.logger.info("Configured for software encoding")
|
|
1518
|
+
|
|
1519
|
+
def get_nvenc_quality_settings(self, quality_mode="high"):
|
|
1520
|
+
"""Get NVENC settings based on quality requirements."""
|
|
1521
|
+
if quality_mode == "lossless":
|
|
1522
|
+
return "-preset lossless"
|
|
1523
|
+
elif quality_mode == "high":
|
|
1524
|
+
return "-preset p4 -tune hq -cq 18" # High quality
|
|
1525
|
+
elif quality_mode == "medium":
|
|
1526
|
+
return "-preset p4 -cq 23" # Balanced quality/speed
|
|
1527
|
+
elif quality_mode == "fast":
|
|
1528
|
+
return "-preset p1 -tune ll" # Low latency, faster encoding
|
|
1529
|
+
else:
|
|
1530
|
+
return "-preset p4" # Balanced default
|
|
1531
|
+
|
|
1532
|
+
def execute_command_with_fallback(self, gpu_command, cpu_command, description):
|
|
1533
|
+
"""Execute GPU command with automatic fallback to CPU if it fails."""
|
|
1534
|
+
self.logger.info(f"{description}")
|
|
1535
|
+
|
|
1536
|
+
if self.dry_run:
|
|
1537
|
+
if self.nvenc_available:
|
|
1538
|
+
self.logger.info(f"DRY RUN: Would run GPU-accelerated command: {gpu_command}")
|
|
1539
|
+
else:
|
|
1540
|
+
self.logger.info(f"DRY RUN: Would run CPU command: {cpu_command}")
|
|
1541
|
+
return
|
|
1542
|
+
|
|
1543
|
+
# Try GPU-accelerated command first if available
|
|
1544
|
+
if self.nvenc_available and gpu_command != cpu_command:
|
|
1545
|
+
self.logger.debug(f"Attempting hardware-accelerated encoding: {gpu_command}")
|
|
1546
|
+
try:
|
|
1547
|
+
result = subprocess.run(gpu_command, shell=True, capture_output=True, text=True, timeout=300)
|
|
1548
|
+
|
|
1549
|
+
if result.returncode == 0:
|
|
1550
|
+
self.logger.info(f"✓ Hardware acceleration successful")
|
|
1551
|
+
return
|
|
1552
|
+
else:
|
|
1553
|
+
self.logger.warning(f"✗ Hardware acceleration failed (exit code {result.returncode})")
|
|
1554
|
+
self.logger.warning(f"GPU Command: {gpu_command}")
|
|
1555
|
+
|
|
1556
|
+
# If we didn't get detailed error info and using fatal loglevel, try again with verbose logging
|
|
1557
|
+
if (not result.stderr or len(result.stderr.strip()) < 10) and "-loglevel fatal" in gpu_command:
|
|
1558
|
+
self.logger.warning("Empty error output detected, retrying with verbose logging...")
|
|
1559
|
+
verbose_gpu_command = gpu_command.replace("-loglevel fatal", "-loglevel error")
|
|
1560
|
+
try:
|
|
1561
|
+
verbose_result = subprocess.run(verbose_gpu_command, shell=True, capture_output=True, text=True, timeout=300)
|
|
1562
|
+
self.logger.warning(f"Verbose GPU Command: {verbose_gpu_command}")
|
|
1563
|
+
if verbose_result.stderr:
|
|
1564
|
+
self.logger.warning(f"FFmpeg STDERR (verbose): {verbose_result.stderr}")
|
|
1565
|
+
if verbose_result.stdout:
|
|
1566
|
+
self.logger.warning(f"FFmpeg STDOUT (verbose): {verbose_result.stdout}")
|
|
1567
|
+
except Exception as e:
|
|
1568
|
+
self.logger.warning(f"Verbose retry failed: {e}")
|
|
1569
|
+
|
|
1570
|
+
if result.stderr:
|
|
1571
|
+
self.logger.warning(f"FFmpeg STDERR: {result.stderr}")
|
|
1572
|
+
else:
|
|
1573
|
+
self.logger.warning("FFmpeg STDERR: (empty)")
|
|
1574
|
+
if result.stdout:
|
|
1575
|
+
self.logger.warning(f"FFmpeg STDOUT: {result.stdout}")
|
|
1576
|
+
else:
|
|
1577
|
+
self.logger.warning("FFmpeg STDOUT: (empty)")
|
|
1578
|
+
self.logger.info("Falling back to software encoding...")
|
|
1579
|
+
|
|
1580
|
+
except subprocess.TimeoutExpired:
|
|
1581
|
+
self.logger.warning("✗ Hardware acceleration timed out, falling back to software encoding")
|
|
1582
|
+
except Exception as e:
|
|
1583
|
+
self.logger.warning(f"✗ Hardware acceleration failed with exception: {e}, falling back to software encoding")
|
|
1584
|
+
|
|
1585
|
+
# Use CPU command (either as fallback or primary method)
|
|
1586
|
+
self.logger.debug(f"Running software encoding: {cpu_command}")
|
|
1587
|
+
try:
|
|
1588
|
+
result = subprocess.run(cpu_command, shell=True, capture_output=True, text=True, timeout=600)
|
|
1589
|
+
|
|
1590
|
+
if result.returncode != 0:
|
|
1591
|
+
error_msg = f"Software encoding failed with exit code {result.returncode}"
|
|
1592
|
+
self.logger.error(error_msg)
|
|
1593
|
+
self.logger.error(f"CPU Command: {cpu_command}")
|
|
1594
|
+
if result.stderr:
|
|
1595
|
+
self.logger.error(f"FFmpeg STDERR: {result.stderr}")
|
|
1596
|
+
else:
|
|
1597
|
+
self.logger.error("FFmpeg STDERR: (empty)")
|
|
1598
|
+
if result.stdout:
|
|
1599
|
+
self.logger.error(f"FFmpeg STDOUT: {result.stdout}")
|
|
1600
|
+
else:
|
|
1601
|
+
self.logger.error("FFmpeg STDOUT: (empty)")
|
|
1602
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
1603
|
+
else:
|
|
1604
|
+
self.logger.info(f"✓ Software encoding successful")
|
|
1605
|
+
|
|
1606
|
+
except subprocess.TimeoutExpired:
|
|
1607
|
+
error_msg = "Software encoding timed out"
|
|
1608
|
+
self.logger.error(error_msg)
|
|
1609
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
1610
|
+
except Exception as e:
|
|
1611
|
+
if "Software encoding failed" not in str(e):
|
|
1612
|
+
error_msg = f"Software encoding failed with exception: {e}"
|
|
1613
|
+
self.logger.error(error_msg)
|
|
1614
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
1615
|
+
else:
|
|
1616
|
+
raise
|
|
1617
|
+
|
|
1121
1618
|
def process(self, replace_existing=False):
|
|
1122
1619
|
if self.dry_run:
|
|
1123
1620
|
self.logger.warning("Dry run enabled. No actions will be performed.")
|
|
@@ -69,6 +69,8 @@ class KaraokePrep:
|
|
|
69
69
|
style_params_json=None,
|
|
70
70
|
# Add the new parameter
|
|
71
71
|
skip_separation=False,
|
|
72
|
+
# YouTube/Online Configuration
|
|
73
|
+
cookies_str=None,
|
|
72
74
|
):
|
|
73
75
|
self.log_level = log_level
|
|
74
76
|
self.log_formatter = log_formatter
|
|
@@ -124,6 +126,9 @@ class KaraokePrep:
|
|
|
124
126
|
self.render_bounding_boxes = render_bounding_boxes # Passed to VideoGenerator
|
|
125
127
|
self.style_params_json = style_params_json # Passed to LyricsProcessor
|
|
126
128
|
|
|
129
|
+
# YouTube/Online Config
|
|
130
|
+
self.cookies_str = cookies_str # Passed to metadata extraction and file download
|
|
131
|
+
|
|
127
132
|
# Load style parameters using the config module
|
|
128
133
|
self.style_params = load_style_params(self.style_params_json, self.logger)
|
|
129
134
|
|
|
@@ -197,7 +202,7 @@ class KaraokePrep:
|
|
|
197
202
|
# Compatibility methods for tests - these call the new functions in metadata.py
|
|
198
203
|
def extract_info_for_online_media(self, input_url=None, input_artist=None, input_title=None):
|
|
199
204
|
"""Compatibility method that calls the function in metadata.py"""
|
|
200
|
-
self.extracted_info = extract_info_for_online_media(input_url, input_artist, input_title, self.logger)
|
|
205
|
+
self.extracted_info = extract_info_for_online_media(input_url, input_artist, input_title, self.logger, self.cookies_str)
|
|
201
206
|
return self.extracted_info
|
|
202
207
|
|
|
203
208
|
def parse_single_track_metadata(self, input_artist, input_title):
|
|
@@ -242,7 +247,7 @@ class KaraokePrep:
|
|
|
242
247
|
self.logger.warning(f"Input media '{self.input_media}' is not a file and self.url was not set. Attempting to treat as URL.")
|
|
243
248
|
# This path requires calling extract/parse again, less efficient
|
|
244
249
|
try:
|
|
245
|
-
extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger)
|
|
250
|
+
extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger, self.cookies_str)
|
|
246
251
|
if extracted:
|
|
247
252
|
metadata_result = parse_track_metadata(
|
|
248
253
|
extracted, self.artist, self.title, self.persistent_artist, self.logger
|
|
@@ -345,7 +350,7 @@ class KaraokePrep:
|
|
|
345
350
|
|
|
346
351
|
self.logger.info(f"Downloading input media from {self.url}...")
|
|
347
352
|
# Delegate to FileHandler
|
|
348
|
-
processed_track["input_media"] = self.file_handler.download_video(self.url, output_filename_no_extension)
|
|
353
|
+
processed_track["input_media"] = self.file_handler.download_video(self.url, output_filename_no_extension, self.cookies_str)
|
|
349
354
|
|
|
350
355
|
self.logger.info("Extracting still image from downloaded media (if input is video)...")
|
|
351
356
|
# Delegate to FileHandler
|
|
@@ -681,7 +686,7 @@ class KaraokePrep:
|
|
|
681
686
|
self.url = self.input_media
|
|
682
687
|
# Use the imported extract_info_for_online_media function
|
|
683
688
|
self.extracted_info = extract_info_for_online_media(
|
|
684
|
-
input_url=self.url, input_artist=self.artist, input_title=self.title, logger=self.logger
|
|
689
|
+
input_url=self.url, input_artist=self.artist, input_title=self.title, logger=self.logger, cookies_str=self.cookies_str
|
|
685
690
|
)
|
|
686
691
|
|
|
687
692
|
if self.extracted_info and "playlist_count" in self.extracted_info:
|
|
@@ -690,4 +695,6 @@ class KaraokePrep:
|
|
|
690
695
|
return await self.process_playlist()
|
|
691
696
|
else:
|
|
692
697
|
self.logger.info(f"Input URL is not a playlist, processing single track")
|
|
698
|
+
# Parse metadata to extract artist and title before processing
|
|
699
|
+
self.parse_single_track_metadata(self.artist, self.title)
|
|
693
700
|
return [await self.prep_single_track()]
|
|
@@ -2,6 +2,7 @@ import os
|
|
|
2
2
|
import re
|
|
3
3
|
import logging
|
|
4
4
|
import shutil
|
|
5
|
+
import json
|
|
5
6
|
from lyrics_transcriber import LyricsTranscriber, OutputConfig, TranscriberConfig, LyricsConfig
|
|
6
7
|
from lyrics_transcriber.core.controller import LyricsControllerResult
|
|
7
8
|
from dotenv import load_dotenv
|
|
@@ -173,6 +174,7 @@ class LyricsProcessor:
|
|
|
173
174
|
"spotify_cookie": os.getenv("SPOTIFY_COOKIE_SP_DC"),
|
|
174
175
|
"runpod_api_key": os.getenv("RUNPOD_API_KEY"),
|
|
175
176
|
"whisper_runpod_id": os.getenv("WHISPER_RUNPOD_ID"),
|
|
177
|
+
"rapidapi_key": os.getenv("RAPIDAPI_KEY"), # Add missing RAPIDAPI_KEY
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
# Create config objects for LyricsTranscriber
|
|
@@ -183,21 +185,51 @@ class LyricsProcessor:
|
|
|
183
185
|
lyrics_config = LyricsConfig(
|
|
184
186
|
genius_api_token=env_config.get("genius_api_token"),
|
|
185
187
|
spotify_cookie=env_config.get("spotify_cookie"),
|
|
188
|
+
rapidapi_key=env_config.get("rapidapi_key"),
|
|
186
189
|
lyrics_file=self.lyrics_file,
|
|
187
190
|
)
|
|
188
|
-
|
|
191
|
+
|
|
192
|
+
# Debug logging for lyrics_config
|
|
193
|
+
self.logger.info(f"LyricsConfig created with:")
|
|
194
|
+
self.logger.info(f" genius_api_token: {env_config.get('genius_api_token')[:3] + '...' if env_config.get('genius_api_token') else 'None'}")
|
|
195
|
+
self.logger.info(f" spotify_cookie: {env_config.get('spotify_cookie')[:3] + '...' if env_config.get('spotify_cookie') else 'None'}")
|
|
196
|
+
self.logger.info(f" rapidapi_key: {env_config.get('rapidapi_key')[:3] + '...' if env_config.get('rapidapi_key') else 'None'}")
|
|
197
|
+
self.logger.info(f" lyrics_file: {self.lyrics_file}")
|
|
198
|
+
|
|
199
|
+
# Detect if we're running in a serverless environment (Modal)
|
|
200
|
+
# Modal sets specific environment variables we can check for
|
|
201
|
+
is_serverless = (
|
|
202
|
+
os.getenv("MODAL_TASK_ID") is not None or
|
|
203
|
+
os.getenv("MODAL_FUNCTION_NAME") is not None or
|
|
204
|
+
os.path.exists("/.modal") # Modal creates this directory in containers
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# In serverless environment, disable interactive review even if skip_transcription_review=False
|
|
208
|
+
# This preserves CLI behavior while fixing serverless hanging
|
|
209
|
+
enable_review_setting = not self.skip_transcription_review and not is_serverless
|
|
210
|
+
|
|
211
|
+
if is_serverless and not self.skip_transcription_review:
|
|
212
|
+
self.logger.info("Detected serverless environment - disabling interactive review to prevent hanging")
|
|
213
|
+
|
|
214
|
+
# In serverless environment, disable video generation during Phase 1 to save compute
|
|
215
|
+
# Video will be generated in Phase 2 after human review
|
|
216
|
+
serverless_render_video = render_video and not is_serverless
|
|
217
|
+
|
|
218
|
+
if is_serverless and render_video:
|
|
219
|
+
self.logger.info("Detected serverless environment - deferring video generation until after review")
|
|
220
|
+
|
|
189
221
|
output_config = OutputConfig(
|
|
190
222
|
output_styles_json=self.style_params_json,
|
|
191
223
|
output_dir=lyrics_dir,
|
|
192
|
-
render_video=
|
|
224
|
+
render_video=serverless_render_video, # Disable video in serverless Phase 1
|
|
193
225
|
fetch_lyrics=True,
|
|
194
226
|
run_transcription=not self.skip_transcription,
|
|
195
227
|
run_correction=True,
|
|
196
228
|
generate_plain_text=True,
|
|
197
229
|
generate_lrc=True,
|
|
198
|
-
generate_cdg=
|
|
230
|
+
generate_cdg=False, # Also defer CDG generation to Phase 2
|
|
199
231
|
video_resolution="4k",
|
|
200
|
-
enable_review=
|
|
232
|
+
enable_review=enable_review_setting,
|
|
201
233
|
subtitle_offset_ms=self.subtitle_offset_ms,
|
|
202
234
|
)
|
|
203
235
|
|
|
@@ -240,6 +272,19 @@ class LyricsProcessor:
|
|
|
240
272
|
)
|
|
241
273
|
transcriber_outputs["corrected_lyrics_text_filepath"] = results.corrected_txt
|
|
242
274
|
|
|
275
|
+
# Save correction data to JSON file for review interface
|
|
276
|
+
# Use the expected filename format: "{artist} - {title} (Lyrics Corrections).json"
|
|
277
|
+
corrections_filename = f"{filename_artist} - {filename_title} (Lyrics Corrections).json"
|
|
278
|
+
corrections_filepath = os.path.join(lyrics_dir, corrections_filename)
|
|
279
|
+
|
|
280
|
+
# Use the CorrectionResult's to_dict() method to serialize
|
|
281
|
+
correction_data = results.transcription_corrected.to_dict()
|
|
282
|
+
|
|
283
|
+
with open(corrections_filepath, 'w') as f:
|
|
284
|
+
json.dump(correction_data, f, indent=2)
|
|
285
|
+
|
|
286
|
+
self.logger.info(f"Saved correction data to {corrections_filepath}")
|
|
287
|
+
|
|
243
288
|
if transcriber_outputs:
|
|
244
289
|
self.logger.info(f"*** Transcriber Filepath Outputs: ***")
|
|
245
290
|
for key, value in transcriber_outputs.items():
|
|
@@ -1,30 +1,80 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
import yt_dlp.YoutubeDL as ydl
|
|
3
3
|
|
|
4
|
-
def extract_info_for_online_media(input_url, input_artist, input_title, logger):
|
|
4
|
+
def extract_info_for_online_media(input_url, input_artist, input_title, logger, cookies_str=None):
|
|
5
5
|
"""Extracts metadata using yt-dlp, either from a URL or via search."""
|
|
6
6
|
logger.info(f"Extracting info for input_url: {input_url} input_artist: {input_artist} input_title: {input_title}")
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
|
|
8
|
+
# Set up yt-dlp options with enhanced anti-detection
|
|
9
|
+
base_opts = {
|
|
10
|
+
"quiet": True,
|
|
11
|
+
# Anti-detection options
|
|
12
|
+
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
13
|
+
"referer": "https://www.youtube.com/",
|
|
14
|
+
"sleep_interval": 1,
|
|
15
|
+
"max_sleep_interval": 3,
|
|
16
|
+
"fragment_retries": 3,
|
|
17
|
+
"extractor_retries": 3,
|
|
18
|
+
"retries": 3,
|
|
19
|
+
# Headers to appear more human
|
|
20
|
+
"http_headers": {
|
|
21
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
22
|
+
"Accept-Language": "en-us,en;q=0.5",
|
|
23
|
+
"Accept-Encoding": "gzip, deflate",
|
|
24
|
+
"DNT": "1",
|
|
25
|
+
"Connection": "keep-alive",
|
|
26
|
+
"Upgrade-Insecure-Requests": "1",
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Add cookies if provided
|
|
31
|
+
if cookies_str:
|
|
32
|
+
logger.info("Using provided cookies for enhanced YouTube access")
|
|
33
|
+
# Save cookies to a temporary file
|
|
34
|
+
import tempfile
|
|
35
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
|
|
36
|
+
f.write(cookies_str)
|
|
37
|
+
base_opts['cookiefile'] = f.name
|
|
12
38
|
else:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
logger.info("No cookies provided - attempting standard extraction")
|
|
40
|
+
|
|
41
|
+
extracted_info = None
|
|
42
|
+
try:
|
|
43
|
+
if input_url is not None:
|
|
44
|
+
# If a URL is provided, use it to extract the metadata
|
|
45
|
+
with ydl(base_opts) as ydl_instance:
|
|
46
|
+
extracted_info = ydl_instance.extract_info(input_url, download=False)
|
|
47
|
+
else:
|
|
48
|
+
# If no URL is provided, use the query to search for the top result
|
|
49
|
+
search_opts = base_opts.copy()
|
|
50
|
+
search_opts.update({
|
|
51
|
+
"format": "bestaudio",
|
|
52
|
+
"noplaylist": "True",
|
|
53
|
+
"extract_flat": True
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
with ydl(search_opts) as ydl_instance:
|
|
57
|
+
query = f"{input_artist} {input_title}"
|
|
58
|
+
search_results = ydl_instance.extract_info(f"ytsearch1:{query}", download=False)
|
|
59
|
+
if search_results and "entries" in search_results and search_results["entries"]:
|
|
60
|
+
extracted_info = search_results["entries"][0]
|
|
61
|
+
else:
|
|
62
|
+
# Raise IndexError to match the expected exception in tests
|
|
63
|
+
raise IndexError(f"No search results found on YouTube for query: {input_artist} {input_title}")
|
|
64
|
+
|
|
65
|
+
if not extracted_info:
|
|
66
|
+
raise Exception(f"Failed to extract info for query: {input_artist} {input_title} or URL: {input_url}")
|
|
67
|
+
|
|
68
|
+
return extracted_info
|
|
69
|
+
|
|
70
|
+
finally:
|
|
71
|
+
# Clean up temporary cookie file if it was created
|
|
72
|
+
if cookies_str and 'cookiefile' in base_opts:
|
|
73
|
+
try:
|
|
74
|
+
import os
|
|
75
|
+
os.unlink(base_opts['cookiefile'])
|
|
76
|
+
except:
|
|
77
|
+
pass
|
|
28
78
|
|
|
29
79
|
|
|
30
80
|
def parse_track_metadata(extracted_info, current_artist, current_title, persistent_artist, logger):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "karaoke-gen"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.57.0"
|
|
4
4
|
description = "Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens."
|
|
5
5
|
authors = ["Andrew Beveridge <andrew@beveridge.uk>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -28,7 +28,7 @@ thefuzz = ">=0.22"
|
|
|
28
28
|
numpy = ">=2"
|
|
29
29
|
audio-separator = { version = ">=0.34.0", extras = ["cpu"] }
|
|
30
30
|
lyrics-converter = ">=0.2.1"
|
|
31
|
-
lyrics-transcriber = ">=0.
|
|
31
|
+
lyrics-transcriber = ">=0.61"
|
|
32
32
|
kbputils = "^0.0.16"
|
|
33
33
|
attrs = ">=24.2.0"
|
|
34
34
|
cattrs = ">=24.1.2"
|
|
@@ -36,8 +36,10 @@ toml = ">=0.10"
|
|
|
36
36
|
argparse = ">=1.4.0"
|
|
37
37
|
psutil = "^7.0.0"
|
|
38
38
|
pyperclip = "*"
|
|
39
|
-
pytest-asyncio = "
|
|
39
|
+
pytest-asyncio = "*"
|
|
40
40
|
ffmpeg-python = "^0.2.0"
|
|
41
|
+
modal = "^1.0.5"
|
|
42
|
+
python-multipart = "^0.0.20"
|
|
41
43
|
|
|
42
44
|
[tool.poetry.group.dev.dependencies]
|
|
43
45
|
black = ">=23"
|
|
@@ -45,7 +47,7 @@ poetry = "*"
|
|
|
45
47
|
pytest = ">=7.0"
|
|
46
48
|
pytest-cov = ">=4.0"
|
|
47
49
|
pytest-mock = ">=3.10"
|
|
48
|
-
pytest-asyncio = "
|
|
50
|
+
pytest-asyncio = "*"
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
[tool.poetry.scripts]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|