karaoke-gen 0.90.1__py3-none-any.whl → 0.96.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +742 -0
- backend/api/routes/audio_search.py +903 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2076 -0
- backend/api/routes/health.py +344 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1610 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1014 -0
- backend/config.py +172 -0
- backend/main.py +133 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +405 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +842 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +275 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +88 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +339 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +273 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +525 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rclone configuration service for cloud storage integration.
|
|
3
|
+
|
|
4
|
+
This service manages the rclone configuration needed for Dropbox
|
|
5
|
+
and other cloud storage uploads from the backend workers.
|
|
6
|
+
"""
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from backend.config import get_settings
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RcloneService:
|
|
18
|
+
"""Service for managing rclone configuration."""
|
|
19
|
+
|
|
20
|
+
# Secret Manager secret name for rclone config
|
|
21
|
+
RCLONE_CONFIG_SECRET = "rclone-config"
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.settings = get_settings()
|
|
25
|
+
self._config_file: Optional[str] = None
|
|
26
|
+
self._config_loaded = False
|
|
27
|
+
|
|
28
|
+
def setup_rclone_config(self) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Load rclone config from Secret Manager and set up environment.
|
|
31
|
+
|
|
32
|
+
Writes the config to a temp file and sets RCLONE_CONFIG env var.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
True if successful, False otherwise
|
|
36
|
+
"""
|
|
37
|
+
if self._config_loaded:
|
|
38
|
+
logger.debug("Rclone config already loaded")
|
|
39
|
+
return True
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Get rclone config from Secret Manager
|
|
43
|
+
config_content = self.settings.get_secret(self.RCLONE_CONFIG_SECRET)
|
|
44
|
+
|
|
45
|
+
if not config_content:
|
|
46
|
+
logger.warning("Rclone config not found in Secret Manager")
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# Write to a temp file
|
|
50
|
+
fd, config_path = tempfile.mkstemp(prefix="rclone_", suffix=".conf")
|
|
51
|
+
try:
|
|
52
|
+
with os.fdopen(fd, 'w') as f:
|
|
53
|
+
f.write(config_content)
|
|
54
|
+
|
|
55
|
+
self._config_file = config_path
|
|
56
|
+
|
|
57
|
+
# Set environment variable for rclone to find the config
|
|
58
|
+
os.environ["RCLONE_CONFIG"] = config_path
|
|
59
|
+
|
|
60
|
+
logger.info(f"Rclone config loaded and written to {config_path}")
|
|
61
|
+
self._config_loaded = True
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
except Exception:
|
|
65
|
+
# Clean up the temp file on error
|
|
66
|
+
# Note: os.fdopen() takes ownership of fd, so it's already closed
|
|
67
|
+
# We only need to remove the temp file if it exists
|
|
68
|
+
if os.path.exists(config_path):
|
|
69
|
+
os.unlink(config_path)
|
|
70
|
+
raise
|
|
71
|
+
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.error(f"Failed to setup rclone config: {e}")
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def cleanup(self) -> None:
|
|
77
|
+
"""Remove the temporary config file."""
|
|
78
|
+
if self._config_file and os.path.exists(self._config_file):
|
|
79
|
+
try:
|
|
80
|
+
os.unlink(self._config_file)
|
|
81
|
+
logger.debug(f"Cleaned up rclone config file: {self._config_file}")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.warning(f"Failed to cleanup rclone config: {e}")
|
|
84
|
+
|
|
85
|
+
# Always reset internal state and environment, even if the file was missing
|
|
86
|
+
if self._config_file is not None:
|
|
87
|
+
os.environ.pop("RCLONE_CONFIG", None)
|
|
88
|
+
self._config_file = None
|
|
89
|
+
self._config_loaded = False
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def is_configured(self) -> bool:
|
|
93
|
+
"""Check if rclone is configured and ready to use."""
|
|
94
|
+
return self._config_loaded and self._config_file is not None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Singleton instance
|
|
98
|
+
_rclone_service: Optional[RcloneService] = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_rclone_service() -> RcloneService:
|
|
102
|
+
"""Get the singleton rclone service instance."""
|
|
103
|
+
global _rclone_service
|
|
104
|
+
if _rclone_service is None:
|
|
105
|
+
_rclone_service = RcloneService()
|
|
106
|
+
return _rclone_service
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Cloud Storage operations for file management.
|
|
3
|
+
"""
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import json
|
|
7
|
+
from typing import Optional, BinaryIO, Any, Dict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from google.cloud import storage
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
from backend.config import settings
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class StorageService:
|
|
19
|
+
"""Service for Google Cloud Storage operations."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
"""Initialize GCS client."""
|
|
23
|
+
self.client = storage.Client(project=settings.google_cloud_project)
|
|
24
|
+
self.bucket = self.client.bucket(settings.gcs_bucket_name)
|
|
25
|
+
|
|
26
|
+
def upload_file(self, local_path: str, destination_path: str) -> str:
|
|
27
|
+
"""Upload a file to GCS."""
|
|
28
|
+
try:
|
|
29
|
+
blob = self.bucket.blob(destination_path)
|
|
30
|
+
blob.upload_from_filename(local_path)
|
|
31
|
+
logger.info(f"Uploaded {local_path} to gs://{settings.gcs_bucket_name}/{destination_path}")
|
|
32
|
+
return destination_path
|
|
33
|
+
except Exception as e:
|
|
34
|
+
logger.error(f"Error uploading file {local_path}: {e}")
|
|
35
|
+
raise
|
|
36
|
+
|
|
37
|
+
def upload_fileobj(self, file_obj: BinaryIO, destination_path: str, content_type: Optional[str] = None) -> str:
|
|
38
|
+
"""Upload a file object to GCS."""
|
|
39
|
+
try:
|
|
40
|
+
blob = self.bucket.blob(destination_path)
|
|
41
|
+
if content_type:
|
|
42
|
+
blob.content_type = content_type
|
|
43
|
+
blob.upload_from_file(file_obj, rewind=True)
|
|
44
|
+
logger.info(f"Uploaded file object to gs://{settings.gcs_bucket_name}/{destination_path}")
|
|
45
|
+
return destination_path
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Error uploading file object: {e}")
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
def download_file(self, source_path: str, destination_path: str) -> str:
|
|
51
|
+
"""Download a file from GCS."""
|
|
52
|
+
try:
|
|
53
|
+
blob = self.bucket.blob(source_path)
|
|
54
|
+
blob.download_to_filename(destination_path)
|
|
55
|
+
logger.info(f"Downloaded gs://{settings.gcs_bucket_name}/{source_path} to {destination_path}")
|
|
56
|
+
return destination_path
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.error(f"Error downloading file {source_path}: {e}")
|
|
59
|
+
raise
|
|
60
|
+
|
|
61
|
+
def generate_signed_url(self, blob_path: str, expiration_minutes: int = 60) -> str:
|
|
62
|
+
"""Generate a signed URL for downloading a file.
|
|
63
|
+
|
|
64
|
+
In Cloud Run, this uses the IAM signBlob API since we don't have
|
|
65
|
+
a private key available. Requires the service account to have
|
|
66
|
+
roles/iam.serviceAccountTokenCreator on itself.
|
|
67
|
+
"""
|
|
68
|
+
return self._generate_signed_url_internal(blob_path, "GET", expiration_minutes)
|
|
69
|
+
|
|
70
|
+
def generate_signed_upload_url(self, blob_path: str, content_type: str = "application/octet-stream", expiration_minutes: int = 60) -> str:
|
|
71
|
+
"""Generate a signed URL for uploading a file directly to GCS.
|
|
72
|
+
|
|
73
|
+
This allows clients to upload files directly to GCS without going through
|
|
74
|
+
the backend, bypassing any request body size limits.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
blob_path: The destination path in GCS
|
|
78
|
+
content_type: The expected content type of the upload
|
|
79
|
+
expiration_minutes: How long the URL is valid for
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
A signed URL that accepts PUT requests with the file content
|
|
83
|
+
"""
|
|
84
|
+
return self._generate_signed_url_internal(blob_path, "PUT", expiration_minutes, content_type)
|
|
85
|
+
|
|
86
|
+
def _generate_signed_url_internal(self, blob_path: str, method: str, expiration_minutes: int = 60, content_type: Optional[str] = None) -> str:
|
|
87
|
+
"""Internal method to generate signed URLs for GET or PUT operations."""
|
|
88
|
+
import google.auth
|
|
89
|
+
from google.auth.transport import requests
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
blob = self.bucket.blob(blob_path)
|
|
93
|
+
|
|
94
|
+
# Get default credentials and refresh to ensure we have a valid token
|
|
95
|
+
credentials, project = google.auth.default()
|
|
96
|
+
|
|
97
|
+
# Common kwargs for signed URL generation
|
|
98
|
+
kwargs = {
|
|
99
|
+
"version": "v4",
|
|
100
|
+
"expiration": timedelta(minutes=expiration_minutes),
|
|
101
|
+
"method": method,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# For PUT requests, we need to specify the content type in headers
|
|
105
|
+
if method == "PUT" and content_type:
|
|
106
|
+
kwargs["headers"] = {"Content-Type": content_type}
|
|
107
|
+
|
|
108
|
+
# Check if we're using compute credentials (Cloud Run/GCE)
|
|
109
|
+
# These need to use IAM signBlob via service_account_email + access_token
|
|
110
|
+
if hasattr(credentials, 'service_account_email'):
|
|
111
|
+
# Refresh credentials to get a valid access token
|
|
112
|
+
auth_request = requests.Request()
|
|
113
|
+
credentials.refresh(auth_request)
|
|
114
|
+
|
|
115
|
+
kwargs["service_account_email"] = credentials.service_account_email
|
|
116
|
+
kwargs["access_token"] = credentials.token
|
|
117
|
+
|
|
118
|
+
url = blob.generate_signed_url(**kwargs)
|
|
119
|
+
|
|
120
|
+
logger.info(f"Generated signed {method} URL for {blob_path}")
|
|
121
|
+
return url
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.error(f"Error generating signed {method} URL for {blob_path}: {e}")
|
|
124
|
+
raise
|
|
125
|
+
|
|
126
|
+
def delete_file(self, blob_path: str) -> None:
|
|
127
|
+
"""Delete a file from GCS."""
|
|
128
|
+
try:
|
|
129
|
+
blob = self.bucket.blob(blob_path)
|
|
130
|
+
blob.delete()
|
|
131
|
+
logger.info(f"Deleted gs://{settings.gcs_bucket_name}/{blob_path}")
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.error(f"Error deleting file {blob_path}: {e}")
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
def delete_folder(self, prefix: str) -> int:
|
|
137
|
+
"""
|
|
138
|
+
Delete all files in GCS with a given prefix (folder).
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
prefix: The folder prefix to delete (e.g., "uploads/abc123/")
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Number of files deleted
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
blobs = list(self.bucket.list_blobs(prefix=prefix))
|
|
148
|
+
deleted_count = 0
|
|
149
|
+
|
|
150
|
+
for blob in blobs:
|
|
151
|
+
try:
|
|
152
|
+
blob.delete()
|
|
153
|
+
deleted_count += 1
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.warning(f"Error deleting blob {blob.name}: {e}")
|
|
156
|
+
|
|
157
|
+
if deleted_count > 0:
|
|
158
|
+
logger.info(f"Deleted {deleted_count} files from gs://{settings.gcs_bucket_name}/{prefix}")
|
|
159
|
+
|
|
160
|
+
return deleted_count
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.error(f"Error deleting folder {prefix}: {e}")
|
|
163
|
+
return 0 # Don't raise - folder deletion shouldn't break operations
|
|
164
|
+
|
|
165
|
+
def list_files(self, prefix: str) -> list:
|
|
166
|
+
"""List files in GCS with a given prefix."""
|
|
167
|
+
try:
|
|
168
|
+
blobs = self.bucket.list_blobs(prefix=prefix)
|
|
169
|
+
return [blob.name for blob in blobs]
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Error listing files with prefix {prefix}: {e}")
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
def file_exists(self, blob_path: str) -> bool:
|
|
175
|
+
"""Check if a file exists in GCS."""
|
|
176
|
+
try:
|
|
177
|
+
blob = self.bucket.blob(blob_path)
|
|
178
|
+
return blob.exists()
|
|
179
|
+
except Exception as e:
|
|
180
|
+
logger.error(f"Error checking file existence {blob_path}: {e}")
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
def upload_json(self, destination_path: str, data: Dict[str, Any]) -> str:
|
|
184
|
+
"""Upload a JSON object to GCS."""
|
|
185
|
+
try:
|
|
186
|
+
blob = self.bucket.blob(destination_path)
|
|
187
|
+
blob.content_type = "application/json"
|
|
188
|
+
blob.upload_from_string(
|
|
189
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
190
|
+
content_type="application/json"
|
|
191
|
+
)
|
|
192
|
+
logger.info(f"Uploaded JSON to gs://{settings.gcs_bucket_name}/{destination_path}")
|
|
193
|
+
return destination_path
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.error(f"Error uploading JSON to {destination_path}: {e}")
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
def download_json(self, source_path: str) -> Dict[str, Any]:
|
|
199
|
+
"""Download and parse a JSON file from GCS."""
|
|
200
|
+
try:
|
|
201
|
+
blob = self.bucket.blob(source_path)
|
|
202
|
+
content = blob.download_as_text()
|
|
203
|
+
data = json.loads(content)
|
|
204
|
+
logger.info(f"Downloaded JSON from gs://{settings.gcs_bucket_name}/{source_path}")
|
|
205
|
+
return data
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error downloading JSON from {source_path}: {e}")
|
|
208
|
+
raise
|
|
209
|
+
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Stripe service for payment processing.
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Creating checkout sessions for credit purchases
|
|
6
|
+
- Processing webhook events
|
|
7
|
+
- Managing customer records
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import Optional, Dict, Any, Tuple
|
|
12
|
+
|
|
13
|
+
import stripe
|
|
14
|
+
|
|
15
|
+
from backend.config import get_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Credit packages available for purchase
|
|
22
|
+
CREDIT_PACKAGES = {
|
|
23
|
+
"1_credit": {
|
|
24
|
+
"credits": 1,
|
|
25
|
+
"price_cents": 500, # $5.00
|
|
26
|
+
"name": "1 Karaoke Credit",
|
|
27
|
+
"description": "Create 1 professional karaoke video",
|
|
28
|
+
},
|
|
29
|
+
"3_credits": {
|
|
30
|
+
"credits": 3,
|
|
31
|
+
"price_cents": 1200, # $12.00 (20% discount)
|
|
32
|
+
"name": "3 Karaoke Credits",
|
|
33
|
+
"description": "Create 3 professional karaoke videos (Save 20%)",
|
|
34
|
+
},
|
|
35
|
+
"5_credits": {
|
|
36
|
+
"credits": 5,
|
|
37
|
+
"price_cents": 1750, # $17.50 (30% discount)
|
|
38
|
+
"name": "5 Karaoke Credits",
|
|
39
|
+
"description": "Create 5 professional karaoke videos (Save 30%)",
|
|
40
|
+
},
|
|
41
|
+
"10_credits": {
|
|
42
|
+
"credits": 10,
|
|
43
|
+
"price_cents": 3000, # $30.00 (40% discount)
|
|
44
|
+
"name": "10 Karaoke Credits",
|
|
45
|
+
"description": "Create 10 professional karaoke videos (Save 40%)",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StripeService:
|
|
51
|
+
"""Service for Stripe payment processing."""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
"""Initialize Stripe with API key."""
|
|
55
|
+
self.settings = get_settings()
|
|
56
|
+
self.secret_key = os.getenv("STRIPE_SECRET_KEY")
|
|
57
|
+
self.webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET")
|
|
58
|
+
self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
|
|
59
|
+
# After consolidation, buy URL is the same as frontend URL
|
|
60
|
+
self.buy_url = os.getenv("BUY_URL", self.frontend_url)
|
|
61
|
+
|
|
62
|
+
if self.secret_key:
|
|
63
|
+
stripe.api_key = self.secret_key
|
|
64
|
+
logger.info("Stripe initialized with API key")
|
|
65
|
+
else:
|
|
66
|
+
logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
|
|
67
|
+
|
|
68
|
+
def is_configured(self) -> bool:
|
|
69
|
+
"""Check if Stripe is properly configured."""
|
|
70
|
+
return bool(self.secret_key)
|
|
71
|
+
|
|
72
|
+
def get_credit_packages(self) -> Dict[str, Dict[str, Any]]:
|
|
73
|
+
"""Get available credit packages."""
|
|
74
|
+
return CREDIT_PACKAGES
|
|
75
|
+
|
|
76
|
+
def create_checkout_session(
|
|
77
|
+
self,
|
|
78
|
+
package_id: str,
|
|
79
|
+
user_email: str,
|
|
80
|
+
success_url: Optional[str] = None,
|
|
81
|
+
cancel_url: Optional[str] = None,
|
|
82
|
+
) -> Tuple[bool, Optional[str], str]:
|
|
83
|
+
"""
|
|
84
|
+
Create a Stripe Checkout session for purchasing credits.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
package_id: ID of the credit package to purchase
|
|
88
|
+
user_email: Email of the purchasing user
|
|
89
|
+
success_url: URL to redirect to on success (optional)
|
|
90
|
+
cancel_url: URL to redirect to on cancel (optional)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
(success, checkout_url, message)
|
|
94
|
+
"""
|
|
95
|
+
if not self.is_configured():
|
|
96
|
+
return False, None, "Payment processing is not configured"
|
|
97
|
+
|
|
98
|
+
package = CREDIT_PACKAGES.get(package_id)
|
|
99
|
+
if not package:
|
|
100
|
+
return False, None, f"Invalid package: {package_id}"
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Default URLs
|
|
104
|
+
if not success_url:
|
|
105
|
+
success_url = f"{self.frontend_url}/payment/success?session_id={{CHECKOUT_SESSION_ID}}"
|
|
106
|
+
if not cancel_url:
|
|
107
|
+
cancel_url = f"{self.buy_url}?cancelled=true"
|
|
108
|
+
|
|
109
|
+
# Create checkout session
|
|
110
|
+
session = stripe.checkout.Session.create(
|
|
111
|
+
payment_method_types=['card'],
|
|
112
|
+
line_items=[{
|
|
113
|
+
'price_data': {
|
|
114
|
+
'currency': 'usd',
|
|
115
|
+
'product_data': {
|
|
116
|
+
'name': package['name'],
|
|
117
|
+
'description': package['description'],
|
|
118
|
+
},
|
|
119
|
+
'unit_amount': package['price_cents'],
|
|
120
|
+
},
|
|
121
|
+
'quantity': 1,
|
|
122
|
+
}],
|
|
123
|
+
mode='payment',
|
|
124
|
+
success_url=success_url,
|
|
125
|
+
cancel_url=cancel_url,
|
|
126
|
+
customer_email=user_email,
|
|
127
|
+
metadata={
|
|
128
|
+
'package_id': package_id,
|
|
129
|
+
'credits': str(package['credits']),
|
|
130
|
+
'user_email': user_email,
|
|
131
|
+
},
|
|
132
|
+
# Allow promotion codes
|
|
133
|
+
allow_promotion_codes=True,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
|
|
137
|
+
return True, session.url, "Checkout session created"
|
|
138
|
+
|
|
139
|
+
except stripe.error.StripeError as e:
|
|
140
|
+
logger.error(f"Stripe error creating checkout session: {e}")
|
|
141
|
+
return False, None, f"Payment error: {str(e)}"
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.error(f"Error creating checkout session: {e}")
|
|
144
|
+
return False, None, "Failed to create checkout session"
|
|
145
|
+
|
|
146
|
+
def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
|
|
147
|
+
"""
|
|
148
|
+
Verify a Stripe webhook signature.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
payload: Raw request body
|
|
152
|
+
signature: Stripe-Signature header value
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
(valid, event_data, message)
|
|
156
|
+
"""
|
|
157
|
+
if not self.webhook_secret:
|
|
158
|
+
logger.error("STRIPE_WEBHOOK_SECRET not configured")
|
|
159
|
+
return False, None, "Webhook secret not configured"
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
event = stripe.Webhook.construct_event(
|
|
163
|
+
payload, signature, self.webhook_secret
|
|
164
|
+
)
|
|
165
|
+
return True, event, "Webhook verified"
|
|
166
|
+
except stripe.error.SignatureVerificationError as e:
|
|
167
|
+
logger.error(f"Invalid webhook signature: {e}")
|
|
168
|
+
return False, None, "Invalid signature"
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error(f"Error verifying webhook: {e}")
|
|
171
|
+
return False, None, str(e)
|
|
172
|
+
|
|
173
|
+
def handle_checkout_completed(self, session: Dict) -> Tuple[bool, Optional[str], int, str]:
|
|
174
|
+
"""
|
|
175
|
+
Handle a completed checkout session.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
session: Stripe checkout session object
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
(success, user_email, credits_to_add, message)
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
metadata = session.get('metadata', {})
|
|
185
|
+
user_email = metadata.get('user_email') or session.get('customer_email')
|
|
186
|
+
try:
|
|
187
|
+
credits = int(metadata.get('credits', 0))
|
|
188
|
+
except (ValueError, TypeError):
|
|
189
|
+
logger.error(f"Invalid credits metadata in session {session.get('id')}: {metadata.get('credits')}")
|
|
190
|
+
return False, user_email, 0, "Invalid credit amount in session metadata"
|
|
191
|
+
package_id = metadata.get('package_id')
|
|
192
|
+
|
|
193
|
+
if not user_email:
|
|
194
|
+
logger.error(f"No user email in checkout session {session.get('id')}")
|
|
195
|
+
return False, None, 0, "No user email found"
|
|
196
|
+
|
|
197
|
+
if credits <= 0:
|
|
198
|
+
logger.error(f"Invalid credits in checkout session {session.get('id')}")
|
|
199
|
+
return False, user_email, 0, "Invalid credit amount"
|
|
200
|
+
|
|
201
|
+
logger.info(
|
|
202
|
+
f"Checkout completed: {user_email} purchased {credits} credits "
|
|
203
|
+
f"(package: {package_id}, session: {session.get('id')})"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return True, user_email, credits, f"Successfully purchased {credits} credits"
|
|
207
|
+
|
|
208
|
+
except Exception as e:
|
|
209
|
+
logger.error(f"Error handling checkout completed: {e}")
|
|
210
|
+
return False, None, 0, str(e)
|
|
211
|
+
|
|
212
|
+
def get_session(self, session_id: str) -> Optional[Dict]:
|
|
213
|
+
"""
|
|
214
|
+
Get a checkout session by ID.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
session_id: Stripe checkout session ID
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Session data or None
|
|
221
|
+
"""
|
|
222
|
+
if not self.is_configured():
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
session = stripe.checkout.Session.retrieve(session_id)
|
|
227
|
+
return dict(session)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
logger.error(f"Error retrieving session {session_id}: {e}")
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def create_customer(self, email: str, name: Optional[str] = None) -> Optional[str]:
|
|
233
|
+
"""
|
|
234
|
+
Create or get a Stripe customer for a user.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
email: User's email
|
|
238
|
+
name: User's display name (optional)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Stripe customer ID or None
|
|
242
|
+
"""
|
|
243
|
+
if not self.is_configured():
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
# Check if customer already exists
|
|
248
|
+
customers = stripe.Customer.list(email=email, limit=1)
|
|
249
|
+
if customers.data:
|
|
250
|
+
return customers.data[0].id
|
|
251
|
+
|
|
252
|
+
# Create new customer
|
|
253
|
+
customer = stripe.Customer.create(
|
|
254
|
+
email=email,
|
|
255
|
+
name=name,
|
|
256
|
+
metadata={'source': 'nomad_karaoke'},
|
|
257
|
+
)
|
|
258
|
+
logger.info(f"Created Stripe customer {customer.id} for {email}")
|
|
259
|
+
return customer.id
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error(f"Error creating Stripe customer: {e}")
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# Global instance
|
|
267
|
+
_stripe_service = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_stripe_service() -> StripeService:
|
|
271
|
+
"""Get the global Stripe service instance."""
|
|
272
|
+
global _stripe_service
|
|
273
|
+
if _stripe_service is None:
|
|
274
|
+
_stripe_service = StripeService()
|
|
275
|
+
return _stripe_service
|