diff --git a/backend/api/assets_serving.py b/backend/api/assets_serving.py index 527b16e3..958e3b1b 100644 --- a/backend/api/assets_serving.py +++ b/backend/api/assets_serving.py @@ -100,9 +100,5 @@ async def serve_voice_sample( media_type = _get_media_type(safe_filename) file_size = file_path.stat().st_size - first_bytes_hex = "N/A" - if file_size > 0: - with open(file_path, 'rb') as f: - first_bytes_hex = f.read(16).hex() - logger.warning(f"[Assets] Serving voice sample: {safe_filename} ({media_type}, {file_size} bytes, first_16hex: {first_bytes_hex})") + logger.warning(f"[Assets] Serving voice sample: {safe_filename} ({media_type}, {file_size} bytes)") return FileResponse(file_path, media_type=media_type) \ No newline at end of file diff --git a/backend/api/onboarding_utils/step4_asset_routes.py b/backend/api/onboarding_utils/step4_asset_routes.py index e75c25df..fe76389c 100644 --- a/backend/api/onboarding_utils/step4_asset_routes.py +++ b/backend/api/onboarding_utils/step4_asset_routes.py @@ -541,44 +541,30 @@ async def create_voice_clone( preview_mime_type = "audio/wav" actual_filename = None # Default if preview save fails - logger.warning(f"[VoiceClone] qwen3 result preview_audio_bytes type: {type(preview_audio_bytes)}, length: {len(preview_audio_bytes) if preview_audio_bytes else 0}") - if preview_audio_bytes and len(preview_audio_bytes) > 0: from utils.media_utils import detect_audio_format, ensure_audio_extension - # Log first few bytes for debugging - first_bytes = preview_audio_bytes[:16].hex() - logger.warning(f"[VoiceClone] Preview audio first 16 bytes (hex): {first_bytes}") - detected_fmt, preview_mime_type = detect_audio_format(preview_audio_bytes) logger.warning(f"[VoiceClone] Detected preview audio format: {detected_fmt} ({preview_mime_type}), {len(preview_audio_bytes)} bytes") # Build filename with correct extension based on actual content format original_stem = Path(filename).stem preview_filename = f"preview_{original_stem}" - logger.warning(f"[VoiceClone] Original filename stem: {original_stem}, preview before: {preview_filename}") - preview_filename = ensure_audio_extension(preview_filename, preview_audio_bytes) - logger.warning(f"[VoiceClone] Preview filename (corrected ext): {preview_filename}") user_voice_dir = get_user_workspace(user_id) / "assets" / "voice_samples" - logger.warning(f"[VoiceClone] user_id: {user_id}") - logger.warning(f"[VoiceClone] user_voice_dir: {user_voice_dir}") - logger.warning(f"[VoiceClone] directory exists: {user_voice_dir.exists()}") saved_preview_path, error = save_file_safely(preview_audio_bytes, user_voice_dir, preview_filename) if not error and saved_preview_path: # Use actual saved filename (may have UUID suffix added by save_file_safely) actual_filename = saved_preview_path.name preview_url = f"/api/assets/{user_id}/voice_samples/{actual_filename}" - logger.warning(f"[VoiceClone] Saved preview audio to: {saved_preview_path}") + logger.warning(f"[VoiceClone] Saved preview: {actual_filename} ({saved_preview_path.stat().st_size} bytes, {preview_mime_type})") # Verify file exists if not saved_preview_path.exists(): logger.warning(f"[VoiceClone] Preview file does not exist after save: {saved_preview_path}") preview_url = None - else: - logger.warning(f"[VoiceClone] Preview file size: {saved_preview_path.stat().st_size} bytes") else: logger.warning(f"[VoiceClone] Failed to save preview audio: {error}") @@ -586,7 +572,6 @@ async def create_voice_clone( # Use the preview file (with corrected .wav extension) as the main asset file has_valid_preview = preview_audio_bytes and len(preview_audio_bytes) > 0 and saved_preview_path stored_filename = actual_filename if has_valid_preview else filename - logger.warning(f"[VoiceClone] stored_filename: {stored_filename}, has_valid_preview: {has_valid_preview}") asset_id = save_asset_to_library( db=db, user_id=user_id, @@ -606,9 +591,6 @@ async def create_voice_clone( } ) - logger.warning(f"[VoiceClone] Response preview_url: {preview_url}") - logger.warning(f"[VoiceClone] Response stored_filename: {stored_filename}") - return { "success": True, "custom_voice_id": custom_voice_id, diff --git a/backend/services/subscription/limit_validation.py b/backend/services/subscription/limit_validation.py index b2a822d7..378bb2ed 100644 --- a/backend/services/subscription/limit_validation.py +++ b/backend/services/subscription/limit_validation.py @@ -19,6 +19,15 @@ if TYPE_CHECKING: from .pricing_service import PricingService +def _should_enforce_limit(limit_value: int, tier: str) -> bool: + """ + Determine if a limit should be enforced. + - Free tier: 0 means DISABLED (not unlimited) + - Basic/Pro/Enterprise: 0 means UNLIMITED + """ + return limit_value > 0 + + class LimitValidator: """Validates subscription limits for API usage.""" @@ -107,20 +116,6 @@ class LimitValidator: } return result - # Helper: Check if a limit should be enforced based on tier - def should_enforce_limit(limit_value: int, tier: str) -> bool: - """ - Determine if a limit should be enforced. - - Free tier: 0 means DISABLED (not unlimited) - - Basic/Pro/Enterprise: 0 means UNLIMITED - """ - if tier == 'free': - # Free tier: 0 means disabled - return limit_value > 0 - else: - # Basic/Pro/Enterprise: 0 means unlimited - return limit_value > 0 - # Get user limits with error handling (STRICT: fail on errors) # CRITICAL: Expire SQLAlchemy objects to ensure we get fresh plan data after renewal try: @@ -263,7 +258,7 @@ class LimitValidator: ) # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(ai_text_gen_limit, user_tier) and current_total_llm_calls >= ai_text_gen_limit: + if _should_enforce_limit(ai_text_gen_limit, user_tier) and current_total_llm_calls >= ai_text_gen_limit: logger.error(f"[Subscription Check] AI text generation call limit exceeded for user {user_id}: {current_total_llm_calls}/{ai_text_gen_limit} (provider: {display_provider_name})") result = (False, f"AI text generation call limit reached. Used {current_total_llm_calls} of {ai_text_gen_limit} total AI text generation calls this billing period.", { 'current_calls': current_total_llm_calls, @@ -296,7 +291,7 @@ class LimitValidator: call_limit = limits['limits'].get(f"{provider_name}_calls", 0) or 0 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(call_limit, user_tier) and current_calls >= call_limit: + if _should_enforce_limit(call_limit, user_tier) and current_calls >= call_limit: logger.error(f"[Subscription Check] Call limit exceeded for user {user_id}, provider {display_provider_name}: {current_calls}/{call_limit}") result = (False, f"API call limit reached for {display_provider_name}. Used {current_calls} of {call_limit} calls this billing period.", { 'current_calls': current_calls, @@ -329,7 +324,7 @@ class LimitValidator: token_limit = limits['limits'].get(f"{provider_name}_tokens", 0) or 0 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(token_limit, user_tier) and (current_tokens + tokens_requested) > token_limit: + if _should_enforce_limit(token_limit, user_tier) and (current_tokens + tokens_requested) > token_limit: result = (False, f"Token limit would be exceeded for {display_provider_name}. Current: {current_tokens}, Requested: {tokens_requested}, Limit: {token_limit}", { 'current_tokens': current_tokens, 'requested_tokens': tokens_requested, @@ -363,7 +358,7 @@ class LimitValidator: try: cost_limit = limits['limits'].get('monthly_cost', 0) or 0 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(cost_limit, user_tier) and usage.total_cost >= cost_limit: + if _should_enforce_limit(cost_limit, user_tier) and usage.total_cost >= cost_limit: result = (False, f"Monthly cost limit reached. Current cost: ${usage.total_cost:.2f}, Limit: ${cost_limit:.2f}", { 'current_cost': usage.total_cost, 'limit': cost_limit, @@ -583,7 +578,7 @@ class LimitValidator: projected_total_llm_calls = total_llm_calls + 1 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(ai_text_gen_limit, tier) and projected_total_llm_calls > ai_text_gen_limit: + if _should_enforce_limit(ai_text_gen_limit, tier) and projected_total_llm_calls > ai_text_gen_limit: error_info = { 'current_calls': total_llm_calls, 'limit': ai_text_gen_limit, @@ -691,7 +686,7 @@ class LimitValidator: token_limit = limits.get(provider_tokens_key, 0) or 0 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(token_limit, tier) and tokens_requested > 0: + if _should_enforce_limit(token_limit, tier) and tokens_requested > 0: projected_tokens = current_provider_tokens + tokens_requested logger.info(f" └─ Token Check: {current_provider_tokens} (current) + {tokens_requested} (requested) = {projected_tokens} (total) / {token_limit} (limit)") @@ -754,7 +749,7 @@ class LimitValidator: projected_images = total_images + 1 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(image_limit, tier) and projected_images > image_limit: + if _should_enforce_limit(image_limit, tier) and projected_images > image_limit: error_info = { 'current_images': total_images, 'limit': image_limit, @@ -776,7 +771,7 @@ class LimitValidator: projected_video_calls = total_video_calls + 1 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(video_limit, tier) and projected_video_calls > video_limit: + if _should_enforce_limit(video_limit, tier) and projected_video_calls > video_limit: error_info = { 'current_calls': total_video_calls, 'limit': video_limit, @@ -796,7 +791,7 @@ class LimitValidator: projected_image_edit_calls = total_image_edit_calls + 1 # Enforce limit based on tier (Free: 0=disabled, others: 0=unlimited) - if should_enforce_limit(image_edit_limit, tier) and projected_image_edit_calls > image_edit_limit: + if _should_enforce_limit(image_edit_limit, tier) and projected_image_edit_calls > image_edit_limit: error_info = { 'current_calls': total_image_edit_calls, 'limit': image_edit_limit, @@ -833,7 +828,7 @@ class LimitValidator: # Check WaveSpeed combined limit if actual_provider is WaveSpeed if actual_provider_name == 'wavespeed': wavespeed_limit = limits.get('wavespeed_calls', 0) or 0 - if should_enforce_limit(wavespeed_limit, tier): + if _should_enforce_limit(wavespeed_limit, tier): wavespeed_usage = usage.wavespeed_calls or 0 projected_wavespeed = wavespeed_usage + 1 if projected_wavespeed > wavespeed_limit: diff --git a/backend/services/subscription/usage_tracking_service.py b/backend/services/subscription/usage_tracking_service.py index f79b53fc..2c2265f5 100644 --- a/backend/services/subscription/usage_tracking_service.py +++ b/backend/services/subscription/usage_tracking_service.py @@ -45,12 +45,12 @@ class UsageTrackingService: self._enforce_cache: Dict[str, Dict[str, Any]] = {} def _get_authoritative_billing_period_keys(self, user_id: str, billing_period: Optional[str] = None) -> Dict[str, Any]: - """Return authoritative billing period lookup keys anchored to subscription period boundaries.""" + """Return authoritative billing period lookup keys. Always uses calendar month for consistency.""" subscription = self.db.query(UserSubscription).filter( UserSubscription.user_id == user_id ).first() - # If caller explicitly requested a billing period, keep it authoritative for that read. + # If caller explicitly requested a billing period, use it if billing_period: return { "billing_period": billing_period, @@ -59,23 +59,15 @@ class UsageTrackingService: "period_end": subscription.current_period_end if subscription else None, } - if subscription and subscription.current_period_start and subscription.current_period_end: - start_key = subscription.current_period_start.strftime("%Y-%m") - end_key = subscription.current_period_end.strftime("%Y-%m") - lookup_periods = [start_key] if start_key == end_key else [start_key, end_key] - return { - "billing_period": start_key, - "lookup_periods": lookup_periods, - "period_start": subscription.current_period_start, - "period_end": subscription.current_period_end, - } - - resolved_period = self.pricing_service.get_current_billing_period(user_id) or datetime.now().strftime("%Y-%m") + # ALWAYS use current calendar month for billing period to ensure consistency + # This prevents data loss when subscription spans month boundaries + current_period = datetime.now().strftime("%Y-%m") + return { - "billing_period": resolved_period, - "lookup_periods": [resolved_period], - "period_start": None, - "period_end": None, + "billing_period": current_period, + "lookup_periods": [current_period], + "period_start": subscription.current_period_start if subscription else None, + "period_end": subscription.current_period_end if subscription else None, } async def track_api_usage(self, user_id: str, provider: APIProvider, @@ -207,11 +199,14 @@ class UsageTrackingService: ).first() if not summary: + logger.info(f"[UsageTracking] Creating new UsageSummary for user={user_id}, period={period_keys['billing_period']}") summary = UsageSummary( user_id=user_id, billing_period=period_keys["billing_period"] ) self.db.add(summary) + else: + logger.debug(f"[UsageTracking] Found existing UsageSummary for user={user_id}, period={summary.billing_period}, calls={summary.total_calls}") # Update provider-specific counters provider_name = provider.value @@ -384,12 +379,19 @@ class UsageTrackingService: period_keys = self._get_authoritative_billing_period_keys(user_id, requested_billing_period) billing_period = period_keys["billing_period"] + logger.debug(f"[get_user_usage_stats] user={user_id}, billing_period={billing_period}, lookup_periods={period_keys['lookup_periods']}") + # Get usage summary summary = self.db.query(UsageSummary).filter( UsageSummary.user_id == user_id, UsageSummary.billing_period.in_(period_keys["lookup_periods"]) ).first() + if summary: + logger.debug(f"[get_user_usage_stats] Found summary: period={summary.billing_period}, calls={summary.total_calls}, cost={summary.total_cost}") + else: + logger.debug(f"[get_user_usage_stats] No summary found for user={user_id}, period={billing_period}") + # Get user limits limits = self.pricing_service.get_user_limits(user_id) diff --git a/backend/services/wavespeed/generators/speech.py b/backend/services/wavespeed/generators/speech.py index 20bbbf15..cac5a5c4 100644 --- a/backend/services/wavespeed/generators/speech.py +++ b/backend/services/wavespeed/generators/speech.py @@ -482,7 +482,7 @@ class SpeechGenerator: audio_url = self._extract_audio_url(outputs) downloaded_audio = self._download_audio(audio_url, timeout) - logger.warning(f"[WaveSpeed] qwen3_voice_clone downloaded {len(downloaded_audio)} bytes, first_16hex: {downloaded_audio[:16].hex()}") + logger.warning(f"[WaveSpeed] qwen3_voice_clone downloaded {len(downloaded_audio)} bytes") return downloaded_audio def cosyvoice_voice_clone( diff --git a/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx b/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx index 2aea6ca0..9614a343 100644 --- a/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx +++ b/frontend/src/components/PodcastMaker/ui/PrimaryButton.tsx @@ -63,7 +63,7 @@ export const PrimaryButton = React.forwardRef - {button} + {button} ) : ( button diff --git a/frontend/src/components/shared/VoiceSelector.tsx b/frontend/src/components/shared/VoiceSelector.tsx index 4c4d8af6..8d412905 100644 --- a/frontend/src/components/shared/VoiceSelector.tsx +++ b/frontend/src/components/shared/VoiceSelector.tsx @@ -16,9 +16,6 @@ import { ListItemIcon, ListItemText, Collapse, - FormControlLabel, - Checkbox, - Divider, Dialog, DialogTitle, DialogContent, @@ -153,7 +150,6 @@ export const VoiceSelector: React.FC = ({ const [playingPreview, setPlayingPreview] = useState(null); const [showVoiceClonePanel, setShowVoiceClonePanel] = useState(false); const [voiceCreated, setVoiceCreated] = useState(false); - const [useCreatedVoice, setUseCreatedVoice] = useState(true); const [redoingClone, setRedoingClone] = useState(false); const [selectOpen, setSelectOpen] = useState(false); const [tuneModalOpen, setTuneModalOpen] = useState(false); @@ -326,7 +322,6 @@ export const VoiceSelector: React.FC = ({ const handleVoiceSet = useCallback(() => { setVoiceCreated(true); - setUseCreatedVoice(true); }, []); const handleRedoClone = useCallback(() => { @@ -335,18 +330,15 @@ export const VoiceSelector: React.FC = ({ setRedoingClone(true); setShowVoiceClonePanel(true); setVoiceCreated(false); - setUseCreatedVoice(true); }, 150); }, []); const handleDoneWithVoice = useCallback(() => { - if (useCreatedVoice) { - fetchVoiceClone(); - } + fetchVoiceClone(); setShowVoiceClonePanel(false); setVoiceCreated(false); setRedoingClone(false); - }, [useCreatedVoice]); + }, []); const handleCancelRedo = useCallback(() => { setShowVoiceClonePanel(false); @@ -362,7 +354,6 @@ export const VoiceSelector: React.FC = ({ } else { setShowVoiceClonePanel(true); setVoiceCreated(false); - setUseCreatedVoice(true); setRedoingClone(false); } }, [showVoiceClonePanel]); @@ -1056,7 +1047,7 @@ export const VoiceSelector: React.FC = ({ border: "1px solid rgba(16, 185, 129, 0.3)", }} > - + {redoingClone ? "Voice Clone Updated!" : "Voice Clone Created Successfully!"} @@ -1064,29 +1055,9 @@ export const VoiceSelector: React.FC = ({ - {redoingClone ? "Your voice clone has been updated." : "Your custom voice clone is ready. Would you like to use this voice for your podcast?"} + {redoingClone ? "Your voice clone has been updated and will be used for your podcast." : "Your custom voice clone is ready and will be used for your podcast."} - setUseCreatedVoice(e.target.checked)} - sx={{ - color: "#10b981", - "&.Mui-checked": { color: "#10b981" }, - }} - /> - } - label={ - - Use this voice for my podcast - - } - /> - - -