Features: - Video download from TikTok/Douyin using yt-dlp - Audio transcription with OpenAI Whisper - GPT-4 translation (direct/summarize/rewrite modes) - Subtitle generation with ASS format - Video trimming with frame-accurate preview - BGM integration with volume control - Intro text overlay support - Thumbnail generation with text overlay Tech stack: - Backend: FastAPI, Python 3.11+ - Frontend: React, Vite, TailwindCSS - Video processing: FFmpeg - AI: OpenAI Whisper, GPT-4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
496 lines
16 KiB
Python
496 lines
16 KiB
Python
"""
|
|
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
|