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:
@@ -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) =>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -191,7 +218,7 @@ export default function BGMPage() {
|
||||
무료 BGM 다운로드
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Freesound.org에서 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능)
|
||||
Freesound.org에서 카테고리당 3개의 CC0 라이선스 BGM을 다운로드합니다 (상업용 가능, 중복 자동 스킵)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -262,43 +289,109 @@ export default function BGMPage() {
|
||||
<p className="text-sm mt-2">MP3, WAV 파일을 업로드하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{bgmList.map((bgm) => (
|
||||
<div
|
||||
key={bgm.id}
|
||||
className="card flex items-center gap-4 hover:border-gray-700 transition-colors"
|
||||
>
|
||||
<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="space-y-6">
|
||||
{/* 카테고리별로 그룹화하여 표시 */}
|
||||
{BGM_CATEGORIES.map((category) => {
|
||||
const categoryBgms = groupedBgm[category.id] || [];
|
||||
if (categoryBgms.length === 0) return null;
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium truncate">{bgm.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{formatDuration(bgm.duration)}
|
||||
</p>
|
||||
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
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => handleDelete(bgm.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-500 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
{/* 기타 카테고리 (분류되지 않은 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user