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:
@@ -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:
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|
||||||
# Download it
|
for result in results:
|
||||||
safe_filename = best_match.title.lower().replace(" ", "_")[:50]
|
if downloaded_count >= count:
|
||||||
safe_filename = "".join(c for c in safe_filename if c.isalnum() or c == "_")
|
break
|
||||||
|
|
||||||
success, download_msg, file_path = await download_freesound(
|
# Generate safe filename with category prefix
|
||||||
sound_id=best_match.id,
|
base_name = result.title.lower().replace(" ", "_")[:50]
|
||||||
output_dir=output_dir,
|
base_name = "".join(c for c in base_name if c.isalnum() or c == "_")
|
||||||
filename=safe_filename,
|
if category:
|
||||||
)
|
safe_filename = f"{category}_{base_name}"
|
||||||
|
else:
|
||||||
|
safe_filename = base_name
|
||||||
|
|
||||||
if not success:
|
# Skip if already exists
|
||||||
return False, download_msg, None, best_match
|
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(
|
async def search_pixabay_music(
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,43 +289,109 @@ 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) => (
|
{/* 카테고리별로 그룹화하여 표시 */}
|
||||||
<div
|
{BGM_CATEGORIES.map((category) => {
|
||||||
key={bgm.id}
|
const categoryBgms = groupedBgm[category.id] || [];
|
||||||
className="card flex items-center gap-4 hover:border-gray-700 transition-colors"
|
if (categoryBgms.length === 0) return null;
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => handlePlay(bgm)}
|
|
||||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
|
|
||||||
playingId === bgm.id
|
|
||||||
? 'bg-red-600 text-white'
|
|
||||||
: 'bg-gray-800 text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{playingId === bgm.id ? (
|
|
||||||
<Pause size={20} />
|
|
||||||
) : (
|
|
||||||
<Play size={20} className="ml-1" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
return (
|
||||||
<h3 className="font-medium truncate">{bgm.name}</h3>
|
<div key={category.id} className="card">
|
||||||
<p className="text-sm text-gray-500">
|
<h3 className="font-medium text-lg mb-4 flex items-center gap-2">
|
||||||
{formatDuration(bgm.duration)}
|
<Music size={18} className="text-blue-400" />
|
||||||
</p>
|
{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
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<button
|
{/* 기타 카테고리 (분류되지 않은 BGM) */}
|
||||||
onClick={() => handleDelete(bgm.id)}
|
{groupedBgm.other && groupedBgm.other.length > 0 && (
|
||||||
className="p-2 text-gray-500 hover:text-red-500 transition-colors"
|
<div className="card">
|
||||||
title="삭제"
|
<h3 className="font-medium text-lg mb-4 flex items-center gap-2">
|
||||||
>
|
<Music size={18} className="text-gray-400" />
|
||||||
<Trash2 size={18} />
|
기타
|
||||||
</button>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user