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:
578
backend/app/routers/bgm.py
Normal file
578
backend/app/routers/bgm.py
Normal file
@@ -0,0 +1,578 @@
|
||||
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")
|
||||
Reference in New Issue
Block a user