feat(bgm): 카테고리당 3개 다운로드 및 카테고리별 그룹화

- 카테고리당 BGM 3개씩 다운로드 기능 추가
- 파일명에 카테고리 prefix 추가 (예: upbeat_trackname.mp3)
- 중복 BGM 자동 스킵 기능
- 프론트엔드에서 BGM을 카테고리별로 그룹화하여 표시
- 분류되지 않은 BGM은 '기타' 섹션에 표시

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kihong.kim
2026-01-04 21:36:30 +09:00
parent 56bf3b1d34
commit be3ed688a1
4 changed files with 210 additions and 55 deletions

View File

@@ -63,6 +63,8 @@ class AutoBGMRequest(BaseModel):
keywords: List[str] # Search keywords (from BGM recommendation) keywords: List[str] # Search keywords (from BGM recommendation)
max_duration: int = 120 max_duration: int = 120
commercial_only: bool = True # 상업적 사용 가능한 라이선스만 commercial_only: bool = True # 상업적 사용 가능한 라이선스만
count: int = 1 # Number of BGMs to download per request (default: 1)
category: str | None = None # Category name to prefix filename (e.g., 'upbeat', 'chill')
@router.get("/", response_model=list[BGMInfo]) @router.get("/", response_model=list[BGMInfo])
@@ -441,12 +443,17 @@ async def auto_download_bgm(request: AutoBGMRequest):
Set commercial_only=true (default) to only download CC0 licensed sounds Set commercial_only=true (default) to only download CC0 licensed sounds
that can be used commercially without attribution. that can be used commercially without attribution.
Set count to download multiple BGMs per category (default: 1).
Existing BGMs with the same name are automatically skipped.
""" """
success, message, file_path, matched_result = await search_and_download_bgm( success, message, file_path, matched_result = await search_and_download_bgm(
keywords=request.keywords, keywords=request.keywords,
output_dir=settings.BGM_DIR, output_dir=settings.BGM_DIR,
max_duration=request.max_duration, max_duration=request.max_duration,
commercial_only=request.commercial_only, commercial_only=request.commercial_only,
count=request.count,
category=request.category,
) )
if not success: if not success:

View File

@@ -249,11 +249,25 @@ async def download_freesound(
return False, f"Download error: {str(e)}", None return False, f"Download error: {str(e)}", None
def get_existing_bgm_ids(output_dir: str) -> set:
"""Get set of existing BGM filenames (without extension) in the directory."""
existing = set()
if os.path.exists(output_dir):
for filename in os.listdir(output_dir):
if filename.endswith((".mp3", ".wav", ".m4a", ".ogg")):
# Extract base name without extension
base_name = os.path.splitext(filename)[0]
existing.add(base_name.lower())
return existing
async def search_and_download_bgm( async def search_and_download_bgm(
keywords: List[str], keywords: List[str],
output_dir: str, output_dir: str,
max_duration: int = 120, max_duration: int = 120,
commercial_only: bool = True, commercial_only: bool = True,
count: int = 1,
category: Optional[str] = None,
) -> Tuple[bool, str, Optional[str], Optional[BGMSearchResult]]: ) -> Tuple[bool, str, Optional[str], Optional[BGMSearchResult]]:
""" """
Search for BGM and download the best match. Search for BGM and download the best match.
@@ -263,13 +277,19 @@ async def search_and_download_bgm(
output_dir: Directory to save downloaded file output_dir: Directory to save downloaded file
max_duration: Maximum duration in seconds max_duration: Maximum duration in seconds
commercial_only: Only search commercially usable licenses (CC0) commercial_only: Only search commercially usable licenses (CC0)
count: Number of BGMs to download (default: 1)
category: Category name to prefix filename (e.g., 'upbeat', 'chill')
Returns: Returns:
Tuple of (success, message, file_path, matched_result) Tuple of (success, message, file_path, matched_result)
When count > 1, file_path contains the last downloaded file path
""" """
if not settings.FREESOUND_API_KEY: if not settings.FREESOUND_API_KEY:
return False, "Freesound API key not configured", None, None return False, "Freesound API key not configured", None, None
# Get existing BGM files to skip duplicates
existing_bgm = get_existing_bgm_ids(output_dir)
# Try searching with combined keywords # Try searching with combined keywords
query = " ".join(keywords[:3]) query = " ".join(keywords[:3])
@@ -277,7 +297,7 @@ async def search_and_download_bgm(
query=query, query=query,
min_duration=15, min_duration=15,
max_duration=max_duration, max_duration=max_duration,
page_size=10, page_size=max(count * 3, 15), # Get more results to filter duplicates
commercial_only=commercial_only, commercial_only=commercial_only,
) )
@@ -288,7 +308,7 @@ async def search_and_download_bgm(
query=keyword, query=keyword,
min_duration=15, min_duration=15,
max_duration=max_duration, max_duration=max_duration,
page_size=5, page_size=max(count * 3, 15),
commercial_only=commercial_only, commercial_only=commercial_only,
) )
if success and results: if success and results:
@@ -297,23 +317,56 @@ async def search_and_download_bgm(
if not results: if not results:
return False, "No matching BGM found on Freesound", None, None return False, "No matching BGM found on Freesound", None, None
# Select the best result (first one, sorted by relevance) # Download multiple BGMs
best_match = results[0] downloaded_count = 0
skipped_count = 0
last_file_path = None
last_match = None
messages = []
for result in results:
if downloaded_count >= count:
break
# Generate safe filename with category prefix
base_name = result.title.lower().replace(" ", "_")[:50]
base_name = "".join(c for c in base_name if c.isalnum() or c == "_")
if category:
safe_filename = f"{category}_{base_name}"
else:
safe_filename = base_name
# Skip if already exists
if safe_filename.lower() in existing_bgm:
skipped_count += 1
continue
# Download it # Download it
safe_filename = best_match.title.lower().replace(" ", "_")[:50] dl_success, download_msg, file_path = await download_freesound(
safe_filename = "".join(c for c in safe_filename if c.isalnum() or c == "_") sound_id=result.id,
success, download_msg, file_path = await download_freesound(
sound_id=best_match.id,
output_dir=output_dir, output_dir=output_dir,
filename=safe_filename, filename=safe_filename,
) )
if not success: if dl_success:
return False, download_msg, None, best_match downloaded_count += 1
last_file_path = file_path
last_match = result
existing_bgm.add(safe_filename.lower()) # Add to existing set
messages.append(f"Downloaded: {result.title}")
else:
messages.append(f"Failed: {result.title} - {download_msg}")
return True, download_msg, file_path, best_match if downloaded_count == 0:
if skipped_count > 0:
return True, f"All {skipped_count} BGMs already exist, skipped", None, None
return False, "Failed to download any BGM", None, None
summary = f"Downloaded {downloaded_count} BGM(s)"
if skipped_count > 0:
summary += f", skipped {skipped_count} existing"
return True, summary, last_file_path, last_match
async def search_pixabay_music( async def search_pixabay_music(

View File

@@ -143,11 +143,13 @@ export const bgmApi = {
}, },
delete: (bgmId) => client.delete(`/bgm/${bgmId}`), delete: (bgmId) => client.delete(`/bgm/${bgmId}`),
// Auto-download BGM from Freesound // Auto-download BGM from Freesound
autoDownload: (keywords, maxDuration = 60) => autoDownload: (keywords, maxDuration = 60, count = 1, category = null) =>
client.post('/bgm/auto-download', { client.post('/bgm/auto-download', {
keywords, keywords,
max_duration: maxDuration, max_duration: maxDuration,
commercial_only: true, commercial_only: true,
count,
category,
}), }),
// Download default BGM tracks (force re-download if needed) // Download default BGM tracks (force re-download if needed)
initializeDefaults: (force = false) => initializeDefaults: (force = false) =>

View File

@@ -97,7 +97,9 @@ export default function BGMPage() {
return `${mins}:${String(secs).padStart(2, '0')}`; return `${mins}:${String(secs).padStart(2, '0')}`;
}; };
// 무료 BGM 다운로드 // 무료 BGM 다운로드 (카테고리당 3개)
const BGM_COUNT_PER_CATEGORY = 3;
const handleDownloadBgm = async (category) => { const handleDownloadBgm = async (category) => {
if (downloading) return; // 이미 다운로드 중이면 무시 if (downloading) return; // 이미 다운로드 중이면 무시
@@ -105,7 +107,7 @@ export default function BGMPage() {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' })); setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' }));
try { try {
const res = await bgmApi.autoDownload(category.keywords, 120); const res = await bgmApi.autoDownload(category.keywords, 120, BGM_COUNT_PER_CATEGORY, category.id);
if (res.data.success) { if (res.data.success) {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' })); setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' }));
// BGM 리스트 새로고침 // BGM 리스트 새로고침
@@ -125,7 +127,7 @@ export default function BGMPage() {
} }
}; };
// 전체 카테고리 다운로드 // 전체 카테고리 다운로드 (카테고리당 3개)
const handleDownloadAll = async () => { const handleDownloadAll = async () => {
if (downloading) return; if (downloading) return;
@@ -134,7 +136,7 @@ export default function BGMPage() {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' })); setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' }));
try { try {
const res = await bgmApi.autoDownload(category.keywords, 120); const res = await bgmApi.autoDownload(category.keywords, 120, BGM_COUNT_PER_CATEGORY, category.id);
if (res.data.success) { if (res.data.success) {
setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' })); setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' }));
} else { } else {
@@ -146,7 +148,7 @@ export default function BGMPage() {
} }
// 잠시 대기 (API 제한 방지) // 잠시 대기 (API 제한 방지)
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1500));
} }
setDownloading(null); setDownloading(null);
@@ -158,6 +160,31 @@ export default function BGMPage() {
}, 5000); }, 5000);
}; };
// BGM을 카테고리별로 그룹화
const getBgmCategory = (bgmId) => {
for (const cat of BGM_CATEGORIES) {
if (bgmId.startsWith(cat.id + '_')) {
return cat.id;
}
}
return 'other'; // 카테고리가 없는 BGM
};
const groupedBgm = bgmList.reduce((acc, bgm) => {
const category = getBgmCategory(bgm.id);
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(bgm);
return acc;
}, {});
// 카테고리 이름 가져오기
const getCategoryName = (categoryId) => {
const cat = BGM_CATEGORIES.find(c => c.id === categoryId);
return cat ? cat.name : '기타';
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -191,7 +218,7 @@ export default function BGMPage() {
무료 BGM 다운로드 무료 BGM 다운로드
</h3> </h3>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
Freesound.org에서 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능) Freesound.org에서 카테고리당 3개의 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능, 중복 자동 스킵)
</p> </p>
</div> </div>
<button <button
@@ -262,44 +289,110 @@ export default function BGMPage() {
<p className="text-sm mt-2">MP3, WAV 파일을 업로드하세요.</p> <p className="text-sm mt-2">MP3, WAV 파일을 업로드하세요.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="space-y-6">
{bgmList.map((bgm) => ( {/* 카테고리별로 그룹화하여 표시 */}
{BGM_CATEGORIES.map((category) => {
const categoryBgms = groupedBgm[category.id] || [];
if (categoryBgms.length === 0) return null;
return (
<div key={category.id} className="card">
<h3 className="font-medium text-lg mb-4 flex items-center gap-2">
<Music size={18} className="text-blue-400" />
{category.name}
<span className="text-sm text-gray-500 font-normal">({categoryBgms.length})</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{categoryBgms.map((bgm) => (
<div <div
key={bgm.id} key={bgm.id}
className="card flex items-center gap-4 hover:border-gray-700 transition-colors" className="flex items-center gap-3 p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
> >
<button <button
onClick={() => handlePlay(bgm)} onClick={() => handlePlay(bgm)}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${ className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors flex-shrink-0 ${
playingId === bgm.id playingId === bgm.id
? 'bg-red-600 text-white' ? 'bg-red-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white' : 'bg-gray-700 text-gray-400 hover:text-white'
}`} }`}
> >
{playingId === bgm.id ? ( {playingId === bgm.id ? (
<Pause size={20} /> <Pause size={16} />
) : ( ) : (
<Play size={20} className="ml-1" /> <Play size={16} className="ml-0.5" />
)} )}
</button> </button>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium truncate">{bgm.name}</h3> <h4 className="font-medium truncate text-sm">{bgm.name}</h4>
<p className="text-sm text-gray-500"> <p className="text-xs text-gray-500">
{formatDuration(bgm.duration)} {formatDuration(bgm.duration)}
</p> </p>
</div> </div>
<button <button
onClick={() => handleDelete(bgm.id)} onClick={() => handleDelete(bgm.id)}
className="p-2 text-gray-500 hover:text-red-500 transition-colors" className="p-1.5 text-gray-500 hover:text-red-500 transition-colors flex-shrink-0"
title="삭제" title="삭제"
> >
<Trash2 size={18} /> <Trash2 size={16} />
</button> </button>
</div> </div>
))} ))}
</div> </div>
</div>
);
})}
{/* 기타 카테고리 (분류되지 않은 BGM) */}
{groupedBgm.other && groupedBgm.other.length > 0 && (
<div className="card">
<h3 className="font-medium text-lg mb-4 flex items-center gap-2">
<Music size={18} className="text-gray-400" />
기타
<span className="text-sm text-gray-500 font-normal">({groupedBgm.other.length})</span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{groupedBgm.other.map((bgm) => (
<div
key={bgm.id}
className="flex items-center gap-3 p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
>
<button
onClick={() => handlePlay(bgm)}
className={`w-10 h-10 rounded-full flex items-center justify-center transition-colors flex-shrink-0 ${
playingId === bgm.id
? 'bg-red-600 text-white'
: 'bg-gray-700 text-gray-400 hover:text-white'
}`}
>
{playingId === bgm.id ? (
<Pause size={16} />
) : (
<Play size={16} className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<h4 className="font-medium truncate text-sm">{bgm.name}</h4>
<p className="text-xs text-gray-500">
{formatDuration(bgm.duration)}
</p>
</div>
<button
onClick={() => handleDelete(bgm.id)}
className="p-1.5 text-gray-500 hover:text-red-500 transition-colors flex-shrink-0"
title="삭제"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
</div>
)}
</div>
)} )}
<div className="card bg-gray-800/50"> <div className="card bg-gray-800/50">