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.
Files changed (187) hide show
  1. backend/.coveragerc +20 -0
  2. backend/.gitignore +37 -0
  3. backend/Dockerfile +43 -0
  4. backend/Dockerfile.base +74 -0
  5. backend/README.md +242 -0
  6. backend/__init__.py +0 -0
  7. backend/api/__init__.py +0 -0
  8. backend/api/dependencies.py +457 -0
  9. backend/api/routes/__init__.py +0 -0
  10. backend/api/routes/admin.py +742 -0
  11. backend/api/routes/audio_search.py +903 -0
  12. backend/api/routes/auth.py +348 -0
  13. backend/api/routes/file_upload.py +2076 -0
  14. backend/api/routes/health.py +344 -0
  15. backend/api/routes/internal.py +435 -0
  16. backend/api/routes/jobs.py +1610 -0
  17. backend/api/routes/review.py +652 -0
  18. backend/api/routes/themes.py +162 -0
  19. backend/api/routes/users.py +1014 -0
  20. backend/config.py +172 -0
  21. backend/main.py +133 -0
  22. backend/middleware/__init__.py +5 -0
  23. backend/middleware/audit_logging.py +124 -0
  24. backend/models/__init__.py +0 -0
  25. backend/models/job.py +519 -0
  26. backend/models/requests.py +123 -0
  27. backend/models/theme.py +153 -0
  28. backend/models/user.py +254 -0
  29. backend/models/worker_log.py +164 -0
  30. backend/pyproject.toml +29 -0
  31. backend/quick-check.sh +93 -0
  32. backend/requirements.txt +29 -0
  33. backend/run_tests.sh +60 -0
  34. backend/services/__init__.py +0 -0
  35. backend/services/audio_analysis_service.py +243 -0
  36. backend/services/audio_editing_service.py +278 -0
  37. backend/services/audio_search_service.py +702 -0
  38. backend/services/auth_service.py +630 -0
  39. backend/services/credential_manager.py +792 -0
  40. backend/services/discord_service.py +172 -0
  41. backend/services/dropbox_service.py +301 -0
  42. backend/services/email_service.py +1093 -0
  43. backend/services/encoding_interface.py +454 -0
  44. backend/services/encoding_service.py +405 -0
  45. backend/services/firestore_service.py +512 -0
  46. backend/services/flacfetch_client.py +573 -0
  47. backend/services/gce_encoding/README.md +72 -0
  48. backend/services/gce_encoding/__init__.py +22 -0
  49. backend/services/gce_encoding/main.py +589 -0
  50. backend/services/gce_encoding/requirements.txt +16 -0
  51. backend/services/gdrive_service.py +356 -0
  52. backend/services/job_logging.py +258 -0
  53. backend/services/job_manager.py +842 -0
  54. backend/services/job_notification_service.py +271 -0
  55. backend/services/local_encoding_service.py +590 -0
  56. backend/services/local_preview_encoding_service.py +407 -0
  57. backend/services/lyrics_cache_service.py +216 -0
  58. backend/services/metrics.py +413 -0
  59. backend/services/packaging_service.py +287 -0
  60. backend/services/rclone_service.py +106 -0
  61. backend/services/storage_service.py +209 -0
  62. backend/services/stripe_service.py +275 -0
  63. backend/services/structured_logging.py +254 -0
  64. backend/services/template_service.py +330 -0
  65. backend/services/theme_service.py +469 -0
  66. backend/services/tracing.py +543 -0
  67. backend/services/user_service.py +721 -0
  68. backend/services/worker_service.py +558 -0
  69. backend/services/youtube_service.py +112 -0
  70. backend/services/youtube_upload_service.py +445 -0
  71. backend/tests/__init__.py +4 -0
  72. backend/tests/conftest.py +224 -0
  73. backend/tests/emulator/__init__.py +7 -0
  74. backend/tests/emulator/conftest.py +88 -0
  75. backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
  76. backend/tests/emulator/test_emulator_integration.py +356 -0
  77. backend/tests/emulator/test_style_loading_direct.py +436 -0
  78. backend/tests/emulator/test_worker_logs_direct.py +229 -0
  79. backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
  80. backend/tests/requirements-test.txt +10 -0
  81. backend/tests/requirements.txt +6 -0
  82. backend/tests/test_admin_email_endpoints.py +411 -0
  83. backend/tests/test_api_integration.py +460 -0
  84. backend/tests/test_api_routes.py +93 -0
  85. backend/tests/test_audio_analysis_service.py +294 -0
  86. backend/tests/test_audio_editing_service.py +386 -0
  87. backend/tests/test_audio_search.py +1398 -0
  88. backend/tests/test_audio_services.py +378 -0
  89. backend/tests/test_auth_firestore.py +231 -0
  90. backend/tests/test_config_extended.py +68 -0
  91. backend/tests/test_credential_manager.py +377 -0
  92. backend/tests/test_dependencies.py +54 -0
  93. backend/tests/test_discord_service.py +244 -0
  94. backend/tests/test_distribution_services.py +820 -0
  95. backend/tests/test_dropbox_service.py +472 -0
  96. backend/tests/test_email_service.py +492 -0
  97. backend/tests/test_emulator_integration.py +322 -0
  98. backend/tests/test_encoding_interface.py +412 -0
  99. backend/tests/test_file_upload.py +1739 -0
  100. backend/tests/test_flacfetch_client.py +632 -0
  101. backend/tests/test_gdrive_service.py +524 -0
  102. backend/tests/test_instrumental_api.py +431 -0
  103. backend/tests/test_internal_api.py +343 -0
  104. backend/tests/test_job_creation_regression.py +583 -0
  105. backend/tests/test_job_manager.py +339 -0
  106. backend/tests/test_job_manager_notifications.py +329 -0
  107. backend/tests/test_job_notification_service.py +443 -0
  108. backend/tests/test_jobs_api.py +273 -0
  109. backend/tests/test_local_encoding_service.py +423 -0
  110. backend/tests/test_local_preview_encoding_service.py +567 -0
  111. backend/tests/test_main.py +87 -0
  112. backend/tests/test_models.py +918 -0
  113. backend/tests/test_packaging_service.py +382 -0
  114. backend/tests/test_requests.py +201 -0
  115. backend/tests/test_routes_jobs.py +282 -0
  116. backend/tests/test_routes_review.py +337 -0
  117. backend/tests/test_services.py +556 -0
  118. backend/tests/test_services_extended.py +112 -0
  119. backend/tests/test_storage_service.py +448 -0
  120. backend/tests/test_style_upload.py +261 -0
  121. backend/tests/test_template_service.py +295 -0
  122. backend/tests/test_theme_service.py +516 -0
  123. backend/tests/test_unicode_sanitization.py +522 -0
  124. backend/tests/test_upload_api.py +256 -0
  125. backend/tests/test_validate.py +156 -0
  126. backend/tests/test_video_worker_orchestrator.py +847 -0
  127. backend/tests/test_worker_log_subcollection.py +509 -0
  128. backend/tests/test_worker_logging.py +365 -0
  129. backend/tests/test_workers.py +1116 -0
  130. backend/tests/test_workers_extended.py +178 -0
  131. backend/tests/test_youtube_service.py +247 -0
  132. backend/tests/test_youtube_upload_service.py +568 -0
  133. backend/validate.py +173 -0
  134. backend/version.py +27 -0
  135. backend/workers/README.md +597 -0
  136. backend/workers/__init__.py +11 -0
  137. backend/workers/audio_worker.py +618 -0
  138. backend/workers/lyrics_worker.py +683 -0
  139. backend/workers/render_video_worker.py +483 -0
  140. backend/workers/screens_worker.py +525 -0
  141. backend/workers/style_helper.py +198 -0
  142. backend/workers/video_worker.py +1277 -0
  143. backend/workers/video_worker_orchestrator.py +701 -0
  144. backend/workers/worker_logging.py +278 -0
  145. karaoke_gen/instrumental_review/static/index.html +7 -4
  146. karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
  147. karaoke_gen/utils/__init__.py +163 -8
  148. karaoke_gen/video_background_processor.py +9 -4
  149. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/METADATA +1 -1
  150. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/RECORD +186 -41
  151. lyrics_transcriber/correction/agentic/providers/config.py +9 -5
  152. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +1 -51
  153. lyrics_transcriber/correction/corrector.py +192 -130
  154. lyrics_transcriber/correction/operations.py +24 -9
  155. lyrics_transcriber/frontend/package-lock.json +2 -2
  156. lyrics_transcriber/frontend/package.json +1 -1
  157. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
  158. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
  159. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
  160. lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
  161. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
  162. lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
  163. lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
  164. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
  165. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
  166. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
  167. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
  168. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  169. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
  170. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
  171. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  172. lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
  173. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
  174. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
  175. lyrics_transcriber/frontend/src/theme.ts +42 -15
  176. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  177. lyrics_transcriber/frontend/vite.config.js +5 -0
  178. lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
  179. lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
  180. lyrics_transcriber/frontend/web_assets/index.html +6 -2
  181. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
  182. lyrics_transcriber/output/generator.py +17 -3
  183. lyrics_transcriber/output/video.py +60 -95
  184. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
  185. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/WHEEL +0 -0
  186. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.dist-info}/entry_points.txt +0 -0
  187. {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.96.0.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()