karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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 +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -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 +502 -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 +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -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/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -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 +109 -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 +356 -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 +283 -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_spacy_preloader.py +119 -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/utils/test_data.py +27 -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 +535 -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.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- 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.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Discord Notification Service.
|
|
3
|
+
|
|
4
|
+
Provides Discord webhook notification functionality, extracted from KaraokeFinalise
|
|
5
|
+
for use by both the cloud backend (video_worker) and local CLI.
|
|
6
|
+
|
|
7
|
+
This service handles:
|
|
8
|
+
- Posting messages to Discord webhooks
|
|
9
|
+
- Video upload notifications
|
|
10
|
+
- Validation of webhook URLs
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import requests
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DiscordNotificationService:
|
|
23
|
+
"""
|
|
24
|
+
Service for sending Discord webhook notifications.
|
|
25
|
+
|
|
26
|
+
Supports posting messages to Discord channels via webhooks,
|
|
27
|
+
commonly used to notify when new karaoke videos are uploaded.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
# Discord webhook URL pattern
|
|
31
|
+
WEBHOOK_URL_PATTERN = re.compile(
|
|
32
|
+
r"^https://discord\.com/api/webhooks/\d+/[a-zA-Z0-9_-]+$"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
webhook_url: Optional[str] = None,
|
|
38
|
+
dry_run: bool = False,
|
|
39
|
+
logger: Optional[logging.Logger] = None,
|
|
40
|
+
):
|
|
41
|
+
"""
|
|
42
|
+
Initialize the Discord notification service.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
webhook_url: Discord webhook URL for posting notifications
|
|
46
|
+
dry_run: If True, log actions without performing them
|
|
47
|
+
logger: Optional logger instance
|
|
48
|
+
"""
|
|
49
|
+
self.webhook_url = webhook_url
|
|
50
|
+
self.dry_run = dry_run
|
|
51
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
# Validate webhook URL if provided
|
|
54
|
+
if webhook_url:
|
|
55
|
+
self._validate_webhook_url(webhook_url)
|
|
56
|
+
|
|
57
|
+
def _validate_webhook_url(self, url: str) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Validate that the webhook URL is a valid Discord webhook URL.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
url: URL to validate
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If URL is not a valid Discord webhook URL
|
|
66
|
+
"""
|
|
67
|
+
url = url.strip()
|
|
68
|
+
if not url.startswith("https://discord.com/api/webhooks/"):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Invalid Discord webhook URL: {url}. "
|
|
71
|
+
"URL must start with 'https://discord.com/api/webhooks/'"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
def post_message(
|
|
75
|
+
self,
|
|
76
|
+
message: str,
|
|
77
|
+
webhook_url: Optional[str] = None,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""
|
|
80
|
+
Post a message to a Discord channel via webhook.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message: The message content to post
|
|
84
|
+
webhook_url: Optional webhook URL (uses instance URL if not provided)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if message was posted successfully, False otherwise
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ValueError: If no webhook URL is available
|
|
91
|
+
requests.HTTPError: If the webhook request fails
|
|
92
|
+
"""
|
|
93
|
+
url = webhook_url or self.webhook_url
|
|
94
|
+
if not url:
|
|
95
|
+
raise ValueError("No Discord webhook URL provided")
|
|
96
|
+
|
|
97
|
+
url = url.strip()
|
|
98
|
+
self._validate_webhook_url(url)
|
|
99
|
+
|
|
100
|
+
if self.dry_run:
|
|
101
|
+
self.logger.info(
|
|
102
|
+
f"DRY RUN: Would post Discord message: '{message}' to {url}"
|
|
103
|
+
)
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
self.logger.info(f"Posting message to Discord webhook...")
|
|
107
|
+
data = {"content": message}
|
|
108
|
+
response = requests.post(url, json=data, timeout=30)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
self.logger.info("Message posted to Discord successfully")
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
def post_video_notification(
|
|
114
|
+
self,
|
|
115
|
+
youtube_url: str,
|
|
116
|
+
webhook_url: Optional[str] = None,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Post a notification about a new video upload.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
youtube_url: The YouTube URL of the uploaded video
|
|
123
|
+
webhook_url: Optional webhook URL (uses instance URL if not provided)
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if notification was posted successfully, False otherwise
|
|
127
|
+
"""
|
|
128
|
+
if not youtube_url:
|
|
129
|
+
self.logger.info("Skipping Discord notification - no YouTube URL available")
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
message = f"New upload: {youtube_url}"
|
|
133
|
+
return self.post_message(message, webhook_url)
|
|
134
|
+
|
|
135
|
+
def is_enabled(self) -> bool:
|
|
136
|
+
"""
|
|
137
|
+
Check if Discord notifications are enabled.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if a valid webhook URL is configured
|
|
141
|
+
"""
|
|
142
|
+
return bool(self.webhook_url)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Singleton instance and factory function (following existing service pattern)
|
|
146
|
+
_discord_notification_service: Optional[DiscordNotificationService] = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_discord_notification_service(
|
|
150
|
+
webhook_url: Optional[str] = None,
|
|
151
|
+
**kwargs
|
|
152
|
+
) -> DiscordNotificationService:
|
|
153
|
+
"""
|
|
154
|
+
Get a Discord notification service instance.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
webhook_url: Discord webhook URL for notifications
|
|
158
|
+
**kwargs: Additional arguments passed to DiscordNotificationService
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
DiscordNotificationService instance
|
|
162
|
+
"""
|
|
163
|
+
global _discord_notification_service
|
|
164
|
+
|
|
165
|
+
# Create new instance if webhook URL changed or not yet created
|
|
166
|
+
if _discord_notification_service is None or webhook_url:
|
|
167
|
+
_discord_notification_service = DiscordNotificationService(
|
|
168
|
+
webhook_url=webhook_url,
|
|
169
|
+
**kwargs
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return _discord_notification_service
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Native Dropbox API service for cloud backend.
|
|
2
|
+
|
|
3
|
+
This service provides Dropbox operations using the native Python SDK,
|
|
4
|
+
replacing rclone for server-side operations. It handles:
|
|
5
|
+
- Folder listing for brand code calculation
|
|
6
|
+
- File and folder uploads
|
|
7
|
+
- Shared link generation
|
|
8
|
+
|
|
9
|
+
Credentials are loaded from Google Cloud Secret Manager.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from google.cloud import secretmanager
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DropboxService:
|
|
23
|
+
"""Dropbox operations using native Python SDK."""
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self._client = None
|
|
27
|
+
self._is_configured = False
|
|
28
|
+
|
|
29
|
+
def _load_credentials(self) -> Optional[dict]:
|
|
30
|
+
"""Load OAuth credentials from Secret Manager."""
|
|
31
|
+
try:
|
|
32
|
+
client = secretmanager.SecretManagerServiceClient()
|
|
33
|
+
# Try to get the project ID from environment or use default
|
|
34
|
+
project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "karaoke-gen")
|
|
35
|
+
name = f"projects/{project_id}/secrets/dropbox-oauth-credentials/versions/latest"
|
|
36
|
+
response = client.access_secret_version(request={"name": name})
|
|
37
|
+
return json.loads(response.payload.data.decode("UTF-8"))
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.warning(f"Failed to load Dropbox credentials from Secret Manager: {e}")
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_configured(self) -> bool:
|
|
44
|
+
"""Check if Dropbox credentials are available."""
|
|
45
|
+
if self._is_configured:
|
|
46
|
+
return True
|
|
47
|
+
creds = self._load_credentials()
|
|
48
|
+
self._is_configured = creds is not None and "access_token" in creds
|
|
49
|
+
return self._is_configured
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def client(self):
|
|
53
|
+
"""Get or create Dropbox client."""
|
|
54
|
+
if self._client is None:
|
|
55
|
+
# Import here to avoid import errors if dropbox is not installed
|
|
56
|
+
try:
|
|
57
|
+
from dropbox import Dropbox
|
|
58
|
+
except ImportError:
|
|
59
|
+
raise ImportError(
|
|
60
|
+
"dropbox package is not installed. "
|
|
61
|
+
"Install it with: pip install dropbox"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
creds = self._load_credentials()
|
|
65
|
+
if not creds:
|
|
66
|
+
raise RuntimeError("Dropbox credentials not configured in Secret Manager")
|
|
67
|
+
|
|
68
|
+
self._client = Dropbox(
|
|
69
|
+
oauth2_access_token=creds["access_token"],
|
|
70
|
+
oauth2_refresh_token=creds.get("refresh_token"),
|
|
71
|
+
app_key=creds.get("app_key"),
|
|
72
|
+
app_secret=creds.get("app_secret"),
|
|
73
|
+
)
|
|
74
|
+
return self._client
|
|
75
|
+
|
|
76
|
+
def list_folders(self, path: str) -> list[str]:
|
|
77
|
+
"""
|
|
78
|
+
List folder names at path for brand code calculation.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
path: Dropbox path to list (e.g., "/Karaoke/Tracks-Organized")
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
List of folder names in the path
|
|
85
|
+
"""
|
|
86
|
+
from dropbox.files import FolderMetadata
|
|
87
|
+
|
|
88
|
+
# Ensure path starts with /
|
|
89
|
+
if not path.startswith("/"):
|
|
90
|
+
path = f"/{path}"
|
|
91
|
+
|
|
92
|
+
logger.info(f"Listing folders at Dropbox path: {path}")
|
|
93
|
+
|
|
94
|
+
result = self.client.files_list_folder(path)
|
|
95
|
+
folders = []
|
|
96
|
+
|
|
97
|
+
# Get all entries (handling pagination)
|
|
98
|
+
while True:
|
|
99
|
+
for entry in result.entries:
|
|
100
|
+
if isinstance(entry, FolderMetadata):
|
|
101
|
+
folders.append(entry.name)
|
|
102
|
+
|
|
103
|
+
if not result.has_more:
|
|
104
|
+
break
|
|
105
|
+
result = self.client.files_list_folder_continue(result.cursor)
|
|
106
|
+
|
|
107
|
+
logger.info(f"Found {len(folders)} folders in {path}")
|
|
108
|
+
return folders
|
|
109
|
+
|
|
110
|
+
def get_next_brand_code(self, path: str, brand_prefix: str) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Calculate next brand code from existing folders.
|
|
113
|
+
|
|
114
|
+
Scans the folder for existing brand codes like "NOMAD-0001" and
|
|
115
|
+
returns the next sequential code.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
path: Dropbox path containing organized folders
|
|
119
|
+
brand_prefix: Brand prefix (e.g., "NOMAD")
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Next brand code (e.g., "NOMAD-1164")
|
|
123
|
+
"""
|
|
124
|
+
folders = self.list_folders(path)
|
|
125
|
+
pattern = re.compile(rf"^{re.escape(brand_prefix)}-(\d{{4}})")
|
|
126
|
+
|
|
127
|
+
max_num = 0
|
|
128
|
+
for folder in folders:
|
|
129
|
+
match = pattern.match(folder)
|
|
130
|
+
if match:
|
|
131
|
+
num = int(match.group(1))
|
|
132
|
+
max_num = max(max_num, num)
|
|
133
|
+
|
|
134
|
+
next_code = f"{brand_prefix}-{max_num + 1:04d}"
|
|
135
|
+
logger.info(f"Next brand code for {brand_prefix}: {next_code} (max existing: {max_num})")
|
|
136
|
+
return next_code
|
|
137
|
+
|
|
138
|
+
def upload_file(self, local_path: str, remote_path: str) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Upload a single file to Dropbox.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
local_path: Local file path
|
|
144
|
+
remote_path: Dropbox destination path (must start with /)
|
|
145
|
+
"""
|
|
146
|
+
from dropbox.files import WriteMode
|
|
147
|
+
|
|
148
|
+
# Ensure remote path starts with /
|
|
149
|
+
if not remote_path.startswith("/"):
|
|
150
|
+
remote_path = f"/{remote_path}"
|
|
151
|
+
|
|
152
|
+
file_size = os.path.getsize(local_path)
|
|
153
|
+
logger.info(f"Uploading {local_path} ({file_size / 1024 / 1024:.1f} MB) to {remote_path}")
|
|
154
|
+
|
|
155
|
+
# For large files (>150MB), use upload sessions
|
|
156
|
+
CHUNK_SIZE = 150 * 1024 * 1024 # 150 MB
|
|
157
|
+
|
|
158
|
+
with open(local_path, "rb") as f:
|
|
159
|
+
if file_size <= CHUNK_SIZE:
|
|
160
|
+
# Simple upload for smaller files
|
|
161
|
+
self.client.files_upload(f.read(), remote_path, mode=WriteMode.overwrite)
|
|
162
|
+
else:
|
|
163
|
+
# Chunked upload for large files
|
|
164
|
+
self._upload_large_file(f, remote_path, file_size)
|
|
165
|
+
|
|
166
|
+
logger.info(f"Successfully uploaded to {remote_path}")
|
|
167
|
+
|
|
168
|
+
def _upload_large_file(self, file_obj, remote_path: str, file_size: int) -> None:
|
|
169
|
+
"""Upload a large file using upload sessions."""
|
|
170
|
+
from dropbox.files import CommitInfo, UploadSessionCursor, WriteMode
|
|
171
|
+
|
|
172
|
+
CHUNK_SIZE = 150 * 1024 * 1024 # 150 MB
|
|
173
|
+
|
|
174
|
+
# Start upload session
|
|
175
|
+
session_start = self.client.files_upload_session_start(file_obj.read(CHUNK_SIZE))
|
|
176
|
+
cursor = UploadSessionCursor(
|
|
177
|
+
session_id=session_start.session_id,
|
|
178
|
+
offset=file_obj.tell(),
|
|
179
|
+
)
|
|
180
|
+
commit = CommitInfo(path=remote_path, mode=WriteMode.overwrite)
|
|
181
|
+
|
|
182
|
+
# Upload remaining chunks
|
|
183
|
+
while file_obj.tell() < file_size:
|
|
184
|
+
remaining = file_size - file_obj.tell()
|
|
185
|
+
if remaining <= CHUNK_SIZE:
|
|
186
|
+
# Final chunk
|
|
187
|
+
self.client.files_upload_session_finish(
|
|
188
|
+
file_obj.read(CHUNK_SIZE),
|
|
189
|
+
cursor,
|
|
190
|
+
commit,
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
# Intermediate chunk
|
|
194
|
+
self.client.files_upload_session_append_v2(
|
|
195
|
+
file_obj.read(CHUNK_SIZE),
|
|
196
|
+
cursor,
|
|
197
|
+
)
|
|
198
|
+
cursor.offset = file_obj.tell()
|
|
199
|
+
|
|
200
|
+
def upload_folder(self, local_dir: str, remote_path: str) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Recursively upload all files and subdirectories to Dropbox folder.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
local_dir: Local directory to upload
|
|
206
|
+
remote_path: Dropbox destination folder path
|
|
207
|
+
"""
|
|
208
|
+
# Ensure remote path starts with /
|
|
209
|
+
if not remote_path.startswith("/"):
|
|
210
|
+
remote_path = f"/{remote_path}"
|
|
211
|
+
|
|
212
|
+
logger.info(f"Uploading folder {local_dir} to {remote_path}")
|
|
213
|
+
|
|
214
|
+
uploaded_count = 0
|
|
215
|
+
for root, _dirs, files in os.walk(local_dir):
|
|
216
|
+
# Calculate the relative path from local_dir to current root
|
|
217
|
+
rel_root = os.path.relpath(root, local_dir)
|
|
218
|
+
if rel_root == ".":
|
|
219
|
+
current_remote = remote_path
|
|
220
|
+
else:
|
|
221
|
+
current_remote = f"{remote_path}/{rel_root}"
|
|
222
|
+
|
|
223
|
+
for filename in files:
|
|
224
|
+
local_file = os.path.join(root, filename)
|
|
225
|
+
remote_file = f"{current_remote}/{filename}"
|
|
226
|
+
self.upload_file(local_file, remote_file)
|
|
227
|
+
uploaded_count += 1
|
|
228
|
+
|
|
229
|
+
logger.info(f"Uploaded {uploaded_count} files to {remote_path}")
|
|
230
|
+
|
|
231
|
+
def create_shared_link(self, path: str) -> str:
|
|
232
|
+
"""
|
|
233
|
+
Create and return a shared link for the path.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
path: Dropbox path to share
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Shared link URL
|
|
240
|
+
"""
|
|
241
|
+
from dropbox.exceptions import ApiError
|
|
242
|
+
from dropbox.sharing import SharedLinkSettings
|
|
243
|
+
|
|
244
|
+
# Ensure path starts with /
|
|
245
|
+
if not path.startswith("/"):
|
|
246
|
+
path = f"/{path}"
|
|
247
|
+
|
|
248
|
+
logger.info(f"Creating shared link for: {path}")
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
# Try to create a new shared link
|
|
252
|
+
settings = SharedLinkSettings(requested_visibility=None)
|
|
253
|
+
link = self.client.sharing_create_shared_link_with_settings(path, settings)
|
|
254
|
+
logger.info(f"Created new shared link: {link.url}")
|
|
255
|
+
return link.url
|
|
256
|
+
except ApiError as e:
|
|
257
|
+
# Link may already exist - try to get existing link
|
|
258
|
+
if e.error.is_shared_link_already_exists():
|
|
259
|
+
logger.info("Shared link already exists, retrieving existing link")
|
|
260
|
+
links = self.client.sharing_list_shared_links(path=path, direct_only=True)
|
|
261
|
+
if links.links:
|
|
262
|
+
logger.info(f"Found existing shared link: {links.links[0].url}")
|
|
263
|
+
return links.links[0].url
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
def delete_folder(self, path: str) -> bool:
|
|
267
|
+
"""
|
|
268
|
+
Delete a folder and all its contents from Dropbox.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
path: Dropbox path to delete (e.g., "/Karaoke/Tracks-Organized/NOMAD-1234 - Artist - Title")
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
True if deleted successfully, False otherwise
|
|
275
|
+
"""
|
|
276
|
+
from dropbox.exceptions import ApiError
|
|
277
|
+
|
|
278
|
+
# Ensure path starts with /
|
|
279
|
+
if not path.startswith("/"):
|
|
280
|
+
path = f"/{path}"
|
|
281
|
+
|
|
282
|
+
logger.info(f"Deleting Dropbox folder: {path}")
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
self.client.files_delete_v2(path)
|
|
286
|
+
logger.info(f"Successfully deleted: {path}")
|
|
287
|
+
return True
|
|
288
|
+
except ApiError as e:
|
|
289
|
+
if e.error.is_path_lookup() and e.error.get_path_lookup().is_not_found():
|
|
290
|
+
logger.warning(f"Folder not found (already deleted?): {path}")
|
|
291
|
+
return True # Consider it success if already gone
|
|
292
|
+
logger.error(f"Failed to delete Dropbox folder: {e}")
|
|
293
|
+
return False
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.error(f"Error deleting Dropbox folder: {e}")
|
|
296
|
+
return False
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def get_dropbox_service() -> DropboxService:
|
|
300
|
+
"""Factory function to get a DropboxService instance."""
|
|
301
|
+
return DropboxService()
|