""" BGM Provider Service - Freesound & Pixabay Integration Freesound API: https://freesound.org/docs/api/ - 500,000+ Creative Commons licensed sounds - Free API with generous rate limits - Various licenses (CC0, CC-BY, CC-BY-NC, etc.) Pixabay: Manual download recommended (no public Music API) """ import os import httpx import aiofiles from typing import Optional, List, Tuple from pydantic import BaseModel from app.config import settings class FreesoundTrack(BaseModel): """Freesound track model.""" id: int name: str duration: float # seconds tags: List[str] license: str username: str preview_url: str # HQ preview (128kbps mp3) download_url: str # Original file (requires auth) description: str = "" class BGMSearchResult(BaseModel): """BGM search result.""" id: str title: str duration: int tags: List[str] preview_url: str download_url: str = "" license: str = "" source: str = "freesound" # Freesound license filters for commercial use # CC0 and CC-BY are commercially usable, CC-BY-NC is NOT COMMERCIAL_LICENSES = [ "Creative Commons 0", # CC0 - Public Domain "Attribution", # CC-BY - Attribution required "Attribution Noncommercial", # Exclude this (NOT commercial) ] # License filter string for commercial-only search COMMERCIAL_LICENSE_FILTER = 'license:"Creative Commons 0" OR license:"Attribution"' async def search_freesound( query: str, min_duration: int = 10, max_duration: int = 180, # Shorts typically < 60s, allow some buffer page: int = 1, page_size: int = 15, filter_music: bool = True, commercial_only: bool = True, # Default: only commercially usable ) -> Tuple[bool, str, List[BGMSearchResult]]: """ Search for sounds on Freesound API. Args: query: Search keywords (e.g., "upbeat music", "chill background") min_duration: Minimum duration in seconds max_duration: Maximum duration in seconds page: Page number (1-indexed) page_size: Results per page (max 150) filter_music: Add "music" to query for better BGM results commercial_only: Only return commercially usable licenses (CC0, CC-BY) Returns: Tuple of (success, message, results) """ api_key = settings.FREESOUND_API_KEY if not api_key: return False, "Freesound API key not configured. Get one at https://freesound.org/apiv2/apply", [] # Add "music" filter for better BGM results search_query = f"{query} music" if filter_music and "music" not in query.lower() else query # Build filter string for duration and license filter_parts = [f"duration:[{min_duration} TO {max_duration}]"] if commercial_only: # Filter for commercially usable licenses only # CC0 (Creative Commons 0) and CC-BY (Attribution) are commercial-OK # Exclude CC-BY-NC (Noncommercial) filter_parts.append('license:"Creative Commons 0"') filter_str = " ".join(filter_parts) params = { "token": api_key, "query": search_query, "filter": filter_str, "page": page, "page_size": min(page_size, 150), "fields": "id,name,duration,tags,license,username,previews,description", "sort": "score", # relevance } try: async with httpx.AsyncClient() as client: response = await client.get( "https://freesound.org/apiv2/search/text/", params=params, timeout=30, ) if response.status_code == 401: return False, "Invalid Freesound API key", [] if response.status_code != 200: return False, f"Freesound API error: HTTP {response.status_code}", [] data = response.json() results = [] for sound in data.get("results", []): # Get preview URLs (prefer high quality) previews = sound.get("previews", {}) preview_url = ( previews.get("preview-hq-mp3") or previews.get("preview-lq-mp3") or "" ) # Parse license for display license_url = sound.get("license", "") license_name = _parse_freesound_license(license_url) results.append(BGMSearchResult( id=str(sound["id"]), title=sound.get("name", "Unknown"), duration=int(sound.get("duration", 0)), tags=sound.get("tags", [])[:10], # Limit tags preview_url=preview_url, download_url=f"https://freesound.org/apiv2/sounds/{sound['id']}/download/", license=license_name, source="freesound", )) total = data.get("count", 0) license_info = " (commercial use OK)" if commercial_only else "" message = f"Found {total} sounds on Freesound{license_info}" return True, message, results except httpx.TimeoutException: return False, "Freesound API timeout", [] except Exception as e: return False, f"Freesound search error: {str(e)}", [] def _parse_freesound_license(license_url: str) -> str: """Parse Freesound license URL to human-readable name.""" if "zero" in license_url or "cc0" in license_url.lower(): return "CC0 (Public Domain)" elif "by-nc" in license_url: return "CC BY-NC (Non-Commercial)" elif "by-sa" in license_url: return "CC BY-SA (Share Alike)" elif "by/" in license_url: return "CC BY (Attribution)" elif "sampling+" in license_url: return "Sampling+" else: return "See License" async def download_freesound( sound_id: str, output_dir: str, filename: str, ) -> Tuple[bool, str, Optional[str]]: """ Download a sound from Freesound. Note: Freesound requires OAuth for original file downloads. This function downloads the HQ preview (128kbps MP3) which is sufficient for BGM. Args: sound_id: Freesound sound ID output_dir: Directory to save file filename: Output filename (without extension) Returns: Tuple of (success, message, file_path) """ api_key = settings.FREESOUND_API_KEY if not api_key: return False, "Freesound API key not configured", None try: async with httpx.AsyncClient() as client: # First, get sound info to get preview URL info_response = await client.get( f"https://freesound.org/apiv2/sounds/{sound_id}/", params={ "token": api_key, "fields": "id,name,previews,license,username", }, timeout=30, ) if info_response.status_code != 200: return False, f"Failed to get sound info: HTTP {info_response.status_code}", None sound_data = info_response.json() previews = sound_data.get("previews", {}) # Get high quality preview URL preview_url = previews.get("preview-hq-mp3") if not preview_url: preview_url = previews.get("preview-lq-mp3") if not preview_url: return False, "No preview URL available", None # Download the preview audio_response = await client.get(preview_url, timeout=60, follow_redirects=True) if audio_response.status_code != 200: return False, f"Download failed: HTTP {audio_response.status_code}", None # Save file os.makedirs(output_dir, exist_ok=True) file_path = os.path.join(output_dir, f"{filename}.mp3") async with aiofiles.open(file_path, 'wb') as f: await f.write(audio_response.content) # Get attribution info username = sound_data.get("username", "Unknown") license_name = _parse_freesound_license(sound_data.get("license", "")) return True, f"Downloaded from Freesound (by {username}, {license_name})", file_path except httpx.TimeoutException: return False, "Download timeout", None except Exception as e: return False, f"Download error: {str(e)}", None async def search_and_download_bgm( keywords: List[str], output_dir: str, max_duration: int = 120, commercial_only: bool = True, ) -> Tuple[bool, str, Optional[str], Optional[BGMSearchResult]]: """ Search for BGM and download the best match. Args: keywords: Search keywords from BGM recommendation output_dir: Directory to save downloaded file max_duration: Maximum duration in seconds commercial_only: Only search commercially usable licenses (CC0) Returns: Tuple of (success, message, file_path, matched_result) """ if not settings.FREESOUND_API_KEY: return False, "Freesound API key not configured", None, None # Try searching with combined keywords query = " ".join(keywords[:3]) success, message, results = await search_freesound( query=query, min_duration=15, max_duration=max_duration, page_size=10, commercial_only=commercial_only, ) if not success or not results: # Try with individual keywords for keyword in keywords[:3]: success, message, results = await search_freesound( query=keyword, min_duration=15, max_duration=max_duration, page_size=5, commercial_only=commercial_only, ) if success and results: break if not results: return False, "No matching BGM found on Freesound", None, None # Select the best result (first one, sorted by relevance) best_match = results[0] # Download it safe_filename = best_match.title.lower().replace(" ", "_")[:50] safe_filename = "".join(c for c in safe_filename if c.isalnum() or c == "_") success, download_msg, file_path = await download_freesound( sound_id=best_match.id, output_dir=output_dir, filename=safe_filename, ) if not success: return False, download_msg, None, best_match return True, download_msg, file_path, best_match async def search_pixabay_music( query: str = "", category: str = "", min_duration: int = 0, max_duration: int = 120, page: int = 1, per_page: int = 20, ) -> Tuple[bool, str, List[BGMSearchResult]]: """ Search for royalty-free music on Pixabay. Note: Pixabay doesn't have a public Music API, returns curated list instead. """ # Pixabay's music API is not publicly available # Return curated recommendations instead return await _get_curated_bgm_list(query) async def _get_curated_bgm_list(query: str = "") -> Tuple[bool, str, List[BGMSearchResult]]: """ Return curated list of recommended free BGM sources. Since Pixabay Music API requires special access, we provide curated recommendations. """ # Curated BGM recommendations (these are categories/suggestions, not actual files) curated_bgm = [ { "id": "upbeat_energetic", "title": "Upbeat & Energetic", "duration": 60, "tags": ["upbeat", "energetic", "happy", "positive"], "description": "활기찬 쇼츠에 적합", }, { "id": "chill_lofi", "title": "Chill Lo-Fi", "duration": 60, "tags": ["chill", "lofi", "relaxing", "calm"], "description": "편안한 분위기의 콘텐츠", }, { "id": "epic_cinematic", "title": "Epic & Cinematic", "duration": 60, "tags": ["epic", "cinematic", "dramatic", "intense"], "description": "드라마틱한 순간", }, { "id": "funny_quirky", "title": "Funny & Quirky", "duration": 30, "tags": ["funny", "quirky", "comedy", "playful"], "description": "유머러스한 콘텐츠", }, { "id": "corporate_tech", "title": "Corporate & Tech", "duration": 60, "tags": ["corporate", "tech", "modern", "professional"], "description": "정보성 콘텐츠", }, ] # Filter by query if provided if query: query_lower = query.lower() filtered = [ bgm for bgm in curated_bgm if query_lower in bgm["title"].lower() or any(query_lower in tag for tag in bgm["tags"]) ] curated_bgm = filtered if filtered else curated_bgm results = [ BGMSearchResult( id=bgm["id"], title=bgm["title"], duration=bgm["duration"], tags=bgm["tags"], preview_url="", # Would be filled with actual URL source="curated", ) for bgm in curated_bgm ] return True, "Curated BGM list", results async def download_from_url( url: str, output_path: str, filename: str, ) -> Tuple[bool, str, Optional[str]]: """ Download audio file from URL. Args: url: Audio file URL output_path: Directory to save file filename: Output filename (without extension) Returns: Tuple of (success, message, file_path) """ try: os.makedirs(output_path, exist_ok=True) async with httpx.AsyncClient() as client: response = await client.get(url, timeout=60, follow_redirects=True) if response.status_code != 200: return False, f"Download failed: HTTP {response.status_code}", None # Determine file extension from content-type content_type = response.headers.get("content-type", "") if "mpeg" in content_type: ext = ".mp3" elif "wav" in content_type: ext = ".wav" elif "ogg" in content_type: ext = ".ogg" else: ext = ".mp3" # Default to mp3 file_path = os.path.join(output_path, f"{filename}{ext}") with open(file_path, "wb") as f: f.write(response.content) return True, "Download complete", file_path except Exception as e: return False, f"Download error: {str(e)}", None # Popular free BGM download links FREE_BGM_SOURCES = { "freesound": { "name": "Freesound", "url": "https://freesound.org/", "license": "CC0/CC-BY/CC-BY-NC (Various)", "description": "500,000+ CC licensed sounds, API available", "api_available": True, "api_url": "https://freesound.org/apiv2/apply", }, "pixabay": { "name": "Pixabay Music", "url": "https://pixabay.com/music/", "license": "Pixabay License (Free for commercial use)", "description": "Large collection of royalty-free music", "api_available": False, }, "mixkit": { "name": "Mixkit", "url": "https://mixkit.co/free-stock-music/", "license": "Mixkit License (Free for commercial use)", "description": "High-quality free music tracks", "api_available": False, }, "uppbeat": { "name": "Uppbeat", "url": "https://uppbeat.io/", "license": "Free tier: 10 tracks/month", "description": "YouTube-friendly music", "api_available": False, }, "youtube_audio_library": { "name": "YouTube Audio Library", "url": "https://studio.youtube.com/channel/UC/music", "license": "Free for YouTube videos", "description": "Google's free music library", "api_available": False, }, } def get_free_bgm_sources() -> dict: """Get list of recommended free BGM sources.""" return FREE_BGM_SOURCES