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