karaoke-gen 0.75.54__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/__init__.py +38 -0
- karaoke_gen/audio_fetcher.py +1614 -0
- karaoke_gen/audio_processor.py +790 -0
- karaoke_gen/config.py +83 -0
- karaoke_gen/file_handler.py +387 -0
- karaoke_gen/instrumental_review/__init__.py +45 -0
- karaoke_gen/instrumental_review/analyzer.py +408 -0
- karaoke_gen/instrumental_review/editor.py +322 -0
- karaoke_gen/instrumental_review/models.py +171 -0
- karaoke_gen/instrumental_review/server.py +475 -0
- karaoke_gen/instrumental_review/static/index.html +1529 -0
- karaoke_gen/instrumental_review/waveform.py +409 -0
- karaoke_gen/karaoke_finalise/__init__.py +1 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
- karaoke_gen/karaoke_gen.py +1026 -0
- karaoke_gen/lyrics_processor.py +474 -0
- karaoke_gen/metadata.py +160 -0
- karaoke_gen/pipeline/__init__.py +87 -0
- karaoke_gen/pipeline/base.py +215 -0
- karaoke_gen/pipeline/context.py +230 -0
- karaoke_gen/pipeline/executors/__init__.py +21 -0
- karaoke_gen/pipeline/executors/local.py +159 -0
- karaoke_gen/pipeline/executors/remote.py +257 -0
- karaoke_gen/pipeline/stages/__init__.py +27 -0
- karaoke_gen/pipeline/stages/finalize.py +202 -0
- karaoke_gen/pipeline/stages/render.py +165 -0
- karaoke_gen/pipeline/stages/screens.py +139 -0
- karaoke_gen/pipeline/stages/separation.py +191 -0
- karaoke_gen/pipeline/stages/transcription.py +191 -0
- karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_gen/style_loader.py +531 -0
- karaoke_gen/utils/__init__.py +18 -0
- karaoke_gen/utils/bulk_cli.py +492 -0
- karaoke_gen/utils/cli_args.py +432 -0
- karaoke_gen/utils/gen_cli.py +978 -0
- karaoke_gen/utils/remote_cli.py +3268 -0
- karaoke_gen/video_background_processor.py +351 -0
- karaoke_gen/video_generator.py +424 -0
- karaoke_gen-0.75.54.dist-info/METADATA +718 -0
- karaoke_gen-0.75.54.dist-info/RECORD +287 -0
- karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
- karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
- karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
- lyrics_transcriber/__init__.py +10 -0
- lyrics_transcriber/cli/__init__.py +0 -0
- lyrics_transcriber/cli/cli_main.py +285 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/config.py +50 -0
- lyrics_transcriber/core/controller.py +594 -0
- lyrics_transcriber/correction/__init__.py +0 -0
- lyrics_transcriber/correction/agentic/__init__.py +9 -0
- lyrics_transcriber/correction/agentic/adapter.py +71 -0
- lyrics_transcriber/correction/agentic/agent.py +313 -0
- lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
- lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
- lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
- lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
- lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
- lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
- lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
- lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
- lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
- lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
- lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
- lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
- lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
- lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
- lyrics_transcriber/correction/agentic/models/enums.py +38 -0
- lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
- lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
- lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
- lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
- lyrics_transcriber/correction/agentic/models/utils.py +19 -0
- lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
- lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
- lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
- lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
- lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
- lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
- lyrics_transcriber/correction/agentic/providers/base.py +36 -0
- lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
- lyrics_transcriber/correction/agentic/providers/config.py +73 -0
- lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
- lyrics_transcriber/correction/agentic/providers/health.py +28 -0
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
- lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
- lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
- lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
- lyrics_transcriber/correction/agentic/router.py +35 -0
- lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
- lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
- lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
- lyrics_transcriber/correction/anchor_sequence.py +919 -0
- lyrics_transcriber/correction/corrector.py +760 -0
- lyrics_transcriber/correction/feedback/__init__.py +2 -0
- lyrics_transcriber/correction/feedback/schemas.py +107 -0
- lyrics_transcriber/correction/feedback/store.py +236 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +52 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
- lyrics_transcriber/correction/handlers/llm.py +293 -0
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
- lyrics_transcriber/correction/handlers/repeat.py +88 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
- lyrics_transcriber/correction/handlers/word_operations.py +187 -0
- lyrics_transcriber/correction/operations.py +352 -0
- lyrics_transcriber/correction/phrase_analyzer.py +435 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/frontend/.gitignore +23 -0
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
- lyrics_transcriber/frontend/.yarnrc.yml +3 -0
- lyrics_transcriber/frontend/README.md +50 -0
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/__init__.py +25 -0
- lyrics_transcriber/frontend/eslint.config.js +28 -0
- lyrics_transcriber/frontend/index.html +18 -0
- lyrics_transcriber/frontend/package.json +42 -0
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/public/favicon.ico +0 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +214 -0
- lyrics_transcriber/frontend/src/api.ts +254 -0
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
- lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
- lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
- lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
- lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
- lyrics_transcriber/frontend/src/main.tsx +17 -0
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
- lyrics_transcriber/frontend/src/types.js +2 -0
- lyrics_transcriber/frontend/src/types.ts +199 -0
- lyrics_transcriber/frontend/src/validation.ts +132 -0
- lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
- lyrics_transcriber/frontend/tsconfig.app.json +26 -0
- lyrics_transcriber/frontend/tsconfig.json +25 -0
- lyrics_transcriber/frontend/tsconfig.node.json +23 -0
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
- lyrics_transcriber/frontend/update_version.js +11 -0
- lyrics_transcriber/frontend/vite.config.d.ts +2 -0
- lyrics_transcriber/frontend/vite.config.js +10 -0
- lyrics_transcriber/frontend/vite.config.ts +11 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +18 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/yarn.lock +3752 -0
- lyrics_transcriber/lyrics/__init__.py +0 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
- lyrics_transcriber/lyrics/file_provider.py +95 -0
- lyrics_transcriber/lyrics/genius.py +384 -0
- lyrics_transcriber/lyrics/lrclib.py +231 -0
- lyrics_transcriber/lyrics/musixmatch.py +156 -0
- lyrics_transcriber/lyrics/spotify.py +290 -0
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/ass/ass.py +2088 -0
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +180 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +265 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +619 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/countdown_processor.py +306 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +257 -0
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +96 -0
- lyrics_transcriber/output/segment_resizer.py +431 -0
- lyrics_transcriber/output/subtitles.py +397 -0
- lyrics_transcriber/output/video.py +544 -0
- lyrics_transcriber/review/__init__.py +0 -0
- lyrics_transcriber/review/server.py +676 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +225 -0
- lyrics_transcriber/transcribers/__init__.py +0 -0
- lyrics_transcriber/transcribers/audioshake.py +379 -0
- lyrics_transcriber/transcribers/base_transcriber.py +157 -0
- lyrics_transcriber/transcribers/whisper.py +330 -0
- lyrics_transcriber/types.py +650 -0
- lyrics_transcriber/utils/__init__.py +0 -0
- lyrics_transcriber/utils/word_utils.py +27 -0
|
@@ -0,0 +1,1833 @@
|
|
|
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
|
+
user_youtube_credentials=None, # Add support for pre-stored credentials
|
|
48
|
+
server_side_mode=False, # New parameter for server-side deployment
|
|
49
|
+
selected_instrumental_file=None, # Add support for pre-selected instrumental file
|
|
50
|
+
countdown_padding_seconds=None, # Padding applied to vocals; instrumental must match
|
|
51
|
+
):
|
|
52
|
+
self.log_level = log_level
|
|
53
|
+
self.log_formatter = log_formatter
|
|
54
|
+
|
|
55
|
+
if logger is None:
|
|
56
|
+
self.logger = logging.getLogger(__name__)
|
|
57
|
+
self.logger.setLevel(log_level)
|
|
58
|
+
# Prevent log propagation to root logger to avoid duplicate logs
|
|
59
|
+
# when external packages (like lyrics_converter) configure root logger handlers
|
|
60
|
+
self.logger.propagate = False
|
|
61
|
+
|
|
62
|
+
self.log_handler = logging.StreamHandler()
|
|
63
|
+
|
|
64
|
+
if self.log_formatter is None:
|
|
65
|
+
self.log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s")
|
|
66
|
+
|
|
67
|
+
self.log_handler.setFormatter(self.log_formatter)
|
|
68
|
+
self.logger.addHandler(self.log_handler)
|
|
69
|
+
else:
|
|
70
|
+
self.logger = logger
|
|
71
|
+
|
|
72
|
+
self.logger.debug(
|
|
73
|
+
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}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Path to the Windows PyInstaller frozen bundled ffmpeg.exe, or the system-installed FFmpeg binary on Mac/Linux
|
|
77
|
+
ffmpeg_path = os.path.join(sys._MEIPASS, "ffmpeg.exe") if getattr(sys, "frozen", False) else "ffmpeg"
|
|
78
|
+
|
|
79
|
+
self.ffmpeg_base_command = f"{ffmpeg_path} -hide_banner -nostats"
|
|
80
|
+
|
|
81
|
+
if self.log_level == logging.DEBUG:
|
|
82
|
+
self.ffmpeg_base_command += " -loglevel verbose"
|
|
83
|
+
else:
|
|
84
|
+
self.ffmpeg_base_command += " -loglevel fatal"
|
|
85
|
+
|
|
86
|
+
self.dry_run = dry_run
|
|
87
|
+
self.instrumental_format = instrumental_format
|
|
88
|
+
|
|
89
|
+
self.brand_prefix = brand_prefix
|
|
90
|
+
self.organised_dir = organised_dir
|
|
91
|
+
self.organised_dir_rclone_root = organised_dir_rclone_root
|
|
92
|
+
|
|
93
|
+
self.public_share_dir = public_share_dir
|
|
94
|
+
self.youtube_client_secrets_file = youtube_client_secrets_file
|
|
95
|
+
self.youtube_description_file = youtube_description_file
|
|
96
|
+
self.rclone_destination = rclone_destination
|
|
97
|
+
self.discord_webhook_url = discord_webhook_url
|
|
98
|
+
self.enable_cdg = enable_cdg
|
|
99
|
+
self.enable_txt = enable_txt
|
|
100
|
+
|
|
101
|
+
self.youtube_upload_enabled = False
|
|
102
|
+
self.discord_notication_enabled = False
|
|
103
|
+
self.folder_organisation_enabled = False
|
|
104
|
+
self.public_share_copy_enabled = False
|
|
105
|
+
self.public_share_rclone_enabled = False
|
|
106
|
+
|
|
107
|
+
self.skip_notifications = False
|
|
108
|
+
self.non_interactive = non_interactive
|
|
109
|
+
self.user_youtube_credentials = user_youtube_credentials
|
|
110
|
+
self.server_side_mode = server_side_mode
|
|
111
|
+
self.selected_instrumental_file = selected_instrumental_file
|
|
112
|
+
self.countdown_padding_seconds = countdown_padding_seconds
|
|
113
|
+
|
|
114
|
+
self.suffixes = {
|
|
115
|
+
"title_mov": " (Title).mov",
|
|
116
|
+
"title_jpg": " (Title).jpg",
|
|
117
|
+
"end_mov": " (End).mov",
|
|
118
|
+
"end_jpg": " (End).jpg",
|
|
119
|
+
"with_vocals_mov": " (With Vocals).mov",
|
|
120
|
+
"with_vocals_mp4": " (With Vocals).mp4",
|
|
121
|
+
"with_vocals_mkv": " (With Vocals).mkv",
|
|
122
|
+
"karaoke_lrc": " (Karaoke).lrc",
|
|
123
|
+
"karaoke_txt": " (Karaoke).txt",
|
|
124
|
+
"karaoke_mp4": " (Karaoke).mp4",
|
|
125
|
+
"karaoke_cdg": " (Karaoke).cdg",
|
|
126
|
+
"karaoke_mp3": " (Karaoke).mp3",
|
|
127
|
+
"final_karaoke_lossless_mp4": " (Final Karaoke Lossless 4k).mp4",
|
|
128
|
+
"final_karaoke_lossless_mkv": " (Final Karaoke Lossless 4k).mkv",
|
|
129
|
+
"final_karaoke_lossy_mp4": " (Final Karaoke Lossy 4k).mp4",
|
|
130
|
+
"final_karaoke_lossy_720p_mp4": " (Final Karaoke Lossy 720p).mp4",
|
|
131
|
+
"final_karaoke_cdg_zip": " (Final Karaoke CDG).zip",
|
|
132
|
+
"final_karaoke_txt_zip": " (Final Karaoke TXT).zip",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
self.youtube_url_prefix = "https://www.youtube.com/watch?v="
|
|
136
|
+
|
|
137
|
+
self.youtube_url = None
|
|
138
|
+
self.brand_code = None
|
|
139
|
+
self.new_brand_code_dir = None
|
|
140
|
+
self.new_brand_code_dir_path = None
|
|
141
|
+
self.brand_code_dir_sharing_link = None
|
|
142
|
+
|
|
143
|
+
self.email_template_file = email_template_file
|
|
144
|
+
self.gmail_service = None
|
|
145
|
+
|
|
146
|
+
self.cdg_styles = cdg_styles
|
|
147
|
+
|
|
148
|
+
# Determine best available AAC codec
|
|
149
|
+
self.aac_codec = self.detect_best_aac_codec()
|
|
150
|
+
|
|
151
|
+
self.keep_brand_code = keep_brand_code
|
|
152
|
+
|
|
153
|
+
# MP4 output flags for better compatibility and streaming
|
|
154
|
+
self.mp4_flags = "-pix_fmt yuv420p -movflags +faststart+frag_keyframe+empty_moov"
|
|
155
|
+
|
|
156
|
+
# Update ffmpeg base command to include -y if non-interactive
|
|
157
|
+
if self.non_interactive:
|
|
158
|
+
self.ffmpeg_base_command += " -y"
|
|
159
|
+
|
|
160
|
+
# Detect and configure hardware acceleration
|
|
161
|
+
# TODO: Re-enable this once we figure out why the resulting MP4s are 10x larger than when encoded with x264...
|
|
162
|
+
self.nvenc_available = False # self.detect_nvenc_support()
|
|
163
|
+
self.configure_hardware_acceleration()
|
|
164
|
+
|
|
165
|
+
def check_input_files_exist(self, base_name, with_vocals_file, instrumental_audio_file):
|
|
166
|
+
self.logger.info(f"Checking required input files exist...")
|
|
167
|
+
|
|
168
|
+
input_files = {
|
|
169
|
+
"title_mov": f"{base_name}{self.suffixes['title_mov']}",
|
|
170
|
+
"title_jpg": f"{base_name}{self.suffixes['title_jpg']}",
|
|
171
|
+
"instrumental_audio": instrumental_audio_file,
|
|
172
|
+
"with_vocals_mov": with_vocals_file,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
optional_input_files = {
|
|
176
|
+
"end_mov": f"{base_name}{self.suffixes['end_mov']}",
|
|
177
|
+
"end_jpg": f"{base_name}{self.suffixes['end_jpg']}",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if self.enable_cdg or self.enable_txt:
|
|
181
|
+
input_files["karaoke_lrc"] = f"{base_name}{self.suffixes['karaoke_lrc']}"
|
|
182
|
+
|
|
183
|
+
for key, file_path in input_files.items():
|
|
184
|
+
if not os.path.isfile(file_path):
|
|
185
|
+
raise Exception(f"Input file {key} not found: {file_path}")
|
|
186
|
+
|
|
187
|
+
self.logger.info(f" Input file {key} found: {file_path}")
|
|
188
|
+
|
|
189
|
+
for key, file_path in optional_input_files.items():
|
|
190
|
+
if not os.path.isfile(file_path):
|
|
191
|
+
self.logger.info(f" Optional input file {key} not found: {file_path}")
|
|
192
|
+
|
|
193
|
+
self.logger.info(f" Input file {key} found, adding to input_files: {file_path}")
|
|
194
|
+
input_files[key] = file_path
|
|
195
|
+
|
|
196
|
+
return input_files
|
|
197
|
+
|
|
198
|
+
def prepare_output_filenames(self, base_name):
|
|
199
|
+
output_files = {
|
|
200
|
+
"karaoke_mp4": f"{base_name}{self.suffixes['karaoke_mp4']}",
|
|
201
|
+
"karaoke_mp3": f"{base_name}{self.suffixes['karaoke_mp3']}",
|
|
202
|
+
"karaoke_cdg": f"{base_name}{self.suffixes['karaoke_cdg']}",
|
|
203
|
+
"with_vocals_mp4": f"{base_name}{self.suffixes['with_vocals_mp4']}",
|
|
204
|
+
"final_karaoke_lossless_mp4": f"{base_name}{self.suffixes['final_karaoke_lossless_mp4']}",
|
|
205
|
+
"final_karaoke_lossless_mkv": f"{base_name}{self.suffixes['final_karaoke_lossless_mkv']}",
|
|
206
|
+
"final_karaoke_lossy_mp4": f"{base_name}{self.suffixes['final_karaoke_lossy_mp4']}",
|
|
207
|
+
"final_karaoke_lossy_720p_mp4": f"{base_name}{self.suffixes['final_karaoke_lossy_720p_mp4']}",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if self.enable_cdg:
|
|
211
|
+
output_files["final_karaoke_cdg_zip"] = f"{base_name}{self.suffixes['final_karaoke_cdg_zip']}"
|
|
212
|
+
|
|
213
|
+
if self.enable_txt:
|
|
214
|
+
output_files["karaoke_txt"] = f"{base_name}{self.suffixes['karaoke_txt']}"
|
|
215
|
+
output_files["final_karaoke_txt_zip"] = f"{base_name}{self.suffixes['final_karaoke_txt_zip']}"
|
|
216
|
+
|
|
217
|
+
return output_files
|
|
218
|
+
|
|
219
|
+
def prompt_user_confirmation_or_raise_exception(self, prompt_message, exit_message, allow_empty=False):
|
|
220
|
+
if self.non_interactive:
|
|
221
|
+
self.logger.info(f"Non-interactive mode, automatically confirming: {prompt_message}")
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
if not self.prompt_user_bool(prompt_message, allow_empty=allow_empty):
|
|
225
|
+
self.logger.error(exit_message)
|
|
226
|
+
raise Exception(exit_message)
|
|
227
|
+
|
|
228
|
+
def prompt_user_bool(self, prompt_message, allow_empty=False):
|
|
229
|
+
if self.non_interactive:
|
|
230
|
+
self.logger.info(f"Non-interactive mode, automatically answering yes to: {prompt_message}")
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
options_string = "[y]/n" if allow_empty else "y/[n]"
|
|
234
|
+
accept_responses = ["y", "yes"]
|
|
235
|
+
if allow_empty:
|
|
236
|
+
accept_responses.append("")
|
|
237
|
+
|
|
238
|
+
print()
|
|
239
|
+
response = input(f"{prompt_message} {options_string} ").strip().lower()
|
|
240
|
+
return response in accept_responses
|
|
241
|
+
|
|
242
|
+
def validate_input_parameters_for_features(self):
|
|
243
|
+
self.logger.info(f"Validating input parameters for enabled features...")
|
|
244
|
+
|
|
245
|
+
current_directory = os.getcwd()
|
|
246
|
+
self.logger.info(f"Current directory to process: {current_directory}")
|
|
247
|
+
|
|
248
|
+
# Enable youtube upload if client secrets file is provided and is valid JSON
|
|
249
|
+
if self.youtube_client_secrets_file is not None and self.youtube_description_file is not None:
|
|
250
|
+
if not os.path.isfile(self.youtube_client_secrets_file):
|
|
251
|
+
raise Exception(f"YouTube client secrets file does not exist: {self.youtube_client_secrets_file}")
|
|
252
|
+
|
|
253
|
+
if not os.path.isfile(self.youtube_description_file):
|
|
254
|
+
raise Exception(f"YouTube description file does not exist: {self.youtube_description_file}")
|
|
255
|
+
|
|
256
|
+
# Test parsing the file as JSON to check it's valid
|
|
257
|
+
try:
|
|
258
|
+
with open(self.youtube_client_secrets_file, "r") as f:
|
|
259
|
+
json.load(f)
|
|
260
|
+
except json.JSONDecodeError as e:
|
|
261
|
+
raise Exception(f"YouTube client secrets file is not valid JSON: {self.youtube_client_secrets_file}") from e
|
|
262
|
+
|
|
263
|
+
self.logger.debug(f"YouTube upload checks passed, enabling YouTube upload")
|
|
264
|
+
self.youtube_upload_enabled = True
|
|
265
|
+
|
|
266
|
+
# Also enable YouTube upload if pre-stored credentials are provided (server-side mode)
|
|
267
|
+
elif self.user_youtube_credentials is not None and self.youtube_description_file is not None:
|
|
268
|
+
if not os.path.isfile(self.youtube_description_file):
|
|
269
|
+
raise Exception(f"YouTube description file does not exist: {self.youtube_description_file}")
|
|
270
|
+
|
|
271
|
+
self.logger.debug(f"Pre-stored YouTube credentials provided, enabling YouTube upload")
|
|
272
|
+
self.youtube_upload_enabled = True
|
|
273
|
+
|
|
274
|
+
# Enable discord notifications if webhook URL is provided and is valid URL
|
|
275
|
+
if self.discord_webhook_url is not None:
|
|
276
|
+
# Strip whitespace/newlines that may have been introduced from environment variables or secrets
|
|
277
|
+
self.discord_webhook_url = self.discord_webhook_url.strip()
|
|
278
|
+
if not self.discord_webhook_url.startswith("https://discord.com/api/webhooks/"):
|
|
279
|
+
raise Exception(f"Discord webhook URL is not valid: {self.discord_webhook_url}")
|
|
280
|
+
|
|
281
|
+
self.logger.debug(f"Discord webhook URL checks passed, enabling Discord notifications")
|
|
282
|
+
self.discord_notication_enabled = True
|
|
283
|
+
|
|
284
|
+
# Enable folder organisation if brand prefix and target directory are provided and target directory is valid
|
|
285
|
+
# In server-side mode, we skip the local folder organization but may still need brand codes
|
|
286
|
+
if self.brand_prefix is not None and self.organised_dir is not None:
|
|
287
|
+
if not self.server_side_mode and not os.path.isdir(self.organised_dir):
|
|
288
|
+
raise Exception(f"Target directory does not exist: {self.organised_dir}")
|
|
289
|
+
|
|
290
|
+
if not self.server_side_mode:
|
|
291
|
+
self.logger.debug(f"Brand prefix and target directory provided, enabling local folder organisation")
|
|
292
|
+
self.folder_organisation_enabled = True
|
|
293
|
+
else:
|
|
294
|
+
self.logger.debug(f"Server-side mode: brand prefix provided for remote organization")
|
|
295
|
+
self.folder_organisation_enabled = False # Disable local folder organization in server mode
|
|
296
|
+
|
|
297
|
+
# Enable public share copy if public share directory is provided and is valid directory with MP4 and CDG subdirectories
|
|
298
|
+
if self.public_share_dir is not None:
|
|
299
|
+
if not os.path.isdir(self.public_share_dir):
|
|
300
|
+
raise Exception(f"Public share directory does not exist: {self.public_share_dir}")
|
|
301
|
+
|
|
302
|
+
if not os.path.isdir(os.path.join(self.public_share_dir, "MP4")):
|
|
303
|
+
raise Exception(f"Public share directory does not contain MP4 subdirectory: {self.public_share_dir}")
|
|
304
|
+
|
|
305
|
+
if not os.path.isdir(os.path.join(self.public_share_dir, "CDG")):
|
|
306
|
+
raise Exception(f"Public share directory does not contain CDG subdirectory: {self.public_share_dir}")
|
|
307
|
+
|
|
308
|
+
self.logger.debug(f"Public share directory checks passed, enabling public share copy")
|
|
309
|
+
self.public_share_copy_enabled = True
|
|
310
|
+
|
|
311
|
+
# Enable public share rclone if rclone destination is provided
|
|
312
|
+
if self.rclone_destination is not None:
|
|
313
|
+
self.logger.debug(f"Rclone destination provided, enabling rclone sync")
|
|
314
|
+
self.public_share_rclone_enabled = True
|
|
315
|
+
|
|
316
|
+
# Tell user which features are enabled, prompt them to confirm before proceeding
|
|
317
|
+
self.logger.info(f"Enabled features:")
|
|
318
|
+
self.logger.info(f" CDG ZIP creation: {self.enable_cdg}")
|
|
319
|
+
self.logger.info(f" TXT ZIP creation: {self.enable_txt}")
|
|
320
|
+
self.logger.info(f" YouTube upload: {self.youtube_upload_enabled}")
|
|
321
|
+
self.logger.info(f" Discord notifications: {self.discord_notication_enabled}")
|
|
322
|
+
self.logger.info(f" Folder organisation: {self.folder_organisation_enabled}")
|
|
323
|
+
self.logger.info(f" Public share copy: {self.public_share_copy_enabled}")
|
|
324
|
+
self.logger.info(f" Public share rclone: {self.public_share_rclone_enabled}")
|
|
325
|
+
|
|
326
|
+
# Skip user confirmation in non-interactive mode for Modal deployment
|
|
327
|
+
if not self.non_interactive:
|
|
328
|
+
self.prompt_user_confirmation_or_raise_exception(
|
|
329
|
+
f"Confirm features enabled log messages above match your expectations for finalisation?",
|
|
330
|
+
"Refusing to proceed without user confirmation they're happy with enabled features.",
|
|
331
|
+
allow_empty=True,
|
|
332
|
+
)
|
|
333
|
+
else:
|
|
334
|
+
self.logger.info("Non-interactive mode: automatically confirming enabled features")
|
|
335
|
+
|
|
336
|
+
def authenticate_youtube(self):
|
|
337
|
+
"""Authenticate with YouTube and return service object."""
|
|
338
|
+
from google.auth.transport.requests import Request
|
|
339
|
+
from google.oauth2.credentials import Credentials
|
|
340
|
+
from googleapiclient.discovery import build
|
|
341
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
342
|
+
import pickle
|
|
343
|
+
import os
|
|
344
|
+
|
|
345
|
+
# Check if we have pre-stored credentials (for non-interactive mode)
|
|
346
|
+
if self.user_youtube_credentials and self.non_interactive:
|
|
347
|
+
try:
|
|
348
|
+
# Create credentials object from stored data
|
|
349
|
+
credentials = Credentials(
|
|
350
|
+
token=self.user_youtube_credentials['token'],
|
|
351
|
+
refresh_token=self.user_youtube_credentials.get('refresh_token'),
|
|
352
|
+
token_uri=self.user_youtube_credentials.get('token_uri'),
|
|
353
|
+
client_id=self.user_youtube_credentials.get('client_id'),
|
|
354
|
+
client_secret=self.user_youtube_credentials.get('client_secret'),
|
|
355
|
+
scopes=self.user_youtube_credentials.get('scopes')
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# Refresh token if needed
|
|
359
|
+
if credentials.expired and credentials.refresh_token:
|
|
360
|
+
credentials.refresh(Request())
|
|
361
|
+
|
|
362
|
+
# Build YouTube service with credentials
|
|
363
|
+
youtube = build('youtube', 'v3', credentials=credentials)
|
|
364
|
+
self.logger.info("Successfully authenticated with YouTube using pre-stored credentials")
|
|
365
|
+
return youtube
|
|
366
|
+
|
|
367
|
+
except Exception as e:
|
|
368
|
+
self.logger.error(f"Failed to authenticate with pre-stored credentials: {str(e)}")
|
|
369
|
+
# Fall through to original authentication if pre-stored credentials fail
|
|
370
|
+
|
|
371
|
+
# Original authentication code for interactive mode
|
|
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
|
+
|
|
375
|
+
# Token file stores the user's access and refresh tokens for YouTube.
|
|
376
|
+
youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
|
|
377
|
+
|
|
378
|
+
credentials = None
|
|
379
|
+
|
|
380
|
+
# Check if we have saved credentials
|
|
381
|
+
if os.path.exists(youtube_token_file):
|
|
382
|
+
with open(youtube_token_file, "rb") as token:
|
|
383
|
+
credentials = pickle.load(token)
|
|
384
|
+
|
|
385
|
+
# If there are no valid credentials, let the user log in.
|
|
386
|
+
if not credentials or not credentials.valid:
|
|
387
|
+
if credentials and credentials.expired and credentials.refresh_token:
|
|
388
|
+
credentials.refresh(Request())
|
|
389
|
+
else:
|
|
390
|
+
if self.non_interactive:
|
|
391
|
+
raise Exception("YouTube authentication required but running in non-interactive mode. Please pre-authenticate or disable YouTube upload.")
|
|
392
|
+
|
|
393
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
394
|
+
self.youtube_client_secrets_file, scopes=["https://www.googleapis.com/auth/youtube"]
|
|
395
|
+
)
|
|
396
|
+
credentials = flow.run_local_server(port=0) # This will open a browser for authentication
|
|
397
|
+
|
|
398
|
+
# Save the credentials for the next run
|
|
399
|
+
with open(youtube_token_file, "wb") as token:
|
|
400
|
+
pickle.dump(credentials, token)
|
|
401
|
+
|
|
402
|
+
return build("youtube", "v3", credentials=credentials)
|
|
403
|
+
|
|
404
|
+
def get_channel_id(self):
|
|
405
|
+
youtube = self.authenticate_youtube()
|
|
406
|
+
|
|
407
|
+
# Get the authenticated user's channel
|
|
408
|
+
request = youtube.channels().list(part="snippet", mine=True)
|
|
409
|
+
response = request.execute()
|
|
410
|
+
|
|
411
|
+
# Extract the channel ID
|
|
412
|
+
if "items" in response:
|
|
413
|
+
channel_id = response["items"][0]["id"]
|
|
414
|
+
return channel_id
|
|
415
|
+
else:
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
def check_if_video_title_exists_on_youtube_channel(self, youtube_title):
|
|
419
|
+
youtube = self.authenticate_youtube()
|
|
420
|
+
channel_id = self.get_channel_id()
|
|
421
|
+
|
|
422
|
+
self.logger.info(f"Searching YouTube channel {channel_id} for title: {youtube_title}")
|
|
423
|
+
request = youtube.search().list(part="snippet", channelId=channel_id, q=youtube_title, type="video", maxResults=10)
|
|
424
|
+
response = request.execute()
|
|
425
|
+
|
|
426
|
+
# Check if any videos were found
|
|
427
|
+
if "items" in response and len(response["items"]) > 0:
|
|
428
|
+
for item in response["items"]:
|
|
429
|
+
# YouTube search API sometimes returns results from other channels even with channelId filter
|
|
430
|
+
# Verify the video actually belongs to our channel
|
|
431
|
+
result_channel_id = item["snippet"]["channelId"]
|
|
432
|
+
if result_channel_id != channel_id:
|
|
433
|
+
self.logger.debug(
|
|
434
|
+
f"Skipping video from different channel: {item['snippet']['title']} (channel: {result_channel_id})"
|
|
435
|
+
)
|
|
436
|
+
continue
|
|
437
|
+
|
|
438
|
+
found_title = item["snippet"]["title"]
|
|
439
|
+
|
|
440
|
+
# In server-side mode, require an exact match to avoid false positives.
|
|
441
|
+
# Otherwise, use fuzzy matching for interactive CLI usage.
|
|
442
|
+
if self.server_side_mode:
|
|
443
|
+
is_match = youtube_title.lower() == found_title.lower()
|
|
444
|
+
similarity_score = 100 if is_match else 0
|
|
445
|
+
else:
|
|
446
|
+
similarity_score = fuzz.ratio(youtube_title.lower(), found_title.lower())
|
|
447
|
+
is_match = similarity_score >= 70
|
|
448
|
+
|
|
449
|
+
if is_match:
|
|
450
|
+
found_id = item["id"]["videoId"]
|
|
451
|
+
self.logger.info(
|
|
452
|
+
f"Potential match found on YouTube channel with ID: {found_id} and title: {found_title} (similarity: {similarity_score}%)"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# In non-interactive mode (server mode), we don't prompt. Just record the match and return.
|
|
456
|
+
if self.non_interactive:
|
|
457
|
+
self.logger.info(f"Non-interactive mode, found a match.")
|
|
458
|
+
self.youtube_video_id = found_id
|
|
459
|
+
self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
|
|
460
|
+
return True
|
|
461
|
+
|
|
462
|
+
confirmation = input(f"Is '{found_title}' the video you are finalising? (y/n): ").strip().lower()
|
|
463
|
+
if confirmation == "y":
|
|
464
|
+
self.youtube_video_id = found_id
|
|
465
|
+
self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
|
|
466
|
+
return True
|
|
467
|
+
|
|
468
|
+
self.logger.info(f"No matching video found with title: {youtube_title}")
|
|
469
|
+
return False
|
|
470
|
+
|
|
471
|
+
def delete_youtube_video(self, video_id):
|
|
472
|
+
"""
|
|
473
|
+
Delete a YouTube video by its ID.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
video_id: The YouTube video ID to delete
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
True if successful, False otherwise
|
|
480
|
+
"""
|
|
481
|
+
self.logger.info(f"Deleting YouTube video with ID: {video_id}")
|
|
482
|
+
|
|
483
|
+
if self.dry_run:
|
|
484
|
+
self.logger.info(f"DRY RUN: Would delete YouTube video with ID: {video_id}")
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
youtube = self.authenticate_youtube()
|
|
489
|
+
youtube.videos().delete(id=video_id).execute()
|
|
490
|
+
self.logger.info(f"Successfully deleted YouTube video with ID: {video_id}")
|
|
491
|
+
return True
|
|
492
|
+
except Exception as e:
|
|
493
|
+
self.logger.error(f"Failed to delete YouTube video with ID {video_id}: {e}")
|
|
494
|
+
return False
|
|
495
|
+
|
|
496
|
+
def truncate_to_nearest_word(self, title, max_length):
|
|
497
|
+
if len(title) <= max_length:
|
|
498
|
+
return title
|
|
499
|
+
truncated_title = title[:max_length].rsplit(" ", 1)[0]
|
|
500
|
+
if len(truncated_title) < max_length:
|
|
501
|
+
truncated_title += " ..."
|
|
502
|
+
return truncated_title
|
|
503
|
+
|
|
504
|
+
def upload_final_mp4_to_youtube_with_title_thumbnail(self, artist, title, input_files, output_files, replace_existing=False):
|
|
505
|
+
self.logger.info(f"Uploading final MKV to YouTube with title thumbnail...")
|
|
506
|
+
if self.dry_run:
|
|
507
|
+
self.logger.info(
|
|
508
|
+
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}'
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
youtube_title = f"{artist} - {title} (Karaoke)"
|
|
512
|
+
|
|
513
|
+
# Truncate title to the nearest whole word and add ellipsis if needed
|
|
514
|
+
max_length = 95
|
|
515
|
+
youtube_title = self.truncate_to_nearest_word(youtube_title, max_length)
|
|
516
|
+
|
|
517
|
+
# In server-side mode, we should always replace videos if an exact match is found.
|
|
518
|
+
# Otherwise, respect the replace_existing flag from CLI.
|
|
519
|
+
should_replace = True if self.server_side_mode else replace_existing
|
|
520
|
+
|
|
521
|
+
if self.check_if_video_title_exists_on_youtube_channel(youtube_title):
|
|
522
|
+
if should_replace:
|
|
523
|
+
self.logger.info(f"Video already exists on YouTube, deleting before re-upload: {self.youtube_url}")
|
|
524
|
+
if self.delete_youtube_video(self.youtube_video_id):
|
|
525
|
+
self.logger.info(f"Successfully deleted existing video, proceeding with upload")
|
|
526
|
+
# Reset the video ID and URL since we're uploading a new one
|
|
527
|
+
self.youtube_video_id = None
|
|
528
|
+
self.youtube_url = None
|
|
529
|
+
else:
|
|
530
|
+
self.logger.error(f"Failed to delete existing video, aborting upload")
|
|
531
|
+
return
|
|
532
|
+
else:
|
|
533
|
+
self.logger.warning(f"Video already exists on YouTube, skipping upload: {self.youtube_url}")
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
youtube_description = f"Karaoke version of {artist} - {title} created using karaoke-gen python package."
|
|
537
|
+
if self.youtube_description_file is not None:
|
|
538
|
+
with open(self.youtube_description_file, "r") as f:
|
|
539
|
+
youtube_description = f.read()
|
|
540
|
+
|
|
541
|
+
youtube_category_id = "10" # Category ID for Music
|
|
542
|
+
youtube_keywords = ["karaoke", "music", "singing", "instrumental", "lyrics", artist, title]
|
|
543
|
+
|
|
544
|
+
self.logger.info(f"Authenticating with YouTube...")
|
|
545
|
+
# Upload video to YouTube and set thumbnail.
|
|
546
|
+
youtube = self.authenticate_youtube()
|
|
547
|
+
|
|
548
|
+
body = {
|
|
549
|
+
"snippet": {
|
|
550
|
+
"title": youtube_title,
|
|
551
|
+
"description": youtube_description,
|
|
552
|
+
"tags": youtube_keywords,
|
|
553
|
+
"categoryId": youtube_category_id,
|
|
554
|
+
},
|
|
555
|
+
"status": {"privacyStatus": "public"},
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
# Use MediaFileUpload to handle the video file - using the MKV with FLAC audio
|
|
559
|
+
media_file = MediaFileUpload(output_files["final_karaoke_lossless_mkv"], mimetype="video/x-matroska", resumable=True)
|
|
560
|
+
|
|
561
|
+
# Call the API's videos.insert method to create and upload the video.
|
|
562
|
+
self.logger.info(f"Uploading final MKV to YouTube...")
|
|
563
|
+
request = youtube.videos().insert(part="snippet,status", body=body, media_body=media_file)
|
|
564
|
+
response = request.execute()
|
|
565
|
+
|
|
566
|
+
self.youtube_video_id = response.get("id")
|
|
567
|
+
self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
|
|
568
|
+
self.logger.info(f"Uploaded video to YouTube: {self.youtube_url}")
|
|
569
|
+
|
|
570
|
+
# Uploading the thumbnail
|
|
571
|
+
if input_files.get("title_jpg") and os.path.isfile(input_files["title_jpg"]):
|
|
572
|
+
try:
|
|
573
|
+
self.logger.info(f"Uploading thumbnail from: {input_files['title_jpg']}")
|
|
574
|
+
media_thumbnail = MediaFileUpload(input_files["title_jpg"], mimetype="image/jpeg")
|
|
575
|
+
youtube.thumbnails().set(videoId=self.youtube_video_id, media_body=media_thumbnail).execute()
|
|
576
|
+
self.logger.info(f"Uploaded thumbnail for video ID {self.youtube_video_id}")
|
|
577
|
+
except Exception as e:
|
|
578
|
+
self.logger.error(f"Failed to upload thumbnail: {e}")
|
|
579
|
+
self.logger.warning("Video uploaded but thumbnail not set. You may need to set it manually on YouTube.")
|
|
580
|
+
else:
|
|
581
|
+
self.logger.warning(f"Thumbnail file not found, skipping thumbnail upload: {input_files.get('title_jpg')}")
|
|
582
|
+
|
|
583
|
+
def get_next_brand_code(self):
|
|
584
|
+
"""
|
|
585
|
+
Calculate the next sequence number based on existing directories in the organised_dir.
|
|
586
|
+
Assumes directories are named with the format: BRAND-XXXX Artist - Title
|
|
587
|
+
"""
|
|
588
|
+
max_num = 0
|
|
589
|
+
pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
|
|
590
|
+
|
|
591
|
+
if not os.path.isdir(self.organised_dir):
|
|
592
|
+
raise Exception(f"Target directory does not exist: {self.organised_dir}")
|
|
593
|
+
|
|
594
|
+
for dir_name in os.listdir(self.organised_dir):
|
|
595
|
+
match = pattern.match(dir_name)
|
|
596
|
+
if match:
|
|
597
|
+
num = int(match.group(1))
|
|
598
|
+
max_num = max(max_num, num)
|
|
599
|
+
|
|
600
|
+
self.logger.info(f"Next sequence number for brand {self.brand_prefix} calculated as: {max_num + 1}")
|
|
601
|
+
next_seq_number = max_num + 1
|
|
602
|
+
|
|
603
|
+
return f"{self.brand_prefix}-{next_seq_number:04d}"
|
|
604
|
+
|
|
605
|
+
def post_discord_message(self, message, webhook_url):
|
|
606
|
+
"""Post a message to a Discord channel via webhook."""
|
|
607
|
+
data = {"content": message}
|
|
608
|
+
response = requests.post(webhook_url, json=data)
|
|
609
|
+
response.raise_for_status() # This will raise an exception if the request failed
|
|
610
|
+
self.logger.info("Message posted to Discord")
|
|
611
|
+
|
|
612
|
+
def find_with_vocals_file(self):
|
|
613
|
+
self.logger.info("Finding input file ending in (With Vocals).mov/.mp4/.mkv or (Karaoke).mov/.mp4/.mkv")
|
|
614
|
+
|
|
615
|
+
# Define all possible suffixes for with vocals files
|
|
616
|
+
with_vocals_suffixes = [
|
|
617
|
+
self.suffixes["with_vocals_mov"],
|
|
618
|
+
self.suffixes["with_vocals_mp4"],
|
|
619
|
+
self.suffixes["with_vocals_mkv"],
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
# First try to find a properly named with vocals file in any supported format
|
|
623
|
+
with_vocals_files = [f for f in os.listdir(".") if any(f.endswith(suffix) for suffix in with_vocals_suffixes)]
|
|
624
|
+
|
|
625
|
+
if with_vocals_files:
|
|
626
|
+
self.logger.info(f"Found with vocals file: {with_vocals_files[0]}")
|
|
627
|
+
return with_vocals_files[0]
|
|
628
|
+
|
|
629
|
+
# If no with vocals file found, look for potentially misnamed karaoke files
|
|
630
|
+
karaoke_suffixes = [" (Karaoke).mov", " (Karaoke).mp4", " (Karaoke).mkv"]
|
|
631
|
+
karaoke_files = [f for f in os.listdir(".") if any(f.endswith(suffix) for suffix in karaoke_suffixes)]
|
|
632
|
+
|
|
633
|
+
if karaoke_files:
|
|
634
|
+
for file in karaoke_files:
|
|
635
|
+
# Get the current extension
|
|
636
|
+
current_ext = os.path.splitext(file)[1].lower() # Convert to lowercase
|
|
637
|
+
base_without_suffix = file.replace(f" (Karaoke){current_ext}", "")
|
|
638
|
+
|
|
639
|
+
# Map file extension to suffix dictionary key
|
|
640
|
+
ext_to_suffix = {".mov": "with_vocals_mov", ".mp4": "with_vocals_mp4", ".mkv": "with_vocals_mkv"}
|
|
641
|
+
|
|
642
|
+
if current_ext in ext_to_suffix:
|
|
643
|
+
new_file = f"{base_without_suffix}{self.suffixes[ext_to_suffix[current_ext]]}"
|
|
644
|
+
|
|
645
|
+
self.prompt_user_confirmation_or_raise_exception(
|
|
646
|
+
f"Found '{file}' but no '(With Vocals)', rename to {new_file} for vocal input?",
|
|
647
|
+
"Unable to proceed without With Vocals file or user confirmation of rename.",
|
|
648
|
+
allow_empty=True,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
os.rename(file, new_file)
|
|
652
|
+
self.logger.info(f"Renamed '{file}' to '{new_file}'")
|
|
653
|
+
return new_file
|
|
654
|
+
else:
|
|
655
|
+
self.logger.warning(f"Unsupported file extension: {current_ext}")
|
|
656
|
+
|
|
657
|
+
raise Exception(
|
|
658
|
+
"No suitable files found for processing.\n"
|
|
659
|
+
"\n"
|
|
660
|
+
"WHAT THIS MEANS:\n"
|
|
661
|
+
"The finalisation step requires a '(With Vocals).mkv' video file, which is created "
|
|
662
|
+
"during the lyrics transcription phase. This file contains the karaoke video with "
|
|
663
|
+
"synchronized lyrics overlay.\n"
|
|
664
|
+
"\n"
|
|
665
|
+
"COMMON CAUSES:\n"
|
|
666
|
+
"1. Transcription provider not configured - No AUDIOSHAKE_API_TOKEN or RUNPOD_API_KEY set\n"
|
|
667
|
+
"2. Transcription failed - Check logs above for API errors or timeout messages\n"
|
|
668
|
+
"3. Invalid API credentials - Verify your API tokens are correct and active\n"
|
|
669
|
+
"4. Network issues - Unable to reach transcription service\n"
|
|
670
|
+
"5. Running in wrong directory - Make sure you're in the track output folder\n"
|
|
671
|
+
"\n"
|
|
672
|
+
"TROUBLESHOOTING STEPS:\n"
|
|
673
|
+
"1. Check environment variables:\n"
|
|
674
|
+
" - AUDIOSHAKE_API_TOKEN (for AudioShake transcription)\n"
|
|
675
|
+
" - RUNPOD_API_KEY + WHISPER_RUNPOD_ID (for Whisper transcription)\n"
|
|
676
|
+
"2. Review the log output above for transcription errors\n"
|
|
677
|
+
"3. Try running with --log_level debug for more detailed output\n"
|
|
678
|
+
"4. If you don't need synchronized lyrics, use --skip-lyrics for instrumental-only karaoke\n"
|
|
679
|
+
"\n"
|
|
680
|
+
"See README.md 'Transcription Providers' and 'Troubleshooting' sections for more details."
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
def choose_instrumental_audio_file(self, base_name):
|
|
684
|
+
self.logger.info(f"Choosing instrumental audio file to use as karaoke audio...")
|
|
685
|
+
|
|
686
|
+
search_string = " (Instrumental"
|
|
687
|
+
self.logger.info(f"Searching for files in current directory containing {search_string}")
|
|
688
|
+
|
|
689
|
+
all_instrumental_files = [f for f in os.listdir(".") if search_string in f]
|
|
690
|
+
flac_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".flac"))
|
|
691
|
+
mp3_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".mp3"))
|
|
692
|
+
wav_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".wav"))
|
|
693
|
+
|
|
694
|
+
self.logger.debug(f"FLAC files found: {flac_files}")
|
|
695
|
+
self.logger.debug(f"MP3 files found: {mp3_files}")
|
|
696
|
+
self.logger.debug(f"WAV files found: {wav_files}")
|
|
697
|
+
|
|
698
|
+
# Filter out MP3 files if their FLAC or WAV counterpart exists
|
|
699
|
+
# Filter out WAV files if their FLAC counterpart exists
|
|
700
|
+
filtered_files = [
|
|
701
|
+
f
|
|
702
|
+
for f in all_instrumental_files
|
|
703
|
+
if f.endswith(".flac")
|
|
704
|
+
or (f.endswith(".wav") and f.rsplit(".", 1)[0] not in flac_files)
|
|
705
|
+
or (f.endswith(".mp3") and f.rsplit(".", 1)[0] not in flac_files and f.rsplit(".", 1)[0] not in wav_files)
|
|
706
|
+
]
|
|
707
|
+
|
|
708
|
+
self.logger.debug(f"Filtered instrumental files: {filtered_files}")
|
|
709
|
+
|
|
710
|
+
if not filtered_files:
|
|
711
|
+
raise Exception(f"No instrumental audio files found containing {search_string}")
|
|
712
|
+
|
|
713
|
+
if len(filtered_files) == 1:
|
|
714
|
+
return filtered_files[0]
|
|
715
|
+
|
|
716
|
+
# In non-interactive mode, always choose the first option
|
|
717
|
+
if self.non_interactive:
|
|
718
|
+
self.logger.info(f"Non-interactive mode, automatically choosing first instrumental file: {filtered_files[0]}")
|
|
719
|
+
return filtered_files[0]
|
|
720
|
+
|
|
721
|
+
# Sort the remaining instrumental options alphabetically
|
|
722
|
+
filtered_files.sort(reverse=True)
|
|
723
|
+
|
|
724
|
+
self.logger.info(f"Found multiple files containing {search_string}:")
|
|
725
|
+
for i, file in enumerate(filtered_files):
|
|
726
|
+
self.logger.info(f" {i+1}: {file}")
|
|
727
|
+
|
|
728
|
+
print()
|
|
729
|
+
response = input(f"Choose instrumental audio file to use as karaoke audio: [1]/{len(filtered_files)}: ").strip().lower()
|
|
730
|
+
if response == "":
|
|
731
|
+
response = "1"
|
|
732
|
+
|
|
733
|
+
try:
|
|
734
|
+
response = int(response)
|
|
735
|
+
except ValueError:
|
|
736
|
+
raise Exception(f"Invalid response to instrumental audio file choice prompt: {response}")
|
|
737
|
+
|
|
738
|
+
if response < 1 or response > len(filtered_files):
|
|
739
|
+
raise Exception(f"Invalid response to instrumental audio file choice prompt: {response}")
|
|
740
|
+
|
|
741
|
+
return filtered_files[response - 1]
|
|
742
|
+
|
|
743
|
+
def get_names_from_withvocals(self, with_vocals_file):
|
|
744
|
+
self.logger.info(f"Getting artist and title from {with_vocals_file}")
|
|
745
|
+
|
|
746
|
+
# Remove both possible suffixes and their extensions
|
|
747
|
+
base_name = with_vocals_file
|
|
748
|
+
for suffix_key in ["with_vocals_mov", "with_vocals_mp4", "with_vocals_mkv"]:
|
|
749
|
+
suffix = self.suffixes[suffix_key]
|
|
750
|
+
if suffix in base_name:
|
|
751
|
+
base_name = base_name.replace(suffix, "")
|
|
752
|
+
break
|
|
753
|
+
|
|
754
|
+
# If we didn't find a match above, try removing just the extension
|
|
755
|
+
if base_name == with_vocals_file:
|
|
756
|
+
base_name = os.path.splitext(base_name)[0]
|
|
757
|
+
|
|
758
|
+
artist, title = base_name.split(" - ", 1)
|
|
759
|
+
return base_name, artist, title
|
|
760
|
+
|
|
761
|
+
def _pad_audio_file(self, input_audio, output_audio, padding_seconds):
|
|
762
|
+
"""
|
|
763
|
+
Pad an audio file by prepending silence at the beginning.
|
|
764
|
+
|
|
765
|
+
Uses the same ffmpeg approach as LyricsTranscriber's CountdownProcessor
|
|
766
|
+
to ensure consistent padding behavior.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
input_audio: Path to input audio file
|
|
770
|
+
output_audio: Path for the padded output file
|
|
771
|
+
padding_seconds: Amount of silence to prepend (in seconds)
|
|
772
|
+
"""
|
|
773
|
+
self.logger.info(f"Padding audio file with {padding_seconds}s of silence")
|
|
774
|
+
|
|
775
|
+
# Use ffmpeg to prepend silence - this matches the approach in audio_processor.py
|
|
776
|
+
# adelay filter adds delay in milliseconds
|
|
777
|
+
delay_ms = int(padding_seconds * 1000)
|
|
778
|
+
|
|
779
|
+
ffmpeg_command = (
|
|
780
|
+
f'{self.ffmpeg_base_command} -i "{input_audio}" '
|
|
781
|
+
f'-af "adelay={delay_ms}|{delay_ms}" '
|
|
782
|
+
f'"{output_audio}"'
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
self.execute_command(ffmpeg_command, f"Padding audio with {padding_seconds}s silence")
|
|
786
|
+
|
|
787
|
+
def execute_command(self, command, description):
|
|
788
|
+
"""Execute a shell command and log the output. For general commands (rclone, etc.)"""
|
|
789
|
+
self.logger.info(f"{description}")
|
|
790
|
+
self.logger.debug(f"Executing command: {command}")
|
|
791
|
+
|
|
792
|
+
if self.dry_run:
|
|
793
|
+
self.logger.info(f"DRY RUN: Would execute: {command}")
|
|
794
|
+
return
|
|
795
|
+
|
|
796
|
+
try:
|
|
797
|
+
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=600)
|
|
798
|
+
|
|
799
|
+
# Log command output for debugging
|
|
800
|
+
if result.stdout and result.stdout.strip():
|
|
801
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
802
|
+
if result.stderr and result.stderr.strip():
|
|
803
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
804
|
+
|
|
805
|
+
if result.returncode != 0:
|
|
806
|
+
error_msg = f"Command failed with exit code {result.returncode}"
|
|
807
|
+
self.logger.error(error_msg)
|
|
808
|
+
self.logger.error(f"Command: {command}")
|
|
809
|
+
if result.stdout:
|
|
810
|
+
self.logger.error(f"STDOUT: {result.stdout}")
|
|
811
|
+
if result.stderr:
|
|
812
|
+
self.logger.error(f"STDERR: {result.stderr}")
|
|
813
|
+
raise Exception(f"{error_msg}: {command}")
|
|
814
|
+
else:
|
|
815
|
+
self.logger.info(f"✓ Command completed successfully")
|
|
816
|
+
|
|
817
|
+
except subprocess.TimeoutExpired:
|
|
818
|
+
error_msg = f"Command timed out after 600 seconds"
|
|
819
|
+
self.logger.error(error_msg)
|
|
820
|
+
raise Exception(f"{error_msg}: {command}")
|
|
821
|
+
except Exception as e:
|
|
822
|
+
if "Command failed" not in str(e):
|
|
823
|
+
error_msg = f"Command failed with exception: {e}"
|
|
824
|
+
self.logger.error(error_msg)
|
|
825
|
+
raise Exception(f"{error_msg}: {command}")
|
|
826
|
+
else:
|
|
827
|
+
raise
|
|
828
|
+
|
|
829
|
+
def remux_with_instrumental(self, with_vocals_file, instrumental_audio, output_file):
|
|
830
|
+
"""Remux the video with instrumental audio to create karaoke version"""
|
|
831
|
+
# Safety net: If countdown padding was applied to vocals, ensure instrumental is padded too
|
|
832
|
+
actual_instrumental = instrumental_audio
|
|
833
|
+
if self.countdown_padding_seconds and self.countdown_padding_seconds > 0:
|
|
834
|
+
# Check if the instrumental file is already padded (has "(Padded)" in name)
|
|
835
|
+
if "(Padded)" not in instrumental_audio:
|
|
836
|
+
self.logger.warning(
|
|
837
|
+
f"Countdown padding ({self.countdown_padding_seconds}s) was applied to vocals, "
|
|
838
|
+
f"but instrumental doesn't appear to be padded. Creating padded version..."
|
|
839
|
+
)
|
|
840
|
+
# Create a padded version of the instrumental
|
|
841
|
+
base, ext = os.path.splitext(instrumental_audio)
|
|
842
|
+
padded_instrumental = f"{base} (Padded){ext}"
|
|
843
|
+
|
|
844
|
+
if not os.path.exists(padded_instrumental):
|
|
845
|
+
self._pad_audio_file(instrumental_audio, padded_instrumental, self.countdown_padding_seconds)
|
|
846
|
+
self.logger.info(f"Created padded instrumental: {padded_instrumental}")
|
|
847
|
+
|
|
848
|
+
actual_instrumental = padded_instrumental
|
|
849
|
+
else:
|
|
850
|
+
self.logger.info(f"Using already-padded instrumental: {instrumental_audio}")
|
|
851
|
+
|
|
852
|
+
# This operation is primarily I/O bound (remuxing), so hardware acceleration doesn't provide significant benefit
|
|
853
|
+
# Keep the existing approach but use the new execute method
|
|
854
|
+
ffmpeg_command = (
|
|
855
|
+
f'{self.ffmpeg_base_command} -an -i "{with_vocals_file}" '
|
|
856
|
+
f'-vn -i "{actual_instrumental}" -c:v copy -c:a pcm_s16le "{output_file}"'
|
|
857
|
+
)
|
|
858
|
+
self.execute_command(ffmpeg_command, "Remuxing video with instrumental audio")
|
|
859
|
+
|
|
860
|
+
def convert_mov_to_mp4(self, input_file, output_file):
|
|
861
|
+
"""Convert MOV file to MP4 format with hardware acceleration support"""
|
|
862
|
+
# Hardware-accelerated version
|
|
863
|
+
gpu_command = (
|
|
864
|
+
f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
|
|
865
|
+
f'-c:v {self.video_encoder} {self.get_nvenc_quality_settings("high")} -c:a {self.aac_codec} -ar 48000 {self.mp4_flags} "{output_file}"'
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
# Software fallback version
|
|
869
|
+
cpu_command = (
|
|
870
|
+
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
871
|
+
f'-c:v libx264 -c:a {self.aac_codec} -ar 48000 {self.mp4_flags} "{output_file}"'
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Converting MOV video to MP4")
|
|
875
|
+
|
|
876
|
+
def encode_lossless_mp4(self, title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_file):
|
|
877
|
+
"""Create the final MP4 with PCM audio (lossless) using hardware acceleration when available"""
|
|
878
|
+
# Hardware-accelerated version
|
|
879
|
+
gpu_command = (
|
|
880
|
+
f"{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i {title_mov_file} "
|
|
881
|
+
f"{self.hwaccel_decode_flags} -i {karaoke_mp4_file} {env_mov_input} "
|
|
882
|
+
f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v {self.video_encoder} '
|
|
883
|
+
f'{self.get_nvenc_quality_settings("lossless")} -c:a pcm_s16le {self.mp4_flags} "{output_file}"'
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Software fallback version
|
|
887
|
+
cpu_command = (
|
|
888
|
+
f"{self.ffmpeg_base_command} -i {title_mov_file} -i {karaoke_mp4_file} {env_mov_input} "
|
|
889
|
+
f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
|
|
890
|
+
f'{self.mp4_flags} "{output_file}"'
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Creating MP4 version with PCM audio")
|
|
894
|
+
|
|
895
|
+
def encode_lossy_mp4(self, input_file, output_file):
|
|
896
|
+
"""Create MP4 with AAC audio (lossy, for wider compatibility)"""
|
|
897
|
+
# This is primarily an audio re-encoding operation, video is copied
|
|
898
|
+
# Hardware acceleration doesn't provide significant benefit for copy operations
|
|
899
|
+
ffmpeg_command = (
|
|
900
|
+
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
901
|
+
f'-c:v copy -c:a {self.aac_codec} -ar 48000 -b:a 320k {self.mp4_flags} "{output_file}"'
|
|
902
|
+
)
|
|
903
|
+
self.execute_command(ffmpeg_command, "Creating MP4 version with AAC audio")
|
|
904
|
+
|
|
905
|
+
def encode_lossless_mkv(self, input_file, output_file):
|
|
906
|
+
"""Create MKV with FLAC audio (for YouTube)"""
|
|
907
|
+
# This is primarily an audio re-encoding operation, video is copied
|
|
908
|
+
# Hardware acceleration doesn't provide significant benefit for copy operations
|
|
909
|
+
ffmpeg_command = (
|
|
910
|
+
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
911
|
+
f'-c:v copy -c:a flac "{output_file}"'
|
|
912
|
+
)
|
|
913
|
+
self.execute_command(ffmpeg_command, "Creating MKV version with FLAC audio for YouTube")
|
|
914
|
+
|
|
915
|
+
def encode_720p_version(self, input_file, output_file):
|
|
916
|
+
"""Create 720p MP4 with AAC audio (for smaller file size) using hardware acceleration when available"""
|
|
917
|
+
# Hardware-accelerated version with GPU scaling and encoding
|
|
918
|
+
gpu_command = (
|
|
919
|
+
f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
|
|
920
|
+
f'-c:v {self.video_encoder} -vf "{self.scale_filter}=1280:720" '
|
|
921
|
+
f'{self.get_nvenc_quality_settings("medium")} -b:v 2000k '
|
|
922
|
+
f'-c:a {self.aac_codec} -ar 48000 -b:a 128k {self.mp4_flags} "{output_file}"'
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
# Software fallback version
|
|
926
|
+
cpu_command = (
|
|
927
|
+
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
928
|
+
f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
|
|
929
|
+
f'-c:a {self.aac_codec} -ar 48000 -b:a 128k {self.mp4_flags} "{output_file}"'
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Encoding 720p version of the final video")
|
|
933
|
+
|
|
934
|
+
def prepare_concat_filter(self, input_files):
|
|
935
|
+
"""Prepare the concat filter and additional input for end credits if present"""
|
|
936
|
+
env_mov_input = ""
|
|
937
|
+
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]"'
|
|
938
|
+
|
|
939
|
+
if "end_mov" in input_files and os.path.isfile(input_files["end_mov"]):
|
|
940
|
+
self.logger.info(f"Found end_mov file: {input_files['end_mov']}, including in final MP4")
|
|
941
|
+
end_mov_file = shlex.quote(os.path.abspath(input_files["end_mov"]))
|
|
942
|
+
env_mov_input = f"-i {end_mov_file}"
|
|
943
|
+
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]"'
|
|
944
|
+
|
|
945
|
+
return env_mov_input, ffmpeg_filter
|
|
946
|
+
|
|
947
|
+
def remux_and_encode_output_video_files(self, with_vocals_file, input_files, output_files):
|
|
948
|
+
self.logger.info(f"Remuxing and encoding output video files (4 formats, ~15-20 minutes total)...")
|
|
949
|
+
|
|
950
|
+
# Check if output files already exist
|
|
951
|
+
if os.path.isfile(output_files["final_karaoke_lossless_mp4"]) and os.path.isfile(output_files["final_karaoke_lossless_mkv"]):
|
|
952
|
+
if not self.prompt_user_bool(
|
|
953
|
+
f"Found existing Final Karaoke output files. Overwrite (y) or skip (n)?",
|
|
954
|
+
):
|
|
955
|
+
self.logger.info(f"Skipping Karaoke MP4 remux and Final video renders, existing files will be used.")
|
|
956
|
+
return
|
|
957
|
+
|
|
958
|
+
# Create karaoke version with instrumental audio
|
|
959
|
+
self.logger.info(f"[Step 1/6] Remuxing video with instrumental audio...")
|
|
960
|
+
self.remux_with_instrumental(with_vocals_file, input_files["instrumental_audio"], output_files["karaoke_mp4"])
|
|
961
|
+
|
|
962
|
+
# Convert the with vocals video to MP4 if needed
|
|
963
|
+
if not with_vocals_file.endswith(".mp4"):
|
|
964
|
+
self.logger.info(f"[Step 2/6] Converting karaoke video to MP4...")
|
|
965
|
+
self.convert_mov_to_mp4(with_vocals_file, output_files["with_vocals_mp4"])
|
|
966
|
+
|
|
967
|
+
# Delete the with vocals mov after successfully converting it to mp4
|
|
968
|
+
if not self.dry_run and os.path.isfile(with_vocals_file):
|
|
969
|
+
self.logger.info(f"Deleting with vocals MOV file: {with_vocals_file}")
|
|
970
|
+
os.remove(with_vocals_file)
|
|
971
|
+
else:
|
|
972
|
+
self.logger.info(f"[Step 2/6] Skipped - video already in MP4 format")
|
|
973
|
+
|
|
974
|
+
# Quote file paths to handle special characters
|
|
975
|
+
title_mov_file = shlex.quote(os.path.abspath(input_files["title_mov"]))
|
|
976
|
+
karaoke_mp4_file = shlex.quote(os.path.abspath(output_files["karaoke_mp4"]))
|
|
977
|
+
|
|
978
|
+
# Prepare concat filter for combining videos
|
|
979
|
+
env_mov_input, ffmpeg_filter = self.prepare_concat_filter(input_files)
|
|
980
|
+
|
|
981
|
+
# Create all output versions with progress logging
|
|
982
|
+
self.logger.info(f"[Step 3/6] Encoding lossless 4K MP4 (title + karaoke + end, ~5 minutes)...")
|
|
983
|
+
self.encode_lossless_mp4(title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_files["final_karaoke_lossless_mp4"])
|
|
984
|
+
|
|
985
|
+
self.logger.info(f"[Step 4/6] Encoding lossy 4K MP4 with AAC audio (~1 minute)...")
|
|
986
|
+
self.encode_lossy_mp4(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_mp4"])
|
|
987
|
+
|
|
988
|
+
self.logger.info(f"[Step 5/6] Creating MKV with FLAC audio for YouTube (~1 minute)...")
|
|
989
|
+
self.encode_lossless_mkv(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossless_mkv"])
|
|
990
|
+
|
|
991
|
+
self.logger.info(f"[Step 6/6] Encoding 720p version (~3 minutes)...")
|
|
992
|
+
self.encode_720p_version(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_720p_mp4"])
|
|
993
|
+
|
|
994
|
+
# Skip user confirmation in non-interactive mode for Modal deployment
|
|
995
|
+
if not self.non_interactive:
|
|
996
|
+
# Prompt user to check final video files before proceeding
|
|
997
|
+
self.prompt_user_confirmation_or_raise_exception(
|
|
998
|
+
f"Final video files created:\n"
|
|
999
|
+
f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
|
|
1000
|
+
f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
|
|
1001
|
+
f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
|
|
1002
|
+
f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
|
|
1003
|
+
f"Please check them! Proceed?",
|
|
1004
|
+
"Refusing to proceed without user confirmation they're happy with the Final videos.",
|
|
1005
|
+
allow_empty=True,
|
|
1006
|
+
)
|
|
1007
|
+
else:
|
|
1008
|
+
self.logger.info("Non-interactive mode: automatically confirming final video files")
|
|
1009
|
+
|
|
1010
|
+
def create_cdg_zip_file(self, input_files, output_files, artist, title):
|
|
1011
|
+
self.logger.info(f"Creating CDG and MP3 files, then zipping them...")
|
|
1012
|
+
|
|
1013
|
+
# Check if CDG file already exists, if so, ask user to overwrite or skip
|
|
1014
|
+
if os.path.isfile(output_files["final_karaoke_cdg_zip"]):
|
|
1015
|
+
if not self.prompt_user_bool(
|
|
1016
|
+
f"Found existing CDG ZIP file: {output_files['final_karaoke_cdg_zip']}. Overwrite (y) or skip (n)?",
|
|
1017
|
+
):
|
|
1018
|
+
self.logger.info(f"Skipping CDG ZIP file creation, existing file will be used.")
|
|
1019
|
+
return
|
|
1020
|
+
|
|
1021
|
+
# Check if individual MP3 and CDG files already exist
|
|
1022
|
+
if os.path.isfile(output_files["karaoke_mp3"]) and os.path.isfile(output_files["karaoke_cdg"]):
|
|
1023
|
+
self.logger.info(f"Found existing MP3 and CDG files, creating ZIP file directly")
|
|
1024
|
+
if not self.dry_run:
|
|
1025
|
+
with zipfile.ZipFile(output_files["final_karaoke_cdg_zip"], "w") as zipf:
|
|
1026
|
+
zipf.write(output_files["karaoke_mp3"], os.path.basename(output_files["karaoke_mp3"]))
|
|
1027
|
+
zipf.write(output_files["karaoke_cdg"], os.path.basename(output_files["karaoke_cdg"]))
|
|
1028
|
+
self.logger.info(f"Created CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
|
|
1029
|
+
return
|
|
1030
|
+
|
|
1031
|
+
# Generate CDG and MP3 files if they don't exist
|
|
1032
|
+
if self.dry_run:
|
|
1033
|
+
self.logger.info(f"DRY RUN: Would generate CDG and MP3 files")
|
|
1034
|
+
else:
|
|
1035
|
+
self.logger.info(f"Generating CDG and MP3 files")
|
|
1036
|
+
|
|
1037
|
+
if self.cdg_styles is None:
|
|
1038
|
+
raise ValueError("CDG styles configuration is required when enable_cdg is True")
|
|
1039
|
+
|
|
1040
|
+
generator = CDGGenerator(output_dir=os.getcwd(), logger=self.logger)
|
|
1041
|
+
cdg_file, mp3_file, zip_file = generator.generate_cdg_from_lrc(
|
|
1042
|
+
lrc_file=input_files["karaoke_lrc"],
|
|
1043
|
+
audio_file=input_files["instrumental_audio"],
|
|
1044
|
+
title=title,
|
|
1045
|
+
artist=artist,
|
|
1046
|
+
cdg_styles=self.cdg_styles,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
# Rename the generated ZIP file to match our expected naming convention
|
|
1050
|
+
if os.path.isfile(zip_file):
|
|
1051
|
+
os.rename(zip_file, output_files["final_karaoke_cdg_zip"])
|
|
1052
|
+
self.logger.info(f"Renamed CDG ZIP file from {zip_file} to {output_files['final_karaoke_cdg_zip']}")
|
|
1053
|
+
|
|
1054
|
+
if not os.path.isfile(output_files["final_karaoke_cdg_zip"]):
|
|
1055
|
+
self.logger.error(f"Failed to find any CDG ZIP file. Listing directory contents:")
|
|
1056
|
+
for file in os.listdir():
|
|
1057
|
+
self.logger.error(f" - {file}")
|
|
1058
|
+
raise Exception(f"Failed to create CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
|
|
1059
|
+
|
|
1060
|
+
self.logger.info(f"CDG ZIP file created: {output_files['final_karaoke_cdg_zip']}")
|
|
1061
|
+
|
|
1062
|
+
# Extract the CDG ZIP file
|
|
1063
|
+
self.logger.info(f"Extracting CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
|
|
1064
|
+
with zipfile.ZipFile(output_files["final_karaoke_cdg_zip"], "r") as zip_ref:
|
|
1065
|
+
zip_ref.extractall()
|
|
1066
|
+
|
|
1067
|
+
if os.path.isfile(output_files["karaoke_mp3"]):
|
|
1068
|
+
self.logger.info(f"Found extracted MP3 file: {output_files['karaoke_mp3']}")
|
|
1069
|
+
else:
|
|
1070
|
+
self.logger.error("Failed to find extracted MP3 file")
|
|
1071
|
+
raise Exception("Failed to extract MP3 file from CDG ZIP")
|
|
1072
|
+
|
|
1073
|
+
def create_txt_zip_file(self, input_files, output_files):
|
|
1074
|
+
self.logger.info(f"Creating TXT ZIP file...")
|
|
1075
|
+
|
|
1076
|
+
# Check if TXT file already exists, if so, ask user to overwrite or skip
|
|
1077
|
+
if os.path.isfile(output_files["final_karaoke_txt_zip"]):
|
|
1078
|
+
if not self.prompt_user_bool(
|
|
1079
|
+
f"Found existing TXT ZIP file: {output_files['final_karaoke_txt_zip']}. Overwrite (y) or skip (n)?",
|
|
1080
|
+
):
|
|
1081
|
+
self.logger.info(f"Skipping TXT ZIP file creation, existing file will be used.")
|
|
1082
|
+
return
|
|
1083
|
+
|
|
1084
|
+
# Create the ZIP file containing the MP3 and TXT files
|
|
1085
|
+
if self.dry_run:
|
|
1086
|
+
self.logger.info(f"DRY RUN: Would create TXT ZIP file: {output_files['final_karaoke_txt_zip']}")
|
|
1087
|
+
else:
|
|
1088
|
+
self.logger.info(f"Running karaoke-converter to convert MidiCo LRC file {input_files['karaoke_lrc']} to TXT format")
|
|
1089
|
+
txt_converter = LyricsConverter(output_format="txt", filepath=input_files["karaoke_lrc"])
|
|
1090
|
+
converted_txt = txt_converter.convert_file()
|
|
1091
|
+
|
|
1092
|
+
with open(output_files["karaoke_txt"], "w") as txt_file:
|
|
1093
|
+
txt_file.write(converted_txt)
|
|
1094
|
+
self.logger.info(f"TXT file written: {output_files['karaoke_txt']}")
|
|
1095
|
+
|
|
1096
|
+
self.logger.info(f"Creating ZIP file containing {output_files['karaoke_mp3']} and {output_files['karaoke_txt']}")
|
|
1097
|
+
with zipfile.ZipFile(output_files["final_karaoke_txt_zip"], "w") as zipf:
|
|
1098
|
+
zipf.write(output_files["karaoke_mp3"], os.path.basename(output_files["karaoke_mp3"]))
|
|
1099
|
+
zipf.write(output_files["karaoke_txt"], os.path.basename(output_files["karaoke_txt"]))
|
|
1100
|
+
|
|
1101
|
+
if not os.path.isfile(output_files["final_karaoke_txt_zip"]):
|
|
1102
|
+
raise Exception(f"Failed to create TXT ZIP file: {output_files['final_karaoke_txt_zip']}")
|
|
1103
|
+
|
|
1104
|
+
self.logger.info(f"TXT ZIP file created: {output_files['final_karaoke_txt_zip']}")
|
|
1105
|
+
|
|
1106
|
+
def move_files_to_brand_code_folder(self, brand_code, artist, title, output_files):
|
|
1107
|
+
self.logger.info(f"Moving files to new brand-prefixed directory...")
|
|
1108
|
+
|
|
1109
|
+
self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
|
|
1110
|
+
self.new_brand_code_dir_path = os.path.join(self.organised_dir, self.new_brand_code_dir)
|
|
1111
|
+
|
|
1112
|
+
# self.prompt_user_confirmation_or_raise_exception(
|
|
1113
|
+
# f"Move files to new brand-prefixed directory {self.new_brand_code_dir_path} and delete current dir?",
|
|
1114
|
+
# "Refusing to move files without user confirmation of move.",
|
|
1115
|
+
# allow_empty=True,
|
|
1116
|
+
# )
|
|
1117
|
+
|
|
1118
|
+
orig_dir = os.getcwd()
|
|
1119
|
+
os.chdir(os.path.dirname(orig_dir))
|
|
1120
|
+
self.logger.info(f"Changed dir to parent directory: {os.getcwd()}")
|
|
1121
|
+
|
|
1122
|
+
if self.dry_run:
|
|
1123
|
+
self.logger.info(f"DRY RUN: Would move original directory {orig_dir} to: {self.new_brand_code_dir_path}")
|
|
1124
|
+
else:
|
|
1125
|
+
os.rename(orig_dir, self.new_brand_code_dir_path)
|
|
1126
|
+
|
|
1127
|
+
# Update output_files dictionary with the new paths after moving
|
|
1128
|
+
self.logger.info(f"Updating output file paths to reflect move to {self.new_brand_code_dir_path}")
|
|
1129
|
+
for key in output_files:
|
|
1130
|
+
if output_files[key]: # Check if the path exists (e.g., optional files)
|
|
1131
|
+
old_basename = os.path.basename(output_files[key])
|
|
1132
|
+
new_path = os.path.join(self.new_brand_code_dir_path, old_basename)
|
|
1133
|
+
output_files[key] = new_path
|
|
1134
|
+
self.logger.debug(f" Updated {key}: {new_path}")
|
|
1135
|
+
|
|
1136
|
+
def copy_final_files_to_public_share_dirs(self, brand_code, base_name, output_files):
|
|
1137
|
+
self.logger.info(f"Copying final MP4, 720p MP4, and ZIP to public share directory...")
|
|
1138
|
+
|
|
1139
|
+
# Validate public_share_dir is a valid folder with MP4, MP4-720p, and CDG subdirectories
|
|
1140
|
+
if not os.path.isdir(self.public_share_dir):
|
|
1141
|
+
raise Exception(f"Public share directory does not exist: {self.public_share_dir}")
|
|
1142
|
+
|
|
1143
|
+
if not os.path.isdir(os.path.join(self.public_share_dir, "MP4")):
|
|
1144
|
+
raise Exception(f"Public share directory does not contain MP4 subdirectory: {self.public_share_dir}")
|
|
1145
|
+
|
|
1146
|
+
if not os.path.isdir(os.path.join(self.public_share_dir, "MP4-720p")):
|
|
1147
|
+
raise Exception(f"Public share directory does not contain MP4-720p subdirectory: {self.public_share_dir}")
|
|
1148
|
+
|
|
1149
|
+
if not os.path.isdir(os.path.join(self.public_share_dir, "CDG")):
|
|
1150
|
+
raise Exception(f"Public share directory does not contain CDG subdirectory: {self.public_share_dir}")
|
|
1151
|
+
|
|
1152
|
+
if brand_code is None:
|
|
1153
|
+
raise Exception(f"New track prefix was not set, refusing to copy to public share directory")
|
|
1154
|
+
|
|
1155
|
+
dest_mp4_dir = os.path.join(self.public_share_dir, "MP4")
|
|
1156
|
+
dest_720p_dir = os.path.join(self.public_share_dir, "MP4-720p")
|
|
1157
|
+
dest_cdg_dir = os.path.join(self.public_share_dir, "CDG")
|
|
1158
|
+
os.makedirs(dest_mp4_dir, exist_ok=True)
|
|
1159
|
+
os.makedirs(dest_720p_dir, exist_ok=True)
|
|
1160
|
+
os.makedirs(dest_cdg_dir, exist_ok=True)
|
|
1161
|
+
|
|
1162
|
+
dest_mp4_file = os.path.join(dest_mp4_dir, f"{brand_code} - {base_name}.mp4")
|
|
1163
|
+
dest_720p_mp4_file = os.path.join(dest_720p_dir, f"{brand_code} - {base_name}.mp4")
|
|
1164
|
+
dest_zip_file = os.path.join(dest_cdg_dir, f"{brand_code} - {base_name}.zip")
|
|
1165
|
+
|
|
1166
|
+
if self.dry_run:
|
|
1167
|
+
self.logger.info(
|
|
1168
|
+
f"DRY RUN: Would copy final MP4, 720p MP4, and ZIP to {dest_mp4_file}, {dest_720p_mp4_file}, and {dest_zip_file}"
|
|
1169
|
+
)
|
|
1170
|
+
else:
|
|
1171
|
+
shutil.copy2(output_files["final_karaoke_lossy_mp4"], dest_mp4_file) # Changed to use lossy MP4
|
|
1172
|
+
shutil.copy2(output_files["final_karaoke_lossy_720p_mp4"], dest_720p_mp4_file)
|
|
1173
|
+
|
|
1174
|
+
# Only copy CDG ZIP if CDG creation is enabled
|
|
1175
|
+
if self.enable_cdg and "final_karaoke_cdg_zip" in output_files:
|
|
1176
|
+
shutil.copy2(output_files["final_karaoke_cdg_zip"], dest_zip_file)
|
|
1177
|
+
self.logger.info(f"Copied CDG ZIP file to public share directory")
|
|
1178
|
+
else:
|
|
1179
|
+
self.logger.info(f"CDG creation disabled, skipping CDG ZIP copy")
|
|
1180
|
+
|
|
1181
|
+
self.logger.info(f"Copied final files to public share directory")
|
|
1182
|
+
|
|
1183
|
+
def sync_public_share_dir_to_rclone_destination(self):
|
|
1184
|
+
self.logger.info(f"Copying public share directory to rclone destination...")
|
|
1185
|
+
|
|
1186
|
+
# Delete .DS_Store files recursively before copying
|
|
1187
|
+
for root, dirs, files in os.walk(self.public_share_dir):
|
|
1188
|
+
for file in files:
|
|
1189
|
+
if file == ".DS_Store":
|
|
1190
|
+
file_path = os.path.join(root, file)
|
|
1191
|
+
os.remove(file_path)
|
|
1192
|
+
self.logger.info(f"Deleted .DS_Store file: {file_path}")
|
|
1193
|
+
|
|
1194
|
+
rclone_cmd = f"rclone copy -v --ignore-existing {shlex.quote(self.public_share_dir)} {shlex.quote(self.rclone_destination)}"
|
|
1195
|
+
self.execute_command(rclone_cmd, "Copying to cloud destination")
|
|
1196
|
+
|
|
1197
|
+
def post_discord_notification(self):
|
|
1198
|
+
self.logger.info(f"Posting Discord notification...")
|
|
1199
|
+
|
|
1200
|
+
if self.skip_notifications:
|
|
1201
|
+
self.logger.info(f"Skipping Discord notification as video was previously uploaded to YouTube")
|
|
1202
|
+
return
|
|
1203
|
+
|
|
1204
|
+
# Only post if we have a YouTube URL
|
|
1205
|
+
if not self.youtube_url:
|
|
1206
|
+
self.logger.info(f"Skipping Discord notification - no YouTube URL available")
|
|
1207
|
+
return
|
|
1208
|
+
|
|
1209
|
+
if self.dry_run:
|
|
1210
|
+
self.logger.info(
|
|
1211
|
+
f"DRY RUN: Would post Discord notification for youtube URL {self.youtube_url} using webhook URL: {self.discord_webhook_url}"
|
|
1212
|
+
)
|
|
1213
|
+
else:
|
|
1214
|
+
discord_message = f"New upload: {self.youtube_url}"
|
|
1215
|
+
self.post_discord_message(discord_message, self.discord_webhook_url)
|
|
1216
|
+
|
|
1217
|
+
def generate_organised_folder_sharing_link(self):
|
|
1218
|
+
self.logger.info(f"Getting Organised Folder sharing link for new brand code directory...")
|
|
1219
|
+
|
|
1220
|
+
rclone_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
|
|
1221
|
+
rclone_link_cmd = f"rclone link {shlex.quote(rclone_dest)}"
|
|
1222
|
+
|
|
1223
|
+
if self.dry_run:
|
|
1224
|
+
self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
|
|
1225
|
+
return "https://file-sharing-service.com/example"
|
|
1226
|
+
|
|
1227
|
+
# Add a 5-second delay to allow dropbox to index the folder before generating a link
|
|
1228
|
+
self.logger.info("Waiting 5 seconds before generating link...")
|
|
1229
|
+
time.sleep(5)
|
|
1230
|
+
|
|
1231
|
+
try:
|
|
1232
|
+
self.logger.info(f"Running command: {rclone_link_cmd}")
|
|
1233
|
+
result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
|
|
1234
|
+
|
|
1235
|
+
# Log command output for debugging
|
|
1236
|
+
if result.stdout and result.stdout.strip():
|
|
1237
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
1238
|
+
if result.stderr and result.stderr.strip():
|
|
1239
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
1240
|
+
|
|
1241
|
+
self.brand_code_dir_sharing_link = result.stdout.strip()
|
|
1242
|
+
self.logger.info(f"Got organised folder sharing link: {self.brand_code_dir_sharing_link}")
|
|
1243
|
+
except subprocess.CalledProcessError as e:
|
|
1244
|
+
self.logger.error(f"Failed to get organised folder sharing link. Exit code: {e.returncode}")
|
|
1245
|
+
self.logger.error(f"Command output (stdout): {e.stdout}")
|
|
1246
|
+
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
1247
|
+
self.logger.error(f"Full exception: {e}")
|
|
1248
|
+
|
|
1249
|
+
def get_next_brand_code_server_side(self):
|
|
1250
|
+
"""
|
|
1251
|
+
Calculate the next sequence number based on existing directories in the remote organised_dir using rclone.
|
|
1252
|
+
Assumes directories are named with the format: BRAND-XXXX Artist - Title
|
|
1253
|
+
"""
|
|
1254
|
+
if not self.organised_dir_rclone_root:
|
|
1255
|
+
raise Exception("organised_dir_rclone_root not configured for server-side brand code generation")
|
|
1256
|
+
|
|
1257
|
+
self.logger.info(f"Getting next brand code from remote organized directory: {self.organised_dir_rclone_root}")
|
|
1258
|
+
|
|
1259
|
+
max_num = 0
|
|
1260
|
+
pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
|
|
1261
|
+
|
|
1262
|
+
# Use rclone lsf --dirs-only for clean, machine-readable directory listing
|
|
1263
|
+
rclone_list_cmd = f"rclone lsf --dirs-only {shlex.quote(self.organised_dir_rclone_root)}"
|
|
1264
|
+
|
|
1265
|
+
if self.dry_run:
|
|
1266
|
+
self.logger.info(f"DRY RUN: Would run: {rclone_list_cmd}")
|
|
1267
|
+
return f"{self.brand_prefix}-0001"
|
|
1268
|
+
|
|
1269
|
+
try:
|
|
1270
|
+
self.logger.info(f"Running command: {rclone_list_cmd}")
|
|
1271
|
+
result = subprocess.run(rclone_list_cmd, shell=True, check=True, capture_output=True, text=True)
|
|
1272
|
+
|
|
1273
|
+
# Log command output for debugging
|
|
1274
|
+
if result.stdout and result.stdout.strip():
|
|
1275
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
1276
|
+
if result.stderr and result.stderr.strip():
|
|
1277
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
1278
|
+
|
|
1279
|
+
# Parse the output to find matching directories
|
|
1280
|
+
matching_dirs = []
|
|
1281
|
+
for line_num, line in enumerate(result.stdout.strip().split('\n')):
|
|
1282
|
+
if line.strip():
|
|
1283
|
+
# Remove trailing slash and whitespace
|
|
1284
|
+
dir_name = line.strip().rstrip('/')
|
|
1285
|
+
|
|
1286
|
+
# Check if directory matches our brand pattern
|
|
1287
|
+
match = pattern.match(dir_name)
|
|
1288
|
+
if match:
|
|
1289
|
+
num = int(match.group(1))
|
|
1290
|
+
max_num = max(max_num, num)
|
|
1291
|
+
matching_dirs.append((dir_name, num))
|
|
1292
|
+
|
|
1293
|
+
self.logger.info(f"Found {len(matching_dirs)} matching directories with pattern {self.brand_prefix}-XXXX")
|
|
1294
|
+
|
|
1295
|
+
next_seq_number = max_num + 1
|
|
1296
|
+
brand_code = f"{self.brand_prefix}-{next_seq_number:04d}"
|
|
1297
|
+
|
|
1298
|
+
self.logger.info(f"Highest existing number: {max_num}, next sequence number for brand {self.brand_prefix} calculated as: {next_seq_number}")
|
|
1299
|
+
return brand_code
|
|
1300
|
+
|
|
1301
|
+
except subprocess.CalledProcessError as e:
|
|
1302
|
+
self.logger.error(f"Failed to list remote organized directory. Exit code: {e.returncode}")
|
|
1303
|
+
self.logger.error(f"Command output (stdout): {e.stdout}")
|
|
1304
|
+
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
1305
|
+
raise Exception(f"Failed to get brand code from remote directory: {e}")
|
|
1306
|
+
|
|
1307
|
+
def upload_files_to_organized_folder_server_side(self, brand_code, artist, title):
|
|
1308
|
+
"""
|
|
1309
|
+
Upload all files from current directory to the remote organized folder using rclone.
|
|
1310
|
+
Creates a brand-prefixed directory in the remote organized folder.
|
|
1311
|
+
"""
|
|
1312
|
+
if not self.organised_dir_rclone_root:
|
|
1313
|
+
raise Exception("organised_dir_rclone_root not configured for server-side file upload")
|
|
1314
|
+
|
|
1315
|
+
self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
|
|
1316
|
+
remote_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
|
|
1317
|
+
|
|
1318
|
+
self.logger.info(f"Uploading files to remote organized directory: {remote_dest}")
|
|
1319
|
+
|
|
1320
|
+
# Get current directory path to upload
|
|
1321
|
+
current_dir = os.getcwd()
|
|
1322
|
+
|
|
1323
|
+
# Use rclone copy to upload the entire current directory to the remote destination
|
|
1324
|
+
rclone_upload_cmd = f"rclone copy -v --ignore-existing {shlex.quote(current_dir)} {shlex.quote(remote_dest)}"
|
|
1325
|
+
|
|
1326
|
+
if self.dry_run:
|
|
1327
|
+
self.logger.info(f"DRY RUN: Would upload current directory to: {remote_dest}")
|
|
1328
|
+
self.logger.info(f"DRY RUN: Command: {rclone_upload_cmd}")
|
|
1329
|
+
else:
|
|
1330
|
+
self.execute_command(rclone_upload_cmd, f"Uploading files to organized folder: {remote_dest}")
|
|
1331
|
+
|
|
1332
|
+
# Generate a sharing link for the uploaded folder
|
|
1333
|
+
self.generate_organised_folder_sharing_link_server_side(remote_dest)
|
|
1334
|
+
|
|
1335
|
+
def generate_organised_folder_sharing_link_server_side(self, remote_path):
|
|
1336
|
+
"""Generate a sharing link for the remote organized folder using rclone."""
|
|
1337
|
+
self.logger.info(f"Getting sharing link for remote organized folder: {remote_path}")
|
|
1338
|
+
|
|
1339
|
+
rclone_link_cmd = f"rclone link {shlex.quote(remote_path)}"
|
|
1340
|
+
|
|
1341
|
+
if self.dry_run:
|
|
1342
|
+
self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
|
|
1343
|
+
self.brand_code_dir_sharing_link = "https://file-sharing-service.com/example"
|
|
1344
|
+
return
|
|
1345
|
+
|
|
1346
|
+
# Add a 10-second delay to allow the remote service to index the folder before generating a link
|
|
1347
|
+
self.logger.info("Waiting 10 seconds before generating link...")
|
|
1348
|
+
time.sleep(10)
|
|
1349
|
+
|
|
1350
|
+
try:
|
|
1351
|
+
self.logger.info(f"Running command: {rclone_link_cmd}")
|
|
1352
|
+
result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
|
|
1353
|
+
|
|
1354
|
+
# Log command output for debugging
|
|
1355
|
+
if result.stdout and result.stdout.strip():
|
|
1356
|
+
self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
|
|
1357
|
+
if result.stderr and result.stderr.strip():
|
|
1358
|
+
self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
|
|
1359
|
+
|
|
1360
|
+
self.brand_code_dir_sharing_link = result.stdout.strip()
|
|
1361
|
+
self.logger.info(f"Got organized folder sharing link: {self.brand_code_dir_sharing_link}")
|
|
1362
|
+
except subprocess.CalledProcessError as e:
|
|
1363
|
+
self.logger.error(f"Failed to get organized folder sharing link. Exit code: {e.returncode}")
|
|
1364
|
+
self.logger.error(f"Command output (stdout): {e.stdout}")
|
|
1365
|
+
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
1366
|
+
self.logger.error(f"Full exception: {e}")
|
|
1367
|
+
|
|
1368
|
+
def get_existing_brand_code(self):
|
|
1369
|
+
"""Extract brand code from current directory name"""
|
|
1370
|
+
current_dir = os.path.basename(os.getcwd())
|
|
1371
|
+
if " - " not in current_dir:
|
|
1372
|
+
raise Exception(f"Current directory '{current_dir}' does not match expected format 'BRAND-XXXX - Artist - Title'")
|
|
1373
|
+
|
|
1374
|
+
brand_code = current_dir.split(" - ")[0]
|
|
1375
|
+
if not brand_code or "-" not in brand_code:
|
|
1376
|
+
raise Exception(f"Could not extract valid brand code from directory name '{current_dir}'")
|
|
1377
|
+
|
|
1378
|
+
self.logger.info(f"Using existing brand code: {brand_code}")
|
|
1379
|
+
return brand_code
|
|
1380
|
+
|
|
1381
|
+
def execute_optional_features(self, artist, title, base_name, input_files, output_files, replace_existing=False):
|
|
1382
|
+
self.logger.info(f"Executing optional features...")
|
|
1383
|
+
|
|
1384
|
+
if self.youtube_upload_enabled:
|
|
1385
|
+
try:
|
|
1386
|
+
self.upload_final_mp4_to_youtube_with_title_thumbnail(artist, title, input_files, output_files, replace_existing)
|
|
1387
|
+
except Exception as e:
|
|
1388
|
+
self.logger.error(f"Failed to upload video to YouTube: {e}")
|
|
1389
|
+
if not self.non_interactive:
|
|
1390
|
+
print("Please manually upload the video to YouTube.")
|
|
1391
|
+
print()
|
|
1392
|
+
self.youtube_video_id = input("Enter the manually uploaded YouTube video ID: ").strip()
|
|
1393
|
+
self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
|
|
1394
|
+
self.logger.info(f"Using manually provided YouTube video ID: {self.youtube_video_id}")
|
|
1395
|
+
else:
|
|
1396
|
+
self.logger.error("YouTube upload failed in non-interactive mode, skipping")
|
|
1397
|
+
|
|
1398
|
+
# Discord notification - runs independently of YouTube upload
|
|
1399
|
+
# Wrapped in try/except so failures don't crash the entire job
|
|
1400
|
+
if self.discord_notication_enabled:
|
|
1401
|
+
try:
|
|
1402
|
+
self.post_discord_notification()
|
|
1403
|
+
except Exception as e:
|
|
1404
|
+
self.logger.error(f"Failed to send Discord notification: {e}")
|
|
1405
|
+
self.logger.warning("Continuing without Discord notification - this is non-fatal")
|
|
1406
|
+
|
|
1407
|
+
# Handle folder organization - different logic for server-side vs local mode
|
|
1408
|
+
if self.server_side_mode and self.brand_prefix and self.organised_dir_rclone_root:
|
|
1409
|
+
self.logger.info("Executing server-side organization...")
|
|
1410
|
+
|
|
1411
|
+
# Generate brand code from remote directory listing
|
|
1412
|
+
if self.keep_brand_code:
|
|
1413
|
+
self.brand_code = self.get_existing_brand_code()
|
|
1414
|
+
else:
|
|
1415
|
+
self.brand_code = self.get_next_brand_code_server_side()
|
|
1416
|
+
|
|
1417
|
+
# Upload files to organized folder via rclone
|
|
1418
|
+
self.upload_files_to_organized_folder_server_side(self.brand_code, artist, title)
|
|
1419
|
+
|
|
1420
|
+
# Copy files to public share if enabled
|
|
1421
|
+
if self.public_share_copy_enabled:
|
|
1422
|
+
self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
|
|
1423
|
+
|
|
1424
|
+
# Sync public share to cloud destination if enabled
|
|
1425
|
+
if self.public_share_rclone_enabled:
|
|
1426
|
+
self.sync_public_share_dir_to_rclone_destination()
|
|
1427
|
+
|
|
1428
|
+
elif self.folder_organisation_enabled:
|
|
1429
|
+
self.logger.info("Executing local folder organization...")
|
|
1430
|
+
|
|
1431
|
+
if self.keep_brand_code:
|
|
1432
|
+
self.brand_code = self.get_existing_brand_code()
|
|
1433
|
+
self.new_brand_code_dir = os.path.basename(os.getcwd())
|
|
1434
|
+
self.new_brand_code_dir_path = os.getcwd()
|
|
1435
|
+
else:
|
|
1436
|
+
self.brand_code = self.get_next_brand_code()
|
|
1437
|
+
self.move_files_to_brand_code_folder(self.brand_code, artist, title, output_files)
|
|
1438
|
+
# Update output file paths after moving
|
|
1439
|
+
for key in output_files:
|
|
1440
|
+
output_files[key] = os.path.join(self.new_brand_code_dir_path, os.path.basename(output_files[key]))
|
|
1441
|
+
|
|
1442
|
+
if self.public_share_copy_enabled:
|
|
1443
|
+
self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
|
|
1444
|
+
|
|
1445
|
+
if self.public_share_rclone_enabled:
|
|
1446
|
+
self.sync_public_share_dir_to_rclone_destination()
|
|
1447
|
+
|
|
1448
|
+
self.generate_organised_folder_sharing_link()
|
|
1449
|
+
|
|
1450
|
+
elif self.public_share_copy_enabled or self.public_share_rclone_enabled:
|
|
1451
|
+
# If only public share features are enabled (no folder organization), we still need a brand code
|
|
1452
|
+
self.logger.info("No folder organization enabled, but public share features require brand code...")
|
|
1453
|
+
if self.brand_prefix:
|
|
1454
|
+
if self.server_side_mode and self.organised_dir_rclone_root:
|
|
1455
|
+
self.brand_code = self.get_next_brand_code_server_side()
|
|
1456
|
+
elif not self.server_side_mode and self.organised_dir:
|
|
1457
|
+
self.brand_code = self.get_next_brand_code()
|
|
1458
|
+
else:
|
|
1459
|
+
# Fallback to timestamp-based brand code if no organized directory configured
|
|
1460
|
+
import datetime
|
|
1461
|
+
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
1462
|
+
self.brand_code = f"{self.brand_prefix}-{timestamp}"
|
|
1463
|
+
self.logger.warning(f"No organized directory configured, using timestamp-based brand code: {self.brand_code}")
|
|
1464
|
+
|
|
1465
|
+
if self.public_share_copy_enabled:
|
|
1466
|
+
self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
|
|
1467
|
+
|
|
1468
|
+
if self.public_share_rclone_enabled:
|
|
1469
|
+
self.sync_public_share_dir_to_rclone_destination()
|
|
1470
|
+
|
|
1471
|
+
def authenticate_gmail(self):
|
|
1472
|
+
"""Authenticate and return a Gmail service object."""
|
|
1473
|
+
creds = None
|
|
1474
|
+
gmail_token_file = "/tmp/karaoke-finalise-gmail-token.pickle"
|
|
1475
|
+
|
|
1476
|
+
if os.path.exists(gmail_token_file):
|
|
1477
|
+
with open(gmail_token_file, "rb") as token:
|
|
1478
|
+
creds = pickle.load(token)
|
|
1479
|
+
|
|
1480
|
+
if not creds or not creds.valid:
|
|
1481
|
+
if creds and creds.expired and creds.refresh_token:
|
|
1482
|
+
creds.refresh(Request())
|
|
1483
|
+
else:
|
|
1484
|
+
if self.non_interactive:
|
|
1485
|
+
raise Exception("Gmail authentication required but running in non-interactive mode. Please pre-authenticate or disable email drafts.")
|
|
1486
|
+
|
|
1487
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
1488
|
+
self.youtube_client_secrets_file, ["https://www.googleapis.com/auth/gmail.compose"]
|
|
1489
|
+
)
|
|
1490
|
+
creds = flow.run_local_server(port=0)
|
|
1491
|
+
with open(gmail_token_file, "wb") as token:
|
|
1492
|
+
pickle.dump(creds, token)
|
|
1493
|
+
|
|
1494
|
+
return build("gmail", "v1", credentials=creds)
|
|
1495
|
+
|
|
1496
|
+
def draft_completion_email(self, artist, title, youtube_url, dropbox_url):
|
|
1497
|
+
# Completely disable email drafts in server-side mode
|
|
1498
|
+
if self.server_side_mode:
|
|
1499
|
+
self.logger.info("Server-side mode: skipping email draft creation")
|
|
1500
|
+
return
|
|
1501
|
+
|
|
1502
|
+
if not self.email_template_file:
|
|
1503
|
+
self.logger.info("Email template file not provided, skipping email draft creation.")
|
|
1504
|
+
return
|
|
1505
|
+
|
|
1506
|
+
if not self.youtube_client_secrets_file:
|
|
1507
|
+
self.logger.error("Email template file was provided, but youtube_client_secrets_file is required for Gmail authentication.")
|
|
1508
|
+
self.logger.error("Please provide --youtube_client_secrets_file parameter to enable email draft creation.")
|
|
1509
|
+
self.logger.info("Skipping email draft creation.")
|
|
1510
|
+
return
|
|
1511
|
+
|
|
1512
|
+
with open(self.email_template_file, "r") as f:
|
|
1513
|
+
template = f.read()
|
|
1514
|
+
|
|
1515
|
+
email_body = template.format(youtube_url=youtube_url, dropbox_url=dropbox_url)
|
|
1516
|
+
|
|
1517
|
+
subject = f"{self.brand_code}: {artist} - {title}"
|
|
1518
|
+
|
|
1519
|
+
if self.dry_run:
|
|
1520
|
+
self.logger.info(f"DRY RUN: Would create email draft with subject: {subject}")
|
|
1521
|
+
self.logger.info(f"DRY RUN: Email body:\n{email_body}")
|
|
1522
|
+
else:
|
|
1523
|
+
if not self.gmail_service:
|
|
1524
|
+
self.gmail_service = self.authenticate_gmail()
|
|
1525
|
+
|
|
1526
|
+
message = MIMEText(email_body)
|
|
1527
|
+
message["subject"] = subject
|
|
1528
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
|
|
1529
|
+
draft = self.gmail_service.users().drafts().create(userId="me", body={"message": {"raw": raw_message}}).execute()
|
|
1530
|
+
self.logger.info(f"Email draft created with ID: {draft['id']}")
|
|
1531
|
+
|
|
1532
|
+
def test_email_template(self):
|
|
1533
|
+
if not self.email_template_file:
|
|
1534
|
+
self.logger.error("Email template file not provided. Use --email_template_file to specify the file path.")
|
|
1535
|
+
return
|
|
1536
|
+
|
|
1537
|
+
fake_artist = "Test Artist"
|
|
1538
|
+
fake_title = "Test Song"
|
|
1539
|
+
fake_youtube_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
1540
|
+
fake_dropbox_url = "https://www.dropbox.com/sh/fake/folder/link"
|
|
1541
|
+
fake_brand_code = "TEST-0001"
|
|
1542
|
+
|
|
1543
|
+
self.brand_code = fake_brand_code
|
|
1544
|
+
self.draft_completion_email(fake_artist, fake_title, fake_youtube_url, fake_dropbox_url)
|
|
1545
|
+
|
|
1546
|
+
self.logger.info("Email template test complete. Check your Gmail drafts for the test email.")
|
|
1547
|
+
|
|
1548
|
+
def detect_best_aac_codec(self):
|
|
1549
|
+
"""Detect the best available AAC codec (aac_at > libfdk_aac > aac)"""
|
|
1550
|
+
self.logger.info("Detecting best available AAC codec...")
|
|
1551
|
+
|
|
1552
|
+
codec_check_command = f"{self.ffmpeg_base_command} -codecs"
|
|
1553
|
+
result = os.popen(codec_check_command).read()
|
|
1554
|
+
|
|
1555
|
+
if "aac_at" in result:
|
|
1556
|
+
self.logger.info("Using aac_at codec (best quality)")
|
|
1557
|
+
return "aac_at"
|
|
1558
|
+
elif "libfdk_aac" in result:
|
|
1559
|
+
self.logger.info("Using libfdk_aac codec (good quality)")
|
|
1560
|
+
return "libfdk_aac"
|
|
1561
|
+
else:
|
|
1562
|
+
self.logger.info("Using built-in aac codec (basic quality)")
|
|
1563
|
+
return "aac"
|
|
1564
|
+
|
|
1565
|
+
def detect_nvenc_support(self):
|
|
1566
|
+
"""Detect if NVENC hardware encoding is available."""
|
|
1567
|
+
try:
|
|
1568
|
+
self.logger.info("🔍 Detecting NVENC hardware acceleration...")
|
|
1569
|
+
|
|
1570
|
+
if self.dry_run:
|
|
1571
|
+
self.logger.info(" DRY RUN: Assuming NVENC is available")
|
|
1572
|
+
return True
|
|
1573
|
+
|
|
1574
|
+
import subprocess
|
|
1575
|
+
import os
|
|
1576
|
+
import shutil
|
|
1577
|
+
|
|
1578
|
+
# Check for nvidia-smi (indicates NVIDIA driver presence)
|
|
1579
|
+
try:
|
|
1580
|
+
nvidia_smi_result = subprocess.run(["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
|
|
1581
|
+
capture_output=True, text=True, timeout=10)
|
|
1582
|
+
if nvidia_smi_result.returncode == 0:
|
|
1583
|
+
gpu_info = nvidia_smi_result.stdout.strip()
|
|
1584
|
+
self.logger.info(f" ✓ NVIDIA GPU detected: {gpu_info}")
|
|
1585
|
+
else:
|
|
1586
|
+
self.logger.debug(f"nvidia-smi failed: {nvidia_smi_result.stderr}")
|
|
1587
|
+
self.logger.info(" ✗ NVENC not available (no NVIDIA GPU)")
|
|
1588
|
+
return False
|
|
1589
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.CalledProcessError) as e:
|
|
1590
|
+
self.logger.debug(f"nvidia-smi not available: {e}")
|
|
1591
|
+
self.logger.info(" ✗ NVENC not available (no NVIDIA GPU)")
|
|
1592
|
+
return False
|
|
1593
|
+
|
|
1594
|
+
# Check for NVENC encoders in FFmpeg
|
|
1595
|
+
try:
|
|
1596
|
+
encoders_cmd = f"{self.ffmpeg_base_command} -hide_banner -encoders 2>/dev/null | grep nvenc"
|
|
1597
|
+
encoders_result = subprocess.run(encoders_cmd, shell=True, capture_output=True, text=True, timeout=10)
|
|
1598
|
+
if encoders_result.returncode == 0 and "nvenc" in encoders_result.stdout:
|
|
1599
|
+
nvenc_encoders = [line.strip() for line in encoders_result.stdout.split('\n') if 'nvenc' in line]
|
|
1600
|
+
self.logger.debug(f"Found NVENC encoders: {nvenc_encoders}")
|
|
1601
|
+
else:
|
|
1602
|
+
self.logger.debug("No NVENC encoders found in FFmpeg")
|
|
1603
|
+
self.logger.info(" ✗ NVENC not available (no FFmpeg support)")
|
|
1604
|
+
return False
|
|
1605
|
+
except Exception as e:
|
|
1606
|
+
self.logger.debug(f"Failed to check FFmpeg NVENC encoders: {e}")
|
|
1607
|
+
self.logger.info(" ✗ NVENC not available")
|
|
1608
|
+
return False
|
|
1609
|
+
|
|
1610
|
+
# Check for libcuda.so.1 (critical for NVENC)
|
|
1611
|
+
try:
|
|
1612
|
+
libcuda_check = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10)
|
|
1613
|
+
if libcuda_check.returncode == 0 and "libcuda.so.1" in libcuda_check.stdout:
|
|
1614
|
+
self.logger.debug("libcuda.so.1 found in system libraries")
|
|
1615
|
+
else:
|
|
1616
|
+
self.logger.debug("libcuda.so.1 NOT found - may need nvidia/cuda:*-devel image")
|
|
1617
|
+
self.logger.info(" ✗ NVENC not available (missing CUDA libraries)")
|
|
1618
|
+
return False
|
|
1619
|
+
except Exception as e:
|
|
1620
|
+
self.logger.debug(f"Failed to check for libcuda.so.1: {e}")
|
|
1621
|
+
self.logger.info(" ✗ NVENC not available")
|
|
1622
|
+
return False
|
|
1623
|
+
|
|
1624
|
+
# Test h264_nvenc encoder
|
|
1625
|
+
test_cmd = f"{self.ffmpeg_base_command} -hide_banner -loglevel error -f lavfi -i testsrc=duration=1:size=320x240:rate=1 -c:v h264_nvenc -f null -"
|
|
1626
|
+
self.logger.debug(f"Testing NVENC: {test_cmd}")
|
|
1627
|
+
|
|
1628
|
+
try:
|
|
1629
|
+
result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=30)
|
|
1630
|
+
|
|
1631
|
+
if result.returncode == 0:
|
|
1632
|
+
self.logger.info(" ✓ NVENC encoding available")
|
|
1633
|
+
return True
|
|
1634
|
+
else:
|
|
1635
|
+
self.logger.debug(f"NVENC test failed (exit code {result.returncode}): {result.stderr}")
|
|
1636
|
+
self.logger.info(" ✗ NVENC not available")
|
|
1637
|
+
return False
|
|
1638
|
+
|
|
1639
|
+
except subprocess.TimeoutExpired:
|
|
1640
|
+
self.logger.debug("NVENC test timed out")
|
|
1641
|
+
self.logger.info(" ✗ NVENC not available (timeout)")
|
|
1642
|
+
return False
|
|
1643
|
+
|
|
1644
|
+
except Exception as e:
|
|
1645
|
+
self.logger.debug(f"Failed to detect NVENC support: {e}")
|
|
1646
|
+
self.logger.info(" ✗ NVENC not available (error)")
|
|
1647
|
+
return False
|
|
1648
|
+
|
|
1649
|
+
def configure_hardware_acceleration(self):
|
|
1650
|
+
"""Configure hardware acceleration settings based on detected capabilities."""
|
|
1651
|
+
if self.nvenc_available:
|
|
1652
|
+
self.video_encoder = "h264_nvenc"
|
|
1653
|
+
# Use simpler hardware acceleration that works with complex filter chains
|
|
1654
|
+
# Remove -hwaccel_output_format cuda as it causes pixel format conversion issues
|
|
1655
|
+
self.hwaccel_decode_flags = "-hwaccel cuda"
|
|
1656
|
+
self.scale_filter = "scale" # Use CPU scaling for complex filter chains
|
|
1657
|
+
self.logger.info("🚀 Using NVENC hardware acceleration for video encoding")
|
|
1658
|
+
else:
|
|
1659
|
+
self.video_encoder = "libx264"
|
|
1660
|
+
self.hwaccel_decode_flags = ""
|
|
1661
|
+
self.scale_filter = "scale"
|
|
1662
|
+
self.logger.info("🔧 Using software encoding (libx264) for video")
|
|
1663
|
+
|
|
1664
|
+
def get_nvenc_quality_settings(self, quality_mode="high"):
|
|
1665
|
+
"""Get NVENC settings based on quality requirements."""
|
|
1666
|
+
if quality_mode == "lossless":
|
|
1667
|
+
return "-preset lossless"
|
|
1668
|
+
elif quality_mode == "high":
|
|
1669
|
+
return "-preset p4 -tune hq -cq 18" # High quality
|
|
1670
|
+
elif quality_mode == "medium":
|
|
1671
|
+
return "-preset p4 -cq 23" # Balanced quality/speed
|
|
1672
|
+
elif quality_mode == "fast":
|
|
1673
|
+
return "-preset p1 -tune ll" # Low latency, faster encoding
|
|
1674
|
+
else:
|
|
1675
|
+
return "-preset p4" # Balanced default
|
|
1676
|
+
|
|
1677
|
+
def execute_command_with_fallback(self, gpu_command, cpu_command, description):
|
|
1678
|
+
"""Execute GPU command with automatic fallback to CPU if it fails."""
|
|
1679
|
+
self.logger.info(f"{description}")
|
|
1680
|
+
|
|
1681
|
+
if self.dry_run:
|
|
1682
|
+
if self.nvenc_available:
|
|
1683
|
+
self.logger.info(f"DRY RUN: Would run GPU-accelerated command: {gpu_command}")
|
|
1684
|
+
else:
|
|
1685
|
+
self.logger.info(f"DRY RUN: Would run CPU command: {cpu_command}")
|
|
1686
|
+
return
|
|
1687
|
+
|
|
1688
|
+
# Try GPU-accelerated command first if available
|
|
1689
|
+
if self.nvenc_available and gpu_command != cpu_command:
|
|
1690
|
+
self.logger.debug(f"Attempting hardware-accelerated encoding: {gpu_command}")
|
|
1691
|
+
try:
|
|
1692
|
+
result = subprocess.run(gpu_command, shell=True, capture_output=True, text=True, timeout=300)
|
|
1693
|
+
|
|
1694
|
+
if result.returncode == 0:
|
|
1695
|
+
self.logger.info(f"✓ Hardware acceleration successful")
|
|
1696
|
+
return
|
|
1697
|
+
else:
|
|
1698
|
+
self.logger.warning(f"✗ Hardware acceleration failed (exit code {result.returncode})")
|
|
1699
|
+
self.logger.warning(f"GPU Command: {gpu_command}")
|
|
1700
|
+
|
|
1701
|
+
# If we didn't get detailed error info and using fatal loglevel, try again with verbose logging
|
|
1702
|
+
if (not result.stderr or len(result.stderr.strip()) < 10) and "-loglevel fatal" in gpu_command:
|
|
1703
|
+
self.logger.warning("Empty error output detected, retrying with verbose logging...")
|
|
1704
|
+
verbose_gpu_command = gpu_command.replace("-loglevel fatal", "-loglevel error")
|
|
1705
|
+
try:
|
|
1706
|
+
verbose_result = subprocess.run(verbose_gpu_command, shell=True, capture_output=True, text=True, timeout=300)
|
|
1707
|
+
self.logger.warning(f"Verbose GPU Command: {verbose_gpu_command}")
|
|
1708
|
+
if verbose_result.stderr:
|
|
1709
|
+
self.logger.warning(f"FFmpeg STDERR (verbose): {verbose_result.stderr}")
|
|
1710
|
+
if verbose_result.stdout:
|
|
1711
|
+
self.logger.warning(f"FFmpeg STDOUT (verbose): {verbose_result.stdout}")
|
|
1712
|
+
except Exception as e:
|
|
1713
|
+
self.logger.warning(f"Verbose retry failed: {e}")
|
|
1714
|
+
|
|
1715
|
+
if result.stderr:
|
|
1716
|
+
self.logger.warning(f"FFmpeg STDERR: {result.stderr}")
|
|
1717
|
+
else:
|
|
1718
|
+
self.logger.warning("FFmpeg STDERR: (empty)")
|
|
1719
|
+
if result.stdout:
|
|
1720
|
+
self.logger.warning(f"FFmpeg STDOUT: {result.stdout}")
|
|
1721
|
+
else:
|
|
1722
|
+
self.logger.warning("FFmpeg STDOUT: (empty)")
|
|
1723
|
+
self.logger.info("Falling back to software encoding...")
|
|
1724
|
+
|
|
1725
|
+
except subprocess.TimeoutExpired:
|
|
1726
|
+
self.logger.warning("✗ Hardware acceleration timed out, falling back to software encoding")
|
|
1727
|
+
except Exception as e:
|
|
1728
|
+
self.logger.warning(f"✗ Hardware acceleration failed with exception: {e}, falling back to software encoding")
|
|
1729
|
+
|
|
1730
|
+
# Use CPU command (either as fallback or primary method)
|
|
1731
|
+
self.logger.debug(f"Running software encoding: {cpu_command}")
|
|
1732
|
+
try:
|
|
1733
|
+
result = subprocess.run(cpu_command, shell=True, capture_output=True, text=True, timeout=600)
|
|
1734
|
+
|
|
1735
|
+
if result.returncode != 0:
|
|
1736
|
+
error_msg = f"Software encoding failed with exit code {result.returncode}"
|
|
1737
|
+
self.logger.error(error_msg)
|
|
1738
|
+
self.logger.error(f"CPU Command: {cpu_command}")
|
|
1739
|
+
if result.stderr:
|
|
1740
|
+
self.logger.error(f"FFmpeg STDERR: {result.stderr}")
|
|
1741
|
+
else:
|
|
1742
|
+
self.logger.error("FFmpeg STDERR: (empty)")
|
|
1743
|
+
if result.stdout:
|
|
1744
|
+
self.logger.error(f"FFmpeg STDOUT: {result.stdout}")
|
|
1745
|
+
else:
|
|
1746
|
+
self.logger.error("FFmpeg STDOUT: (empty)")
|
|
1747
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
1748
|
+
else:
|
|
1749
|
+
self.logger.info(f"✓ Software encoding successful")
|
|
1750
|
+
|
|
1751
|
+
except subprocess.TimeoutExpired:
|
|
1752
|
+
error_msg = "Software encoding timed out"
|
|
1753
|
+
self.logger.error(error_msg)
|
|
1754
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
1755
|
+
except Exception as e:
|
|
1756
|
+
if "Software encoding failed" not in str(e):
|
|
1757
|
+
error_msg = f"Software encoding failed with exception: {e}"
|
|
1758
|
+
self.logger.error(error_msg)
|
|
1759
|
+
raise Exception(f"{error_msg}: {cpu_command}")
|
|
1760
|
+
else:
|
|
1761
|
+
raise
|
|
1762
|
+
|
|
1763
|
+
def process(self, replace_existing=False):
|
|
1764
|
+
if self.dry_run:
|
|
1765
|
+
self.logger.warning("Dry run enabled. No actions will be performed.")
|
|
1766
|
+
|
|
1767
|
+
self.logger.info("=" * 60)
|
|
1768
|
+
self.logger.info("Starting KaraokeFinalise processing pipeline")
|
|
1769
|
+
self.logger.info("=" * 60)
|
|
1770
|
+
|
|
1771
|
+
# Check required input files and parameters exist, get user to confirm features before proceeding
|
|
1772
|
+
self.validate_input_parameters_for_features()
|
|
1773
|
+
|
|
1774
|
+
with_vocals_file = self.find_with_vocals_file()
|
|
1775
|
+
base_name, artist, title = self.get_names_from_withvocals(with_vocals_file)
|
|
1776
|
+
|
|
1777
|
+
self.logger.info(f"Processing: {artist} - {title}")
|
|
1778
|
+
|
|
1779
|
+
# Use the selected instrumental file if provided, otherwise search for one
|
|
1780
|
+
if self.selected_instrumental_file:
|
|
1781
|
+
if not os.path.isfile(self.selected_instrumental_file):
|
|
1782
|
+
raise Exception(f"Selected instrumental file not found: {self.selected_instrumental_file}")
|
|
1783
|
+
instrumental_audio_file = self.selected_instrumental_file
|
|
1784
|
+
self.logger.info(f"Using pre-selected instrumental file: {instrumental_audio_file}")
|
|
1785
|
+
else:
|
|
1786
|
+
self.logger.info("No instrumental file pre-selected, searching for instrumental files...")
|
|
1787
|
+
instrumental_audio_file = self.choose_instrumental_audio_file(base_name)
|
|
1788
|
+
|
|
1789
|
+
input_files = self.check_input_files_exist(base_name, with_vocals_file, instrumental_audio_file)
|
|
1790
|
+
output_files = self.prepare_output_filenames(base_name)
|
|
1791
|
+
|
|
1792
|
+
if self.enable_cdg:
|
|
1793
|
+
self.logger.info("Creating CDG package...")
|
|
1794
|
+
self.create_cdg_zip_file(input_files, output_files, artist, title)
|
|
1795
|
+
self.logger.info("CDG package created successfully")
|
|
1796
|
+
|
|
1797
|
+
if self.enable_txt:
|
|
1798
|
+
self.logger.info("Creating TXT package...")
|
|
1799
|
+
self.create_txt_zip_file(input_files, output_files)
|
|
1800
|
+
self.logger.info("TXT package created successfully")
|
|
1801
|
+
|
|
1802
|
+
self.logger.info("Starting video encoding (this is the longest step, ~15-20 minutes)...")
|
|
1803
|
+
self.remux_and_encode_output_video_files(with_vocals_file, input_files, output_files)
|
|
1804
|
+
self.logger.info("Video encoding completed successfully")
|
|
1805
|
+
|
|
1806
|
+
self.logger.info("Executing distribution features (YouTube, Dropbox, Discord)...")
|
|
1807
|
+
self.execute_optional_features(artist, title, base_name, input_files, output_files, replace_existing)
|
|
1808
|
+
|
|
1809
|
+
result = {
|
|
1810
|
+
"artist": artist,
|
|
1811
|
+
"title": title,
|
|
1812
|
+
"video_with_vocals": output_files["with_vocals_mp4"],
|
|
1813
|
+
"video_with_instrumental": output_files["karaoke_mp4"],
|
|
1814
|
+
"final_video": output_files["final_karaoke_lossless_mp4"],
|
|
1815
|
+
"final_video_mkv": output_files["final_karaoke_lossless_mkv"],
|
|
1816
|
+
"final_video_lossy": output_files["final_karaoke_lossy_mp4"],
|
|
1817
|
+
"final_video_720p": output_files["final_karaoke_lossy_720p_mp4"],
|
|
1818
|
+
"youtube_url": self.youtube_url,
|
|
1819
|
+
"brand_code": self.brand_code,
|
|
1820
|
+
"new_brand_code_dir_path": self.new_brand_code_dir_path,
|
|
1821
|
+
"brand_code_dir_sharing_link": self.brand_code_dir_sharing_link,
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
if self.enable_cdg:
|
|
1825
|
+
result["final_karaoke_cdg_zip"] = output_files["final_karaoke_cdg_zip"]
|
|
1826
|
+
|
|
1827
|
+
if self.enable_txt:
|
|
1828
|
+
result["final_karaoke_txt_zip"] = output_files["final_karaoke_txt_zip"]
|
|
1829
|
+
|
|
1830
|
+
if self.email_template_file:
|
|
1831
|
+
self.draft_completion_email(artist, title, result["youtube_url"], result["brand_code_dir_sharing_link"])
|
|
1832
|
+
|
|
1833
|
+
return result
|