import { uploadAlbumCover, deleteAlbumCover, } from '../../services/image.js'; import photosRoutes from './photos.js'; import teasersRoutes from './teasers.js'; /** * 앨범 라우트 * GET: 공개, POST/PUT/DELETE: 인증 필요 */ export default async function albumsRoutes(fastify) { const { db } = fastify; // 하위 라우트 등록 fastify.register(photosRoutes); fastify.register(teasersRoutes); /** * 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함) */ async function getAlbumDetails(album) { const [tracks] = await db.query( 'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number', [album.id] ); album.tracks = tracks; const [teasers] = await db.query( `SELECT original_url, medium_url, thumb_url, video_url, media_type FROM album_teasers WHERE album_id = ? ORDER BY sort_order`, [album.id] ); album.teasers = teasers; const [photos] = await db.query( `SELECT p.id, p.original_url, p.medium_url, p.thumb_url, p.photo_type, p.concept_name, p.sort_order, p.width, p.height, GROUP_CONCAT(m.name ORDER BY m.id SEPARATOR ', ') as members FROM album_photos p LEFT JOIN album_photo_members pm ON p.id = pm.photo_id LEFT JOIN members m ON pm.member_id = m.id WHERE p.album_id = ? GROUP BY p.id ORDER BY p.sort_order`, [album.id] ); const conceptPhotos = {}; for (const photo of photos) { const concept = photo.concept_name || 'Default'; if (!conceptPhotos[concept]) { conceptPhotos[concept] = []; } conceptPhotos[concept].push({ id: photo.id, original_url: photo.original_url, medium_url: photo.medium_url, thumb_url: photo.thumb_url, width: photo.width, height: photo.height, type: photo.photo_type, members: photo.members, sortOrder: photo.sort_order, }); } album.conceptPhotos = conceptPhotos; return album; } // ==================== GET (공개) ==================== /** * GET /api/albums */ fastify.get('/', { schema: { tags: ['albums'], summary: '전체 앨범 목록 조회', }, }, async () => { const [albums] = await db.query(` SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url, description FROM albums ORDER BY release_date DESC `); if (albums.length === 0) return albums; // N+1 쿼리 최적화: 모든 트랙을 한 번에 조회 const albumIds = albums.map(a => a.id); const [allTracks] = await db.query( `SELECT id, album_id, track_number, title, is_title_track, duration, lyricist, composer, arranger FROM album_tracks WHERE album_id IN (?) ORDER BY album_id, track_number`, [albumIds] ); // 앨범 ID별로 트랙 그룹화 const tracksByAlbum = {}; for (const track of allTracks) { if (!tracksByAlbum[track.album_id]) { tracksByAlbum[track.album_id] = []; } tracksByAlbum[track.album_id].push(track); } // 각 앨범에 트랙 할당 for (const album of albums) { album.tracks = tracksByAlbum[album.id] || []; } return albums; }); /** * GET /api/albums/by-name/:albumName/track/:trackTitle */ fastify.get('/by-name/:albumName/track/:trackTitle', { schema: { tags: ['albums'], summary: '앨범명과 트랙명으로 트랙 조회', }, }, async (request, reply) => { const albumName = decodeURIComponent(request.params.albumName); const trackTitle = decodeURIComponent(request.params.trackTitle); const [albums] = await db.query( 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', [albumName, albumName] ); if (albums.length === 0) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } const album = albums[0]; const [tracks] = await db.query( 'SELECT * FROM album_tracks WHERE album_id = ? AND title = ?', [album.id, trackTitle] ); if (tracks.length === 0) { return reply.code(404).send({ error: '트랙을 찾을 수 없습니다.' }); } const track = tracks[0]; const [otherTracks] = await db.query( 'SELECT id, track_number, title, is_title_track, duration FROM album_tracks WHERE album_id = ? ORDER BY track_number', [album.id] ); return { ...track, album: { id: album.id, title: album.title, folder_name: album.folder_name, cover_thumb_url: album.cover_thumb_url, cover_medium_url: album.cover_medium_url, release_date: album.release_date, album_type: album.album_type, }, otherTracks, }; }); /** * GET /api/albums/by-name/:name */ fastify.get('/by-name/:name', { schema: { tags: ['albums'], summary: '앨범명으로 앨범 조회', }, }, async (request, reply) => { const name = decodeURIComponent(request.params.name); const [albums] = await db.query( 'SELECT * FROM albums WHERE folder_name = ? OR title = ?', [name, name] ); if (albums.length === 0) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } return getAlbumDetails(albums[0]); }); /** * GET /api/albums/:id */ fastify.get('/:id', { schema: { tags: ['albums'], summary: 'ID로 앨범 조회', }, }, async (request, reply) => { const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [ request.params.id, ]); if (albums.length === 0) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } return getAlbumDetails(albums[0]); }); // ==================== POST/PUT/DELETE (인증 필요) ==================== /** * POST /api/albums */ fastify.post('/', { schema: { tags: ['albums'], summary: '앨범 생성', security: [{ bearerAuth: [] }], }, preHandler: [fastify.authenticate], }, async (request, reply) => { const parts = request.parts(); let data = null; let coverBuffer = null; for await (const part of parts) { if (part.type === 'file' && part.fieldname === 'cover') { coverBuffer = await part.toBuffer(); } else if (part.fieldname === 'data') { data = JSON.parse(part.value); } } if (!data) { return reply.code(400).send({ error: '데이터가 필요합니다.' }); } const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data; if (!title || !album_type || !release_date || !folder_name) { return reply.code(400).send({ error: '필수 필드를 모두 입력해주세요.' }); } const connection = await db.getConnection(); try { await connection.beginTransaction(); let coverOriginalUrl = null; let coverMediumUrl = null; let coverThumbUrl = null; if (coverBuffer) { const urls = await uploadAlbumCover(folder_name, coverBuffer); coverOriginalUrl = urls.originalUrl; coverMediumUrl = urls.mediumUrl; coverThumbUrl = urls.thumbUrl; } const [albumResult] = await connection.query( `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_original_url, cover_medium_url, cover_thumb_url, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [title, album_type, album_type_short || null, release_date, folder_name, coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null] ); const albumId = albumResult.insertId; if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( `INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [albumId, track.track_number, track.title, track.duration || null, track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, track.arranger || null, track.lyrics || null, track.music_video_url || null] ); } } await connection.commit(); return { message: '앨범이 생성되었습니다.', albumId }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }); /** * PUT /api/albums/:id */ fastify.put('/:id', { schema: { tags: ['albums'], summary: '앨범 수정', security: [{ bearerAuth: [] }], }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; const parts = request.parts(); let data = null; let coverBuffer = null; for await (const part of parts) { if (part.type === 'file' && part.fieldname === 'cover') { coverBuffer = await part.toBuffer(); } else if (part.fieldname === 'data') { data = JSON.parse(part.value); } } if (!data) { return reply.code(400).send({ error: '데이터가 필요합니다.' }); } const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data; const connection = await db.getConnection(); try { await connection.beginTransaction(); const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]); if (existingAlbums.length === 0) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } const existing = existingAlbums[0]; let coverOriginalUrl = existing.cover_original_url; let coverMediumUrl = existing.cover_medium_url; let coverThumbUrl = existing.cover_thumb_url; if (coverBuffer) { const urls = await uploadAlbumCover(folder_name, coverBuffer); coverOriginalUrl = urls.originalUrl; coverMediumUrl = urls.mediumUrl; coverThumbUrl = urls.thumbUrl; } await connection.query( `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_original_url = ?, cover_medium_url = ?, cover_thumb_url = ?, description = ? WHERE id = ?`, [title, album_type, album_type_short || null, release_date, folder_name, coverOriginalUrl, coverMediumUrl, coverThumbUrl, description || null, id] ); await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); if (tracks && tracks.length > 0) { for (const track of tracks) { await connection.query( `INSERT INTO album_tracks (album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, track.track_number, track.title, track.duration || null, track.is_title_track ? 1 : 0, track.lyricist || null, track.composer || null, track.arranger || null, track.lyrics || null, track.music_video_url || null] ); } } await connection.commit(); return { message: '앨범이 수정되었습니다.' }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }); /** * DELETE /api/albums/:id */ fastify.delete('/:id', { schema: { tags: ['albums'], summary: '앨범 삭제', security: [{ bearerAuth: [] }], }, preHandler: [fastify.authenticate], }, async (request, reply) => { const { id } = request.params; const connection = await db.getConnection(); try { await connection.beginTransaction(); const [existingAlbums] = await connection.query('SELECT * FROM albums WHERE id = ?', [id]); if (existingAlbums.length === 0) { return reply.code(404).send({ error: '앨범을 찾을 수 없습니다.' }); } const album = existingAlbums[0]; if (album.cover_original_url && album.folder_name) { await deleteAlbumCover(album.folder_name); } await connection.query('DELETE FROM album_tracks WHERE album_id = ?', [id]); await connection.query('DELETE FROM albums WHERE id = ?', [id]); await connection.commit(); return { message: '앨범이 삭제되었습니다.' }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } }); }