aliSaac510 commited on
Commit
02fa899
·
1 Parent(s): 0faf659

Feat: Advanced API Key Management (Firebase rotation, deactivation logic), Swagger UI improvements, and bug fixes.

Browse files
Files changed (4) hide show
  1. core/analyze.py +60 -16
  2. core/database.py +92 -7
  3. core/task_queue.py +3 -1
  4. main.py +407 -196
core/analyze.py CHANGED
@@ -14,23 +14,67 @@ except Exception:
14
  print("⚠️ Firebase not configured, falling back to local SQLite.")
15
  db = DatabaseManager(use_firebase=False)
16
 
17
- # Retrieve API Key from Secure Storage
18
- # 1. Try to get from Database
19
- api_key = db.get_key("openrouter")
20
-
21
- # 2. If not in DB, fallback to .env (Legacy support)
22
- if not api_key:
23
- api_key = os.getenv("OPENROUTER_API_KEY")
24
-
25
- if not api_key:
26
- print("❌ ERROR: OPENROUTER_API_KEY not found in Database or .env")
27
- # We don't raise error here to allow module import, but client creation will fail if used.
28
-
29
  # Configure OpenAI Client
30
- client = OpenAI(
31
- base_url="https://openrouter.ai/api/v1",
32
- api_key=api_key
33
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  def analyze_transcript_gemini(transcript):
36
  """تحليل النص باستخدام OpenRouter (DeepSeek) للحصول على أفضل النتائج"""
 
14
  print("⚠️ Firebase not configured, falling back to local SQLite.")
15
  db = DatabaseManager(use_firebase=False)
16
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  # Configure OpenAI Client
18
+ def get_working_client():
19
+ """
20
+ Retrieves a working OpenAI client by testing available keys.
21
+ Rotates through keys in DB, deactivates invalid ones, and falls back to .env.
22
+ """
23
+ print("🔍 Searching for a valid API Key...")
24
+
25
+ # 1. Try keys from Database (Firebase/SQLite)
26
+ # We fetch ALL keys to rotate through them
27
+ db_keys_data = db.get_all_keys("openrouter")
28
+
29
+ if db_keys_data:
30
+ print(f"📊 Found {len(db_keys_data)} potential keys in Database.")
31
+ print(f"👥 Contributors: {', '.join([k['name'] for k in db_keys_data])}")
32
+ else:
33
+ print("⚠️ No keys found in Database for service 'openrouter'.")
34
+
35
+ for key_data in db_keys_data:
36
+ key = key_data['key']
37
+ contributor = key_data['name']
38
+
39
+ print(f"👉 Testing Key provided by: {contributor}...")
40
+
41
+ temp_client = OpenAI(
42
+ base_url="https://openrouter.ai/api/v1",
43
+ api_key=key
44
+ )
45
+ try:
46
+ # Enhanced Validation: Try a minimal generation to ensure quota/permissions
47
+ temp_client.chat.completions.create(
48
+ model="deepseek/deepseek-v3.2", # Use a cheap/fast model for check
49
+ messages=[{"role": "user", "content": "Hi"}],
50
+ max_tokens=1
51
+ )
52
+ print(f"✅ SUCCESS: Key from {contributor} is working and ready!")
53
+ return temp_client
54
+ except Exception as e:
55
+ # If 401 Unauthorized or Quota Exceeded, key is invalid -> Deactivate it
56
+ error_str = str(e).lower()
57
+ if "401" in error_str or "unauthorized" in error_str or "insufficient_quota" in error_str:
58
+ print(f"🚫 Invalid/Expired Key from {contributor}. Deactivating... ({str(e)})")
59
+ db.deactivate_key("openrouter", key)
60
+ else:
61
+ print(f"⚠️ Key check failed for {contributor} (Error: {str(e)})")
62
+ # We skip non-auth errors but don't deactivate immediately
63
+
64
+ # 2. If no DB keys work, fallback to .env
65
+ print("⚠️ No valid Community keys found. Trying local .env...")
66
+ env_key = os.getenv("OPENROUTER_API_KEY")
67
+ if env_key:
68
+ print("✅ Found valid API Key in .env.")
69
+ return OpenAI(
70
+ base_url="https://openrouter.ai/api/v1",
71
+ api_key=env_key
72
+ )
73
+
74
+ print("❌ ERROR: No valid API keys found in Database or .env")
75
+ return None
76
+
77
+ client = get_working_client()
78
 
79
  def analyze_transcript_gemini(transcript):
80
  """تحليل النص باستخدام OpenRouter (DeepSeek) للحصول على أفضل النتائج"""
core/database.py CHANGED
@@ -68,6 +68,8 @@ class DatabaseManager:
68
  encrypted = self.security.encrypt_data(api_key)
69
 
70
  if self.use_firebase:
 
 
71
  # Save to Firestore
72
  # We use a hash of the encrypted key as document ID to prevent duplicates
73
  doc_id = f"{service_name}_{hash(encrypted)}"
@@ -92,6 +94,78 @@ class DatabaseManager:
92
  finally:
93
  conn.close()
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def get_key(self, service_name: str) -> Optional[str]:
96
  """Retrieves a valid API key (Round-Robin or Random could be implemented here)."""
97
  # For now, just get the first available active key
@@ -118,19 +192,26 @@ class DatabaseManager:
118
  return self.security.decrypt_data(row[0])
119
  return None
120
 
121
- def get_all_keys(self, service_name: str) -> List[str]:
122
- """Returns ALL valid decrypted keys for a service (useful for rotation)."""
123
- keys = []
124
  if self.use_firebase:
125
  docs = self.collection.where('service', '==', service_name.lower())\
126
  .where('is_active', '==', True).stream()
127
  for doc in docs:
128
- decrypted = self.security.decrypt_data(doc.to_dict()['encrypted_key'])
 
129
  if decrypted:
130
- keys.append(decrypted)
 
 
 
 
131
  else:
132
  conn = sqlite3.connect(self.db_path)
133
  cursor = conn.cursor()
 
 
134
  cursor.execute('''
135
  SELECT encrypted_key FROM api_keys
136
  WHERE service_name = ? AND is_active = 1
@@ -140,5 +221,9 @@ class DatabaseManager:
140
  for row in rows:
141
  decrypted = self.security.decrypt_data(row[0])
142
  if decrypted:
143
- keys.append(decrypted)
144
- return keys
 
 
 
 
 
68
  encrypted = self.security.encrypt_data(api_key)
69
 
70
  if self.use_firebase:
71
+ # Local import to ensure symbol availability
72
+ from firebase_admin import firestore
73
  # Save to Firestore
74
  # We use a hash of the encrypted key as document ID to prevent duplicates
75
  doc_id = f"{service_name}_{hash(encrypted)}"
 
94
  finally:
95
  conn.close()
96
 
97
+ def save_key_with_meta(self, name: str, provider: str, api_key: str, community_message: str = None):
98
+ """
99
+ Encrypts and saves an API key with extra metadata.
100
+ Args:
101
+ name: User defined name for the key.
102
+ provider: The service provider (e.g. OpenAI).
103
+ api_key: The RAW api key string (will be encrypted here).
104
+ community_message: Optional message from the contributor.
105
+ """
106
+ # Encrypt the key SERVER-SIDE before storage
107
+ encrypted = self.security.encrypt_data(api_key)
108
+
109
+ if self.use_firebase:
110
+ from firebase_admin import firestore
111
+
112
+ # Create a unique ID based on provider and key hash to prevent duplicates
113
+ doc_id = f"{provider.lower()}_{hash(encrypted)}"
114
+
115
+ data = {
116
+ 'name': name,
117
+ 'provider': provider.lower(),
118
+ 'service': provider.lower(),
119
+ 'encrypted_key': encrypted,
120
+ 'is_active': True,
121
+ 'added_at': firestore.SERVER_TIMESTAMP
122
+ }
123
+
124
+ if community_message:
125
+ data['community_message'] = community_message
126
+
127
+ self.collection.document(doc_id).set(data)
128
+ return True
129
+ else:
130
+ # Fallback to standard save for SQLite
131
+ self.save_key(provider, api_key)
132
+ return False
133
+
134
+ def deactivate_key(self, service_name: str, api_key: str):
135
+ """
136
+ Marks an API key as inactive instead of deleting it.
137
+ This preserves the contributor's name and message history.
138
+ """
139
+ encrypted = self.security.encrypt_data(api_key)
140
+
141
+ if self.use_firebase:
142
+ from firebase_admin import firestore
143
+ # Reconstruct doc_id to find the document
144
+ doc_id = f"{service_name.lower()}_{hash(encrypted)}"
145
+ try:
146
+ self.collection.document(doc_id).update({
147
+ 'is_active': False,
148
+ 'deactivated_at': firestore.SERVER_TIMESTAMP
149
+ })
150
+ print(f"🚫 Deactivated invalid key for service: {service_name}")
151
+ except Exception as e:
152
+ print(f"⚠️ Failed to deactivate key in Firebase: {e}")
153
+ else:
154
+ conn = sqlite3.connect(self.db_path)
155
+ cursor = conn.cursor()
156
+ try:
157
+ cursor.execute('''
158
+ UPDATE api_keys
159
+ SET is_active = 0
160
+ WHERE service_name = ? AND encrypted_key = ?
161
+ ''', (service_name.lower(), encrypted))
162
+ conn.commit()
163
+ print(f"🚫 Deactivated invalid key in SQLite")
164
+ except Exception as e:
165
+ print(f"⚠️ Failed to deactivate key in SQLite: {e}")
166
+ finally:
167
+ conn.close()
168
+
169
  def get_key(self, service_name: str) -> Optional[str]:
170
  """Retrieves a valid API key (Round-Robin or Random could be implemented here)."""
171
  # For now, just get the first available active key
 
192
  return self.security.decrypt_data(row[0])
193
  return None
194
 
195
+ def get_all_keys(self, service_name: str) -> List[Dict]:
196
+ """Returns ALL valid decrypted keys with metadata for a service."""
197
+ keys_data = []
198
  if self.use_firebase:
199
  docs = self.collection.where('service', '==', service_name.lower())\
200
  .where('is_active', '==', True).stream()
201
  for doc in docs:
202
+ data = doc.to_dict()
203
+ decrypted = self.security.decrypt_data(data['encrypted_key'])
204
  if decrypted:
205
+ keys_data.append({
206
+ 'key': decrypted,
207
+ 'name': data.get('name', 'Anonymous'),
208
+ 'message': data.get('community_message', '')
209
+ })
210
  else:
211
  conn = sqlite3.connect(self.db_path)
212
  cursor = conn.cursor()
213
+ # Note: SQLite schema might need migration to support 'name' if not present,
214
+ # but we'll stick to basic key retrieval for local fallback
215
  cursor.execute('''
216
  SELECT encrypted_key FROM api_keys
217
  WHERE service_name = ? AND is_active = 1
 
221
  for row in rows:
222
  decrypted = self.security.decrypt_data(row[0])
223
  if decrypted:
224
+ keys_data.append({
225
+ 'key': decrypted,
226
+ 'name': 'Local User',
227
+ 'message': ''
228
+ })
229
+ return keys_data
core/task_queue.py CHANGED
@@ -62,11 +62,13 @@ class TaskManager:
62
  """Get the current status and result of a task."""
63
  return self.tasks.get(task_id)
64
 
65
- def update_task_progress(self, task_id: str, progress: int, message: str = ""):
66
  """Update the progress of a running task."""
67
  if task_id in self.tasks:
68
  self.tasks[task_id]["progress"] = progress
69
  self.tasks[task_id]["message"] = message
 
 
70
  logger.info(f"📈 Task {task_id} progress: {progress}% - {message}")
71
 
72
  def _worker(self):
 
62
  """Get the current status and result of a task."""
63
  return self.tasks.get(task_id)
64
 
65
+ def update_task_progress(self, task_id: str, progress: int, message: str = "", result: Any = None):
66
  """Update the progress of a running task."""
67
  if task_id in self.tasks:
68
  self.tasks[task_id]["progress"] = progress
69
  self.tasks[task_id]["message"] = message
70
+ if result is not None:
71
+ self.tasks[task_id]["result"] = result
72
  logger.info(f"📈 Task {task_id} progress: {progress}% - {message}")
73
 
74
  def _worker(self):
main.py CHANGED
@@ -1,6 +1,7 @@
1
- from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks
2
  from fastapi.responses import JSONResponse, FileResponse
3
- from typing import Optional, Union, Any
 
4
  from enum import Enum
5
  import os
6
  import uuid
@@ -13,7 +14,8 @@ from core.config import Config
13
  from core.logger import Logger
14
  from core.task_queue import TaskManager
15
  from core.database import DatabaseManager
16
- from pydantic import BaseModel
 
17
 
18
  logger = Logger.get_logger(__name__)
19
  task_manager = TaskManager()
@@ -27,76 +29,154 @@ except Exception:
27
  # Ensure directories exist
28
  Config.setup_dirs()
29
 
30
- class APIKeyInput(BaseModel):
31
- service: str
32
- key: str
33
- use_firebase: bool = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
  class VideoStyle(str, Enum):
36
- cinematic = "cinematic"
37
- cinematic_blur = "cinematic_blur"
38
- vertical_full = "vertical_full"
39
- split_vertical = "split_vertical"
40
- split_horizontal = "split_horizontal"
41
 
42
  class CaptionMode(str, Enum):
43
- word = "word"
44
- sentence = "sentence"
45
  highlight_word = "highlight_word"
46
 
47
  class CaptionStyle(str, Enum):
48
- classic = "classic"
49
- modern_glow = "modern_glow"
50
- tiktok_bold = "tiktok_bold"
51
- tiktok_neon = "tiktok_neon"
52
  youtube_clean = "youtube_clean"
53
- youtube_box = "youtube_box"
54
 
55
  class Language(str, Enum):
56
  auto = "auto"
57
- ar = "ar"
58
- en = "en"
59
- hi = "hi"
60
- zh = "zh"
61
- es = "es"
62
- fr = "fr"
63
- de = "de"
64
- ru = "ru"
65
- ja = "ja"
66
-
67
- app = FastAPI(title="Auto-Clipping API")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  clipper = VideoProcessor()
69
 
70
- @app.post("/api/keys")
71
- async def add_api_key(input_data: APIKeyInput):
72
- """
73
- Securely adds an API key to the database.
74
- - service: Service name (e.g., 'openrouter', 'openai')
75
- - key: The API key string
76
- - use_firebase: If true, saves to community database (Firebase). If false, saves to local SQLite.
77
- """
78
- try:
79
- # If user explicitly requested Firebase but it wasn't initialized globally
80
- target_db = db_manager
81
- if input_data.use_firebase and not db_manager.use_firebase:
82
- # Try to init a temporary firebase manager
83
- try:
84
- target_db = DatabaseManager(use_firebase=True)
85
- except Exception as e:
86
- return JSONResponse(
87
- status_code=400,
88
- content={"error": f"Firebase not configured: {str(e)}"}
89
- )
90
-
91
- # Save key
92
- target_db.save_key(input_data.service, input_data.key)
93
-
94
- dest = "Firebase (Community)" if input_data.use_firebase else "Local SQLite"
95
- return {"message": f"API Key for {input_data.service} saved securely to {dest}."}
96
-
97
- except Exception as e:
98
- logger.error(f"Error saving API key: {e}")
99
- return JSONResponse(status_code=500, content={"error": str(e)})
100
 
101
  def process_video_task(
102
  task_id: str,
@@ -112,44 +192,41 @@ def process_video_task(
112
  caption_mode: CaptionMode = CaptionMode.sentence,
113
  caption_style: CaptionStyle = CaptionStyle.classic
114
  ):
115
- from moviepy.editor import VideoFileClip
116
- full_video_clip = None
117
  try:
118
- # Helper for progress updates
119
  def update_progress(progress, message):
120
  task_manager.update_task_progress(task_id, progress, message)
121
-
122
  update_progress(1, "Starting video analysis...")
123
-
124
- # 1. Analyze video
125
- # Fix: Ensure 'words' mode is used for highlight_word too
126
- timestamp_mode = "words" if caption_mode in (CaptionMode.word, CaptionMode.highlight_word) else "segments"
 
 
 
 
127
  scored_segments, total_duration, llm_moments = clipper.analyze_impact(
128
- video_path,
129
- # video_clip removed as it's not supported
130
- # language passed as target_language if needed, or source?
131
- # In processor.py: source_language=None (auto), target_language=...
132
- # main.py seems to treat 'language' as the output/target language
133
  target_language=language,
134
  timestamp_mode=timestamp_mode,
135
  progress_callback=update_progress
136
  )
137
-
138
- # 2. Select best clips
139
  best_clips = clipper.get_best_segments(
140
- scored_segments,
141
  video_duration=total_duration
142
  )
143
-
144
- # 3. Final processing
145
  output_files = clipper.process_clips(
146
- video_path,
147
- best_clips,
148
  llm_moments,
149
  style=style,
150
  task_id=task_id,
151
- language=language, # target language
152
- # video_clip removed
153
  playground_path=playground_path,
154
  audio_path=audio_path,
155
  bg_music_volume=bg_music_volume,
@@ -159,7 +236,7 @@ def process_video_task(
159
  caption_style=caption_style,
160
  progress_callback=update_progress
161
  )
162
-
163
  result = {
164
  "status": "success",
165
  "task_id": task_id,
@@ -167,12 +244,13 @@ def process_video_task(
167
  "output_files": [os.path.basename(f) for f in output_files],
168
  "best_segments_info": best_clips
169
  }
170
-
171
- task_manager.update_task_progress(task_id, 100, "Completed successfully")
172
-
 
173
  except Exception as e:
174
  import traceback
175
- error_msg = f"Error during processing: {str(e)}"
176
  logger.error(error_msg)
177
  logger.error(traceback.format_exc())
178
  result = {
@@ -181,144 +259,89 @@ def process_video_task(
181
  "error": str(e),
182
  "traceback": traceback.format_exc()
183
  }
184
- finally:
185
- pass
186
 
187
- # Send webhook
188
  if webhook_url and webhook_url.strip() and webhook_url.startswith(('http://', 'https://')):
189
  try:
190
- logger.info(f"📡 Sending results to webhook: {webhook_url}")
191
- json_payload = json.dumps(result)
192
- headers = {'Content-Type': 'application/json'}
193
-
194
- response = requests.post(webhook_url, data=json_payload, headers=headers, timeout=30)
195
-
196
- logger.info(f"✅ Webhook sent. Status Code: {response.status_code}")
 
197
  if response.status_code >= 400:
198
- logger.warning(f"⚠️ Webhook Response Error: {response.text}")
199
  except Exception as webhook_err:
200
- logger.error(f"⚠️ Failed to send webhook: {webhook_err}")
201
- else:
202
- logger.info("ℹ️ No webhook URL provided, skipping webhook notification")
203
 
204
  return result
205
 
206
- @app.get("/download/{filename}")
207
- async def download_video(filename: str):
208
- """Download video from outputs folder"""
209
- file_path = os.path.join(Config.OUTPUTS_DIR, "viral_clips", filename)
210
- # Check if file exists in the specific viral_clips folder or root outputs
211
- if not os.path.exists(file_path):
212
- file_path = os.path.join(Config.OUTPUTS_DIR, filename)
213
-
214
- if os.path.exists(file_path):
215
- return FileResponse(file_path, media_type='video/mp4', filename=filename)
216
- return JSONResponse(status_code=404, content={"error": "File not found"})
217
 
218
- @app.get("/status/{task_id}")
219
- async def get_task_status(task_id: str):
220
- """Check the status of a specific task"""
221
- status_info = task_manager.get_task_status(task_id)
222
- if not status_info:
223
- return JSONResponse(status_code=404, content={"error": "Task not found"})
224
-
225
- return status_info
226
-
227
- @app.get("/files")
228
- async def list_files():
229
- """List all files in outputs folder"""
230
- try:
231
- files = []
232
- # Search in viral_clips subdirectory as well
233
- search_dirs = [Config.OUTPUTS_DIR, os.path.join(Config.OUTPUTS_DIR, "viral_clips")]
234
-
235
- for d in search_dirs:
236
- if os.path.exists(d):
237
- for filename in os.listdir(d):
238
- file_path = os.path.join(d, filename)
239
- if os.path.isfile(file_path) and filename.endswith('.mp4'):
240
- file_size = os.path.getsize(file_path)
241
- files.append({
242
- "filename": filename,
243
- "size": file_size,
244
- "size_mb": round(file_size / (1024 * 1024), 2),
245
- "download_url": f"/download/{filename}"
246
- })
247
-
248
- return {
249
- "status": "success",
250
- "total_files": len(files),
251
- "files": files
252
- }
253
- except Exception as e:
254
- return JSONResponse(status_code=500, content={"error": str(e)})
255
-
256
- @app.post("/clear")
257
- async def clear_files():
258
- """Clear all files in upload, output and temp directories"""
259
- try:
260
- count = 0
261
- for directory in [Config.UPLOADS_DIR, Config.OUTPUTS_DIR, Config.TEMP_DIR]:
262
- if os.path.exists(directory):
263
- files = glob.glob(os.path.join(directory, "**", "*"), recursive=True)
264
- for f in files:
265
- try:
266
- if os.path.isfile(f):
267
- os.remove(f)
268
- count += 1
269
- elif os.path.isdir(f) and f != directory:
270
- # Don't delete the root directories themselves, just content
271
- # But glob returns directories too.
272
- pass
273
- except Exception as e:
274
- logger.error(f"Error deleting {f}: {e}")
275
- return {"status": "success", "message": f"Cleared {count} files"}
276
- except Exception as e:
277
- return JSONResponse(status_code=500, content={"error": str(e)})
278
-
279
- @app.post("/auto-clip")
280
  async def create_auto_clip(
281
- video: UploadFile = File(...),
282
- playground_video: Optional[UploadFile] = File(None),
283
- audio: Optional[UploadFile] = File(None),
284
- background_image: Optional[UploadFile] = File(None),
285
- style: VideoStyle = Form(VideoStyle.cinematic_blur),
286
- caption_mode: CaptionMode = Form(CaptionMode.sentence),
287
- caption_style: CaptionStyle = Form(CaptionStyle.classic),
288
- webhook_url: Optional[str] = Form(None),
289
- language: Language = Form(Language.auto),
290
- bg_music_volume: float = Form(0.1),
291
- secondary_video_volume: float = Form(0.2)
292
  ):
 
 
 
 
 
 
 
293
  task_id = uuid.uuid4().hex[:8]
294
-
295
- # 1. Save main video
296
  video_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{video.filename}")
297
  with open(video_path, "wb") as f:
298
  shutil.copyfileobj(video.file, f)
299
-
300
- # 2. Save secondary video
301
  playground_path = None
302
  if playground_video and playground_video.filename and style in [VideoStyle.split_vertical, VideoStyle.split_horizontal]:
303
  playground_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{playground_video.filename}")
304
  with open(playground_path, "wb") as f:
305
  shutil.copyfileobj(playground_video.file, f)
306
-
307
- # 3. Save background image
308
  bg_image_path = None
309
  if background_image and background_image.filename:
310
  bg_image_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{background_image.filename}")
311
  with open(bg_image_path, "wb") as f:
312
  shutil.copyfileobj(background_image.file, f)
313
 
314
- # 4. Save audio file
315
  audio_path = None
316
  if audio and audio.filename:
317
  audio_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{audio.filename}")
318
  with open(audio_path, "wb") as f:
319
  shutil.copyfileobj(audio.file, f)
320
 
321
- # Add task to queue
322
  task_manager.add_task(
323
  process_video_task,
324
  task_id=task_id,
@@ -334,13 +357,201 @@ async def create_auto_clip(
334
  caption_mode=caption_mode,
335
  caption_style=caption_style
336
  )
337
-
338
  return {
339
  "status": "queued",
340
  "task_id": task_id,
341
- "message": "Task added to queue. Check status at /status/{task_id}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
  if __name__ == "__main__":
345
  import uvicorn
346
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
2
  from fastapi.responses import JSONResponse, FileResponse
3
+ from fastapi.openapi.utils import get_openapi
4
+ from typing import Optional, List
5
  from enum import Enum
6
  import os
7
  import uuid
 
14
  from core.logger import Logger
15
  from core.task_queue import TaskManager
16
  from core.database import DatabaseManager
17
+ from core.security import SecurityManager
18
+ from pydantic import BaseModel, Field
19
 
20
  logger = Logger.get_logger(__name__)
21
  task_manager = TaskManager()
 
29
  # Ensure directories exist
30
  Config.setup_dirs()
31
 
32
+ security_mgr = SecurityManager()
33
+
34
+ # ─────────────────────────────────────────────
35
+ # Pydantic Models
36
+ # ─────────────────────────────────────────────
37
+
38
+ class APIKeyCreate(BaseModel):
39
+ name: str = Field(
40
+ ...,
41
+ title="Key Name",
42
+ description="A friendly name to identify this key.",
43
+ json_schema_extra={"example": "My OpenAI Key"}
44
+ )
45
+ provider: str = Field(
46
+ ...,
47
+ title="Provider Service",
48
+ description="The name of the AI service provider (e.g., openai, anthropic).",
49
+ json_schema_extra={"example": "openai"}
50
+ )
51
+ api_key: str = Field(
52
+ ...,
53
+ title="API Key",
54
+ description="The actual API key string. It will be encrypted securely on the server.",
55
+ json_schema_extra={"example": "sk-..."}
56
+ )
57
+ community_message: Optional[str] = Field(
58
+ None,
59
+ title="Community Message",
60
+ description="Optional message to share with the community.",
61
+ json_schema_extra={"example": "Free to use for testing!"}
62
+ )
63
+
64
+ class TaskStatusResponse(BaseModel):
65
+ task_id: str
66
+ status: str
67
+ progress: Optional[int] = None
68
+ message: Optional[str] = None
69
+ result: Optional[dict] = None
70
+
71
+ class FileInfo(BaseModel):
72
+ filename: str
73
+ size: int
74
+ size_mb: float
75
+ download_url: str
76
+
77
+ class FilesListResponse(BaseModel):
78
+ status: str
79
+ total_files: int
80
+ files: List[FileInfo]
81
+
82
+ class QueuedTaskResponse(BaseModel):
83
+ status: str
84
+ task_id: str
85
+ message: str
86
+
87
+ # ─────────────────────────────────────────────
88
+ # Enums
89
+ # ─────────────────────────────────────────────
90
 
91
  class VideoStyle(str, Enum):
92
+ cinematic = "cinematic"
93
+ cinematic_blur = "cinematic_blur"
94
+ vertical_full = "vertical_full"
95
+ split_vertical = "split_vertical"
96
+ split_horizontal = "split_horizontal"
97
 
98
  class CaptionMode(str, Enum):
99
+ word = "word"
100
+ sentence = "sentence"
101
  highlight_word = "highlight_word"
102
 
103
  class CaptionStyle(str, Enum):
104
+ classic = "classic"
105
+ modern_glow = "modern_glow"
106
+ tiktok_bold = "tiktok_bold"
107
+ tiktok_neon = "tiktok_neon"
108
  youtube_clean = "youtube_clean"
109
+ youtube_box = "youtube_box"
110
 
111
  class Language(str, Enum):
112
  auto = "auto"
113
+ ar = "ar"
114
+ en = "en"
115
+ hi = "hi"
116
+ zh = "zh"
117
+ es = "es"
118
+ fr = "fr"
119
+ de = "de"
120
+ ru = "ru"
121
+ ja = "ja"
122
+
123
+ # ─────────────────────────────────────────────
124
+ # App Initialization
125
+ # ─────────────────────────────────────────────
126
+
127
+ app = FastAPI(
128
+ title="🎬 Auto-Clipping API",
129
+ description="""
130
+ ## Auto-Clipping API
131
+
132
+ Automatically extract **viral-worthy clips** from long-form videos using AI.
133
+
134
+ ### Features
135
+ - 🎯 **Smart clip detection** — AI analyzes and scores the most impactful moments
136
+ - 🎨 **Multiple video styles** — Cinematic, TikTok vertical, split-screen, and more
137
+ - 💬 **Auto captions** — Word-by-word, sentence, or highlight-word modes
138
+ - 🌍 **Multi-language support** — Auto-detect or specify the output language
139
+ - 🔔 **Webhook notifications** — Get notified when processing is done
140
+ - 🔐 **Encrypted API key storage** — Community-shared provider keys with encryption
141
+
142
+ ### Workflow
143
+ 1. Upload your video via `/auto-clip`
144
+ 2. Poll `/status/{task_id}` for progress
145
+ 3. Download results via `/download/{filename}`
146
+ """,
147
+ version="1.0.0",
148
+ contact={
149
+ "name": "Auto-Clip Support",
150
+ "url": "https://github.com/your-repo",
151
+ },
152
+ license_info={
153
+ "name": "MIT",
154
+ },
155
+ openapi_tags=[
156
+ {
157
+ "name": "Clipping",
158
+ "description": "Upload videos and manage the auto-clipping pipeline."
159
+ },
160
+ {
161
+ "name": "Tasks",
162
+ "description": "Monitor task status and progress."
163
+ },
164
+ {
165
+ "name": "Files",
166
+ "description": "List and download processed video clips."
167
+ },
168
+ {
169
+ "name": "API Keys",
170
+ "description": "Manage community-shared AI provider API keys."
171
+ },
172
+ ]
173
+ )
174
+
175
  clipper = VideoProcessor()
176
 
177
+ # ─────────────────────────────────────────────
178
+ # Background Task Function
179
+ # ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
  def process_video_task(
182
  task_id: str,
 
192
  caption_mode: CaptionMode = CaptionMode.sentence,
193
  caption_style: CaptionStyle = CaptionStyle.classic
194
  ):
195
+ result = {}
 
196
  try:
 
197
  def update_progress(progress, message):
198
  task_manager.update_task_progress(task_id, progress, message)
199
+
200
  update_progress(1, "Starting video analysis...")
201
+
202
+ # 1. Determine timestamp mode
203
+ timestamp_mode = (
204
+ "words" if caption_mode in (CaptionMode.word, CaptionMode.highlight_word)
205
+ else "segments"
206
+ )
207
+
208
+ # 2. Analyze video
209
  scored_segments, total_duration, llm_moments = clipper.analyze_impact(
210
+ video_path,
 
 
 
 
211
  target_language=language,
212
  timestamp_mode=timestamp_mode,
213
  progress_callback=update_progress
214
  )
215
+
216
+ # 3. Select best clips
217
  best_clips = clipper.get_best_segments(
218
+ scored_segments,
219
  video_duration=total_duration
220
  )
221
+
222
+ # 4. Process and export clips
223
  output_files = clipper.process_clips(
224
+ video_path,
225
+ best_clips,
226
  llm_moments,
227
  style=style,
228
  task_id=task_id,
229
+ language=language,
 
230
  playground_path=playground_path,
231
  audio_path=audio_path,
232
  bg_music_volume=bg_music_volume,
 
236
  caption_style=caption_style,
237
  progress_callback=update_progress
238
  )
239
+
240
  result = {
241
  "status": "success",
242
  "task_id": task_id,
 
244
  "output_files": [os.path.basename(f) for f in output_files],
245
  "best_segments_info": best_clips
246
  }
247
+
248
+ # FIX: Store final result in task manager so /status returns it
249
+ task_manager.update_task_progress(task_id, 100, "Completed successfully", result=result)
250
+
251
  except Exception as e:
252
  import traceback
253
+ error_msg = f"Error during processing: {str(e)}"
254
  logger.error(error_msg)
255
  logger.error(traceback.format_exc())
256
  result = {
 
259
  "error": str(e),
260
  "traceback": traceback.format_exc()
261
  }
262
+ # ✅ FIX: Store error result too
263
+ task_manager.update_task_progress(task_id, -1, error_msg, result=result)
264
 
265
+ # Send webhook notification
266
  if webhook_url and webhook_url.strip() and webhook_url.startswith(('http://', 'https://')):
267
  try:
268
+ logger.info(f"Sending results to webhook: {webhook_url}")
269
+ response = requests.post(
270
+ webhook_url,
271
+ data=json.dumps(result),
272
+ headers={'Content-Type': 'application/json'},
273
+ timeout=30
274
+ )
275
+ logger.info(f"Webhook sent. Status Code: {response.status_code}")
276
  if response.status_code >= 400:
277
+ logger.warning(f"Webhook Response Error: {response.text}")
278
  except Exception as webhook_err:
279
+ logger.error(f"Failed to send webhook: {webhook_err}")
 
 
280
 
281
  return result
282
 
283
+ # ─────────────────────────────────────────────
284
+ # Endpoints Clipping
285
+ # ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
286
 
287
+ @app.post(
288
+ "/auto-clip",
289
+ tags=["Clipping"],
290
+ response_model=QueuedTaskResponse,
291
+ summary="Upload & auto-clip a video",
292
+ responses={
293
+ 200: {"description": "Task queued successfully"},
294
+ 500: {"description": "Internal server error"},
295
+ }
296
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  async def create_auto_clip(
298
+ video: UploadFile = File(..., description="Main video file to clip (required)"),
299
+ playground_video: Optional[UploadFile] = File(None, description="Secondary video for split-screen styles"),
300
+ audio: Optional[UploadFile] = File(None, description="Background music file"),
301
+ background_image: Optional[UploadFile] = File(None, description="Background image for vertical styles"),
302
+ style: VideoStyle = Form(VideoStyle.cinematic_blur, description="Output video style"),
303
+ caption_mode: CaptionMode = Form(CaptionMode.sentence, description="Caption display mode"),
304
+ caption_style: CaptionStyle = Form(CaptionStyle.classic, description="Caption visual style"),
305
+ webhook_url: Optional[str] = Form(None, description="URL to notify when processing completes"),
306
+ language: Language = Form(Language.auto, description="Target language for captions"),
307
+ bg_music_volume: float = Form(0.1, ge=0.0, le=1.0, description="Background music volume (0.0 – 1.0)"),
308
+ secondary_video_volume: float = Form(0.2, ge=0.0, le=1.0, description="Secondary video volume (0.0 – 1.0)")
309
  ):
310
+ """
311
+ Upload a video to be automatically clipped into viral-ready short clips.
312
+
313
+ - The task runs **asynchronously** in the background.
314
+ - Use the returned `task_id` to check progress at `/status/{task_id}`.
315
+ - Download finished clips at `/download/{filename}`.
316
+ """
317
  task_id = uuid.uuid4().hex[:8]
318
+
319
+ # Save main video
320
  video_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{video.filename}")
321
  with open(video_path, "wb") as f:
322
  shutil.copyfileobj(video.file, f)
323
+
324
+ # Save secondary (playground) video — only relevant for split styles
325
  playground_path = None
326
  if playground_video and playground_video.filename and style in [VideoStyle.split_vertical, VideoStyle.split_horizontal]:
327
  playground_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{playground_video.filename}")
328
  with open(playground_path, "wb") as f:
329
  shutil.copyfileobj(playground_video.file, f)
330
+
331
+ # Save background image
332
  bg_image_path = None
333
  if background_image and background_image.filename:
334
  bg_image_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{background_image.filename}")
335
  with open(bg_image_path, "wb") as f:
336
  shutil.copyfileobj(background_image.file, f)
337
 
338
+ # Save audio
339
  audio_path = None
340
  if audio and audio.filename:
341
  audio_path = os.path.join(Config.UPLOADS_DIR, f"{task_id}_{audio.filename}")
342
  with open(audio_path, "wb") as f:
343
  shutil.copyfileobj(audio.file, f)
344
 
 
345
  task_manager.add_task(
346
  process_video_task,
347
  task_id=task_id,
 
357
  caption_mode=caption_mode,
358
  caption_style=caption_style
359
  )
360
+
361
  return {
362
  "status": "queued",
363
  "task_id": task_id,
364
+ "message": f"Task queued successfully. Track progress at /status/{task_id}"
365
+ }
366
+
367
+ # ─────────────────────────────────────────────
368
+ # Endpoints — Tasks
369
+ # ─────────────────────────────────────────────
370
+
371
+ @app.get(
372
+ "/status/{task_id}",
373
+ tags=["Tasks"],
374
+ summary="Get task status",
375
+ responses={
376
+ 200: {"description": "Task status returned"},
377
+ 404: {"description": "Task not found"},
378
+ }
379
+ )
380
+ async def get_task_status(task_id: str):
381
+ """
382
+ Poll the status and progress of a clipping task by its `task_id`.
383
+
384
+ **Progress values:**
385
+ - `1–99` → In progress
386
+ - `100` → Completed successfully
387
+ - `-1` → Failed with error
388
+ """
389
+ status_info = task_manager.get_task_status(task_id)
390
+ if not status_info:
391
+ raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found.")
392
+ return status_info
393
+
394
+ # ─────────────────────────────────────────────
395
+ # Endpoints — Files
396
+ # ─────────────────────────────────────────────
397
+
398
+ @app.get(
399
+ "/download/{filename}",
400
+ tags=["Files"],
401
+ summary="Download a processed clip",
402
+ responses={
403
+ 200: {"description": "Video file returned"},
404
+ 404: {"description": "File not found"},
405
+ }
406
+ )
407
+ async def download_video(filename: str):
408
+ """Download a processed clip by filename."""
409
+ # Check viral_clips subdirectory first, then root outputs
410
+ file_path = os.path.join(Config.OUTPUTS_DIR, "viral_clips", filename)
411
+ if not os.path.exists(file_path):
412
+ file_path = os.path.join(Config.OUTPUTS_DIR, filename)
413
+
414
+ if not os.path.exists(file_path):
415
+ raise HTTPException(status_code=404, detail=f"File '{filename}' not found.")
416
+
417
+ return FileResponse(file_path, media_type="video/mp4", filename=filename)
418
+
419
+ @app.get(
420
+ "/files",
421
+ tags=["Files"],
422
+ response_model=FilesListResponse,
423
+ summary="List all output files",
424
+ )
425
+ async def list_files():
426
+ """List all processed `.mp4` clips available for download."""
427
+ try:
428
+ files = []
429
+ search_dirs = [
430
+ Config.OUTPUTS_DIR,
431
+ os.path.join(Config.OUTPUTS_DIR, "viral_clips")
432
+ ]
433
+ seen = set()
434
+
435
+ for d in search_dirs:
436
+ if not os.path.exists(d):
437
+ continue
438
+ for filename in os.listdir(d):
439
+ if filename in seen or not filename.endswith(".mp4"):
440
+ continue
441
+ file_path = os.path.join(d, filename)
442
+ if os.path.isfile(file_path):
443
+ size = os.path.getsize(file_path)
444
+ files.append({
445
+ "filename": filename,
446
+ "size": size,
447
+ "size_mb": round(size / (1024 * 1024), 2),
448
+ "download_url": f"/download/{filename}"
449
+ })
450
+ seen.add(filename)
451
+
452
+ return {
453
+ "status": "success",
454
+ "total_files": len(files),
455
+ "files": files
456
+ }
457
+ except Exception as e:
458
+ raise HTTPException(status_code=500, detail=str(e))
459
+
460
+ @app.post(
461
+ "/clear",
462
+ tags=["Files"],
463
+ summary="Clear all uploaded and output files",
464
+ responses={200: {"description": "Files cleared"}, 500: {"description": "Error during cleanup"}}
465
+ )
466
+ async def clear_files():
467
+ """
468
+ ⚠️ **Danger zone** — deletes all files in upload, output, and temp directories.
469
+ This action is irreversible.
470
+ """
471
+ try:
472
+ count = 0
473
+ for directory in [Config.UPLOADS_DIR, Config.OUTPUTS_DIR, Config.TEMP_DIR]:
474
+ if not os.path.exists(directory):
475
+ continue
476
+ # ✅ FIX: Sort so files are deleted before their parent directories
477
+ all_paths = sorted(
478
+ glob.glob(os.path.join(directory, "**", "*"), recursive=True),
479
+ reverse=True
480
+ )
481
+ for path in all_paths:
482
+ try:
483
+ if os.path.isfile(path):
484
+ os.remove(path)
485
+ count += 1
486
+ elif os.path.isdir(path) and path != directory:
487
+ # Only remove truly empty sub-directories
488
+ if not os.listdir(path):
489
+ os.rmdir(path)
490
+ except Exception as e:
491
+ logger.error(f"Error deleting {path}: {e}")
492
+
493
+ return {"status": "success", "message": f"Cleared {count} files"}
494
+ except Exception as e:
495
+ raise HTTPException(status_code=500, detail=str(e))
496
+
497
+ # ─────────────────────────────────────────────
498
+ # Endpoints — API Keys
499
+ # ─────────��───────────────────────────────────
500
+
501
+ @app.post(
502
+ "/api/key",
503
+ tags=["API Keys"],
504
+ summary="Add a community API key",
505
+ responses={
506
+ 200: {"description": "Key saved successfully"},
507
+ 400: {"description": "Firebase not configured"},
508
+ 500: {"description": "Internal error"},
509
  }
510
+ )
511
+ async def add_api_key(
512
+ name: str = Form(..., description="A friendly name to identify this key."),
513
+ provider: str = Form(..., description="The AI service provider (e.g., openai)."),
514
+ api_key: str = Form(..., description="The actual API key string."),
515
+ community_message: Optional[str] = Form(None, description="Optional message to share.")
516
+ ):
517
+ """
518
+ **Add a new API Key to the Community Database.**
519
+
520
+ The key is **encrypted immediately** by the server before being stored in Firebase.
521
+ It is never stored in plain text.
522
+ """
523
+ try:
524
+ target_db = db_manager
525
+ if not db_manager.use_firebase:
526
+ try:
527
+ target_db = DatabaseManager(use_firebase=True)
528
+ except Exception as e:
529
+ raise HTTPException(
530
+ status_code=400,
531
+ detail=f"Firebase not configured: {str(e)}"
532
+ )
533
+
534
+ target_db.save_key_with_meta(
535
+ name=name,
536
+ provider=provider,
537
+ api_key=api_key,
538
+ community_message=community_message
539
+ )
540
+
541
+ return {
542
+ "message": f"API key for provider '{provider}' saved securely to Firebase."
543
+ }
544
+
545
+ except HTTPException:
546
+ raise
547
+ except Exception as e:
548
+ logger.error(f"Error in /api/key: {str(e)}")
549
+ raise HTTPException(status_code=500, detail=str(e))
550
+
551
+ # ─────────────────────────────────────────────
552
+ # Entry Point
553
+ # ─────────────────────────────────────────────
554
 
555
  if __name__ == "__main__":
556
  import uvicorn
557
+ uvicorn.run(app, host="0.0.0.0", port=7860)