diff --git a/backend/app/routers/bgm.py b/backend/app/routers/bgm.py index 9a0c947..006b2f7 100644 --- a/backend/app/routers/bgm.py +++ b/backend/app/routers/bgm.py @@ -63,6 +63,8 @@ class AutoBGMRequest(BaseModel): keywords: List[str] # Search keywords (from BGM recommendation) max_duration: int = 120 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]) @@ -441,12 +443,17 @@ async def auto_download_bgm(request: AutoBGMRequest): Set commercial_only=true (default) to only download CC0 licensed sounds 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( keywords=request.keywords, output_dir=settings.BGM_DIR, max_duration=request.max_duration, commercial_only=request.commercial_only, + count=request.count, + category=request.category, ) if not success: diff --git a/backend/app/services/bgm_provider.py b/backend/app/services/bgm_provider.py index eed7554..524ba20 100644 --- a/backend/app/services/bgm_provider.py +++ b/backend/app/services/bgm_provider.py @@ -249,11 +249,25 @@ async def download_freesound( 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( keywords: List[str], output_dir: str, max_duration: int = 120, commercial_only: bool = True, + count: int = 1, + category: Optional[str] = None, ) -> Tuple[bool, str, Optional[str], Optional[BGMSearchResult]]: """ 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 max_duration: Maximum duration in seconds 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: 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: 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 query = " ".join(keywords[:3]) @@ -277,7 +297,7 @@ async def search_and_download_bgm( query=query, min_duration=15, max_duration=max_duration, - page_size=10, + page_size=max(count * 3, 15), # Get more results to filter duplicates commercial_only=commercial_only, ) @@ -288,7 +308,7 @@ async def search_and_download_bgm( query=keyword, min_duration=15, max_duration=max_duration, - page_size=5, + page_size=max(count * 3, 15), commercial_only=commercial_only, ) if success and results: @@ -297,23 +317,56 @@ async def search_and_download_bgm( 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 multiple BGMs + downloaded_count = 0 + skipped_count = 0 + last_file_path = None + last_match = None + messages = [] - # Download it - safe_filename = best_match.title.lower().replace(" ", "_")[:50] - safe_filename = "".join(c for c in safe_filename if c.isalnum() or c == "_") + for result in results: + if downloaded_count >= count: + break - success, download_msg, file_path = await download_freesound( - sound_id=best_match.id, - output_dir=output_dir, - filename=safe_filename, - ) + # 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 - if not success: - return False, download_msg, None, best_match + # Skip if already exists + if safe_filename.lower() in existing_bgm: + skipped_count += 1 + continue - return True, download_msg, file_path, best_match + # Download it + dl_success, download_msg, file_path = await download_freesound( + sound_id=result.id, + output_dir=output_dir, + filename=safe_filename, + ) + + if dl_success: + 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}") + + 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( diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 5f38c56..4b58cf6 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -143,11 +143,13 @@ export const bgmApi = { }, delete: (bgmId) => client.delete(`/bgm/${bgmId}`), // Auto-download BGM from Freesound - autoDownload: (keywords, maxDuration = 60) => + autoDownload: (keywords, maxDuration = 60, count = 1, category = null) => client.post('/bgm/auto-download', { keywords, max_duration: maxDuration, commercial_only: true, + count, + category, }), // Download default BGM tracks (force re-download if needed) initializeDefaults: (force = false) => diff --git a/frontend/src/pages/BGMPage.jsx b/frontend/src/pages/BGMPage.jsx index 2cc1936..2fbc5e5 100644 --- a/frontend/src/pages/BGMPage.jsx +++ b/frontend/src/pages/BGMPage.jsx @@ -97,7 +97,9 @@ export default function BGMPage() { return `${mins}:${String(secs).padStart(2, '0')}`; }; - // 무료 BGM 다운로드 + // 무료 BGM 다운로드 (카테고리당 3개) + const BGM_COUNT_PER_CATEGORY = 3; + const handleDownloadBgm = async (category) => { if (downloading) return; // 이미 다운로드 중이면 무시 @@ -105,7 +107,7 @@ export default function BGMPage() { setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' })); 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) { setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' })); // BGM 리스트 새로고침 @@ -125,7 +127,7 @@ export default function BGMPage() { } }; - // 전체 카테고리 다운로드 + // 전체 카테고리 다운로드 (카테고리당 3개) const handleDownloadAll = async () => { if (downloading) return; @@ -134,7 +136,7 @@ export default function BGMPage() { setDownloadStatus(prev => ({ ...prev, [category.id]: 'downloading' })); 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) { setDownloadStatus(prev => ({ ...prev, [category.id]: 'success' })); } else { @@ -146,7 +148,7 @@ export default function BGMPage() { } // 잠시 대기 (API 제한 방지) - await new Promise(resolve => setTimeout(resolve, 1000)); + await new Promise(resolve => setTimeout(resolve, 1500)); } setDownloading(null); @@ -158,6 +160,31 @@ export default function BGMPage() { }, 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 (
@@ -191,7 +218,7 @@ export default function BGMPage() { 무료 BGM 다운로드

- Freesound.org에서 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능) + Freesound.org에서 카테고리당 3개의 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능, 중복 자동 스킵)

) : ( -
- {bgmList.map((bgm) => ( -
- +
+ {/* 카테고리별로 그룹화하여 표시 */} + {BGM_CATEGORIES.map((category) => { + const categoryBgms = groupedBgm[category.id] || []; + if (categoryBgms.length === 0) return null; -
-

{bgm.name}

-

- {formatDuration(bgm.duration)} -

+ return ( +
+

+ + {category.name} + ({categoryBgms.length}) +

+
+ {categoryBgms.map((bgm) => ( +
+ + +
+

{bgm.name}

+

+ {formatDuration(bgm.duration)} +

+
+ + +
+ ))} +
+ ); + })} - + {/* 기타 카테고리 (분류되지 않은 BGM) */} + {groupedBgm.other && groupedBgm.other.length > 0 && ( +
+

+ + 기타 + ({groupedBgm.other.length}) +

+
+ {groupedBgm.other.map((bgm) => ( +
+ + +
+

{bgm.name}

+

+ {formatDuration(bgm.duration)} +

+
+ + +
+ ))} +
- ))} + )}
)}