Files
bini-shorts-maker/backend/app/routers/bgm.py
kihong.kim c3795138da 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>
2026-01-03 21:38:34 +09:00

579 lines
18 KiB
Python

import os
import aiofiles
import httpx
from typing import List
from fastapi import APIRouter, UploadFile, File, HTTPException
from pydantic import BaseModel
from app.models.schemas import BGMInfo, BGMUploadResponse, TranscriptSegment
from app.services.video_processor import get_audio_duration
from app.services.bgm_provider import (
get_free_bgm_sources,
search_freesound,
download_freesound,
search_and_download_bgm,
BGMSearchResult,
)
from app.services.bgm_recommender import (
recommend_bgm_for_script,
get_preset_recommendation,
BGMRecommendation,
BGM_PRESETS,
)
from app.services.default_bgm import (
initialize_default_bgm,
get_default_bgm_list,
check_default_bgm_status,
DEFAULT_BGM_TRACKS,
)
from app.config import settings
router = APIRouter()
class BGMDownloadRequest(BaseModel):
"""Request to download BGM from URL."""
url: str
name: str
class BGMRecommendRequest(BaseModel):
"""Request for BGM recommendation based on script."""
segments: List[dict] # TranscriptSegment as dict
use_translated: bool = True
class FreesoundSearchRequest(BaseModel):
"""Request to search Freesound."""
query: str
min_duration: int = 10
max_duration: int = 180
page: int = 1
page_size: int = 15
commercial_only: bool = True # 상업적 사용 가능한 라이선스만 (CC0, CC-BY)
class FreesoundDownloadRequest(BaseModel):
"""Request to download from Freesound."""
sound_id: str
name: str # Custom name for the downloaded file
class AutoBGMRequest(BaseModel):
"""Request for automatic BGM search and download."""
keywords: List[str] # Search keywords (from BGM recommendation)
max_duration: int = 120
commercial_only: bool = True # 상업적 사용 가능한 라이선스만
@router.get("/", response_model=list[BGMInfo])
async def list_bgm():
"""List all available BGM files."""
bgm_list = []
if not os.path.exists(settings.BGM_DIR):
return bgm_list
for filename in os.listdir(settings.BGM_DIR):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
filepath = os.path.join(settings.BGM_DIR, filename)
bgm_id = os.path.splitext(filename)[0]
duration = await get_audio_duration(filepath)
bgm_list.append(BGMInfo(
id=bgm_id,
name=bgm_id.replace("_", " ").replace("-", " ").title(),
duration=duration or 0,
path=f"/static/bgm/{filename}"
))
return bgm_list
@router.post("/upload", response_model=BGMUploadResponse)
async def upload_bgm(
file: UploadFile = File(...),
name: str | None = None
):
"""Upload a new BGM file."""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
# Validate file type
allowed_extensions = (".mp3", ".wav", ".m4a", ".ogg")
if not file.filename.lower().endswith(allowed_extensions):
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {allowed_extensions}"
)
# Generate ID
bgm_id = name or os.path.splitext(file.filename)[0]
bgm_id = bgm_id.lower().replace(" ", "_")
# Get extension
ext = os.path.splitext(file.filename)[1].lower()
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
# Save file
os.makedirs(settings.BGM_DIR, exist_ok=True)
async with aiofiles.open(filepath, 'wb') as out_file:
content = await file.read()
await out_file.write(content)
return BGMUploadResponse(
id=bgm_id,
name=name or file.filename,
message="BGM uploaded successfully"
)
@router.delete("/{bgm_id}")
async def delete_bgm(bgm_id: str):
"""Delete a BGM file."""
for ext in (".mp3", ".wav", ".m4a", ".ogg"):
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
if os.path.exists(filepath):
os.remove(filepath)
return {"message": f"BGM '{bgm_id}' deleted"}
raise HTTPException(status_code=404, detail="BGM not found")
@router.get("/sources/free", response_model=dict)
async def get_free_sources():
"""Get list of recommended free BGM sources for commercial use."""
sources = get_free_bgm_sources()
return {
"sources": sources,
"notice": "이 소스들은 상업적 사용이 가능한 무료 음악을 제공합니다. 각 사이트의 라이선스를 확인하세요.",
"recommended": [
{
"name": "Pixabay Music",
"url": "https://pixabay.com/music/search/",
"why": "CC0 라이선스, 저작권 표기 불필요, 쇼츠용 짧은 트랙 많음",
"search_tips": ["upbeat", "energetic", "chill", "cinematic", "funny"],
},
{
"name": "Mixkit",
"url": "https://mixkit.co/free-stock-music/",
"why": "고품질, 카테고리별 정리, 상업적 무료 사용",
"search_tips": ["short", "intro", "background"],
},
],
}
@router.post("/download-url", response_model=BGMUploadResponse)
async def download_bgm_from_url(request: BGMDownloadRequest):
"""
Download BGM from external URL (Pixabay, Mixkit, etc.)
Use this to download free BGM files directly from their source URLs.
"""
url = request.url
name = request.name.lower().replace(" ", "_")
# Validate URL - allow trusted free music sources
allowed_domains = [
"pixabay.com",
"cdn.pixabay.com",
"mixkit.co",
"assets.mixkit.co",
"uppbeat.io",
"freemusicarchive.org",
]
from urllib.parse import urlparse
parsed = urlparse(url)
domain = parsed.netloc.lower()
if not any(allowed in domain for allowed in allowed_domains):
raise HTTPException(
status_code=400,
detail=f"URL must be from allowed sources: {', '.join(allowed_domains)}"
)
try:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url, timeout=60)
if response.status_code != 200:
raise HTTPException(
status_code=400,
detail=f"Failed to download: HTTP {response.status_code}"
)
# Determine file extension
content_type = response.headers.get("content-type", "")
if "mpeg" in content_type or url.endswith(".mp3"):
ext = ".mp3"
elif "wav" in content_type or url.endswith(".wav"):
ext = ".wav"
elif "ogg" in content_type or url.endswith(".ogg"):
ext = ".ogg"
else:
ext = ".mp3"
# Save file
os.makedirs(settings.BGM_DIR, exist_ok=True)
filepath = os.path.join(settings.BGM_DIR, f"{name}{ext}")
async with aiofiles.open(filepath, 'wb') as f:
await f.write(response.content)
return BGMUploadResponse(
id=name,
name=request.name,
message=f"BGM downloaded from {domain}"
)
except httpx.TimeoutException:
raise HTTPException(status_code=408, detail="Download timed out")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
@router.post("/recommend")
async def recommend_bgm(request: BGMRecommendRequest):
"""
AI-powered BGM recommendation based on script content.
Analyzes the script mood and suggests appropriate background music.
Returns matched BGM from library if available, otherwise provides search keywords.
"""
# Convert dict to TranscriptSegment
segments = [TranscriptSegment(**seg) for seg in request.segments]
# Get available BGM list
available_bgm = []
if os.path.exists(settings.BGM_DIR):
for filename in os.listdir(settings.BGM_DIR):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
bgm_id = os.path.splitext(filename)[0]
available_bgm.append({
"id": bgm_id,
"name": bgm_id.replace("_", " ").replace("-", " "),
})
# Get recommendation
success, message, recommendation = await recommend_bgm_for_script(
segments,
available_bgm,
use_translated=request.use_translated,
)
if not success:
raise HTTPException(status_code=500, detail=message)
# Build Pixabay search URL
search_keywords = "+".join(recommendation.search_keywords[:3])
pixabay_url = f"https://pixabay.com/music/search/{search_keywords}/"
return {
"recommendation": {
"mood": recommendation.mood,
"energy": recommendation.energy,
"reasoning": recommendation.reasoning,
"suggested_genres": recommendation.suggested_genres,
"search_keywords": recommendation.search_keywords,
},
"matched_bgm": recommendation.matched_bgm_id,
"search_urls": {
"pixabay": pixabay_url,
"mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}",
},
"message": message,
}
@router.get("/recommend/presets")
async def get_bgm_presets():
"""
Get predefined BGM presets for common content types.
Use these presets for quick BGM selection without AI analysis.
"""
presets = {}
for content_type, preset_info in BGM_PRESETS.items():
presets[content_type] = {
"mood": preset_info["mood"],
"keywords": preset_info["keywords"],
"description": f"Best for {content_type} content",
}
return {
"presets": presets,
"usage": "Use content_type parameter with /recommend/preset/{content_type}",
}
@router.get("/recommend/preset/{content_type}")
async def get_preset_bgm(content_type: str):
"""
Get BGM recommendation for a specific content type.
Available types: cooking, fitness, tutorial, comedy, travel, asmr, news, gaming
"""
recommendation = get_preset_recommendation(content_type)
if not recommendation:
available_types = list(BGM_PRESETS.keys())
raise HTTPException(
status_code=404,
detail=f"Unknown content type. Available: {', '.join(available_types)}"
)
# Check for matching BGM in library
if os.path.exists(settings.BGM_DIR):
for filename in os.listdir(settings.BGM_DIR):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
bgm_id = os.path.splitext(filename)[0]
bgm_name = bgm_id.lower()
# Check if any keyword matches
for keyword in recommendation.search_keywords:
if keyword in bgm_name:
recommendation.matched_bgm_id = bgm_id
break
if recommendation.matched_bgm_id:
break
search_keywords = "+".join(recommendation.search_keywords[:3])
return {
"content_type": content_type,
"recommendation": {
"mood": recommendation.mood,
"energy": recommendation.energy,
"suggested_genres": recommendation.suggested_genres,
"search_keywords": recommendation.search_keywords,
},
"matched_bgm": recommendation.matched_bgm_id,
"search_urls": {
"pixabay": f"https://pixabay.com/music/search/{search_keywords}/",
"mixkit": f"https://mixkit.co/free-stock-music/?q={search_keywords}",
},
}
@router.post("/freesound/search")
async def search_freesound_api(request: FreesoundSearchRequest):
"""
Search for music on Freesound.
Freesound API provides 500,000+ CC licensed sounds.
Get your API key at: https://freesound.org/apiv2/apply
Set commercial_only=true (default) to only return CC0 licensed sounds
that can be used commercially without attribution.
"""
success, message, results = await search_freesound(
query=request.query,
min_duration=request.min_duration,
max_duration=request.max_duration,
page=request.page,
page_size=request.page_size,
commercial_only=request.commercial_only,
)
if not success:
raise HTTPException(status_code=400, detail=message)
def is_commercial_ok(license_str: str) -> bool:
return "CC0" in license_str or license_str == "CC BY (Attribution)"
return {
"message": message,
"commercial_only": request.commercial_only,
"results": [
{
"id": r.id,
"title": r.title,
"duration": r.duration,
"tags": r.tags,
"license": r.license,
"commercial_use_ok": is_commercial_ok(r.license),
"preview_url": r.preview_url,
"source": r.source,
}
for r in results
],
"search_url": f"https://freesound.org/search/?q={request.query}",
}
@router.post("/freesound/download", response_model=BGMUploadResponse)
async def download_freesound_api(request: FreesoundDownloadRequest):
"""
Download a sound from Freesound by ID.
Downloads the high-quality preview (128kbps MP3).
"""
name = request.name.lower().replace(" ", "_")
name = "".join(c for c in name if c.isalnum() or c == "_")
success, message, file_path = await download_freesound(
sound_id=request.sound_id,
output_dir=settings.BGM_DIR,
filename=name,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return BGMUploadResponse(
id=name,
name=request.name,
message=message,
)
@router.post("/auto-download")
async def auto_download_bgm(request: AutoBGMRequest):
"""
Automatically search and download BGM based on keywords.
Use this with keywords from /recommend endpoint to auto-download matching BGM.
Requires FREESOUND_API_KEY to be configured.
Set commercial_only=true (default) to only download CC0 licensed sounds
that can be used commercially without attribution.
"""
success, message, file_path, matched_result = await search_and_download_bgm(
keywords=request.keywords,
output_dir=settings.BGM_DIR,
max_duration=request.max_duration,
commercial_only=request.commercial_only,
)
if not success:
return {
"success": False,
"message": message,
"downloaded": None,
"suggestion": "Configure FREESOUND_API_KEY or manually download from Pixabay/Mixkit",
}
# Get duration of downloaded file
duration = 0
if file_path:
duration = await get_audio_duration(file_path) or 0
# Check if license is commercially usable
license_name = matched_result.license if matched_result else ""
commercial_ok = "CC0" in license_name or license_name == "CC BY (Attribution)"
return {
"success": True,
"message": message,
"downloaded": {
"id": os.path.splitext(os.path.basename(file_path))[0] if file_path else None,
"name": matched_result.title if matched_result else None,
"duration": duration,
"license": license_name,
"commercial_use_ok": commercial_ok,
"source": "freesound",
"path": f"/static/bgm/{os.path.basename(file_path)}" if file_path else None,
},
"original": {
"freesound_id": matched_result.id if matched_result else None,
"tags": matched_result.tags if matched_result else [],
},
}
@router.get("/defaults/status")
async def get_default_bgm_status():
"""
Check status of default BGM tracks.
Returns which default tracks are installed and which are missing.
"""
status = check_default_bgm_status(settings.BGM_DIR)
# Add track details
tracks = []
for track in DEFAULT_BGM_TRACKS:
installed = track.id in status["installed_ids"]
tracks.append({
"id": track.id,
"name": track.name,
"category": track.category,
"description": track.description,
"installed": installed,
})
return {
"total": status["total"],
"installed": status["installed"],
"missing": status["missing"],
"tracks": tracks,
}
@router.post("/defaults/initialize")
async def initialize_default_bgms(force: bool = False):
"""
Download default BGM tracks.
Downloads pre-selected royalty-free BGM tracks (Pixabay License).
Use force=true to re-download all tracks.
These tracks are free for commercial use without attribution.
"""
downloaded, skipped, errors = await initialize_default_bgm(
settings.BGM_DIR,
force=force,
)
return {
"success": len(errors) == 0,
"downloaded": downloaded,
"skipped": skipped,
"errors": errors,
"message": f"Downloaded {downloaded} tracks, skipped {skipped} existing" if downloaded > 0
else "All default tracks already installed" if skipped > 0
else "Failed to download tracks",
}
@router.get("/defaults/list")
async def list_default_bgms():
"""
Get list of available default BGM tracks with metadata.
Returns information about all pre-configured default tracks.
"""
tracks = await get_default_bgm_list()
status = check_default_bgm_status(settings.BGM_DIR)
for track in tracks:
track["installed"] = track["id"] in status["installed_ids"]
return {
"tracks": tracks,
"total": len(tracks),
"installed": status["installed"],
"license": "Pixabay License (Free for commercial use, no attribution required)",
}
@router.get("/{bgm_id}")
async def get_bgm(bgm_id: str):
"""Get BGM info by ID."""
for ext in (".mp3", ".wav", ".m4a", ".ogg"):
filepath = os.path.join(settings.BGM_DIR, f"{bgm_id}{ext}")
if os.path.exists(filepath):
duration = await get_audio_duration(filepath)
return BGMInfo(
id=bgm_id,
name=bgm_id.replace("_", " ").replace("-", " ").title(),
duration=duration or 0,
path=f"/static/bgm/{bgm_id}{ext}"
)
raise HTTPException(status_code=404, detail="BGM not found")