- source_name, source_url → source: { name, url } 형태로 변경
- YouTube: schedule_youtube에서 video_id로 URL 생성
- X: schedule_x에서 post_id로 URL 생성
- 프론트엔드 전체 파일 source 객체 형태로 수정
- 문서 업데이트 (api.md, architecture.md, migration.md 등)
- tracks → album_tracks 테이블명 변경 반영
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
410 lines
12 KiB
JavaScript
410 lines
12 KiB
JavaScript
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
|
|
`);
|
|
|
|
for (const album of albums) {
|
|
const [tracks] = await db.query(
|
|
`SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger
|
|
FROM album_tracks WHERE album_id = ? ORDER BY track_number`,
|
|
[album.id]
|
|
);
|
|
album.tracks = tracks;
|
|
}
|
|
|
|
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();
|
|
}
|
|
});
|
|
}
|