Initial commit: YouTube Shorts maker application
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>
This commit is contained in:
495
backend/app/services/bgm_provider.py
Normal file
495
backend/app/services/bgm_provider.py
Normal file
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user