2026-01-21 13:42:01 +09:00
|
|
|
/**
|
|
|
|
|
* 앨범 서비스
|
|
|
|
|
* 앨범 관련 비즈니스 로직
|
|
|
|
|
*/
|
2026-01-21 14:22:45 +09:00
|
|
|
import { uploadAlbumCover, deleteAlbumCover } from './image.js';
|
2026-01-21 14:58:07 +09:00
|
|
|
import { withTransaction } from '../utils/transaction.js';
|
2026-01-21 13:42:01 +09:00
|
|
|
|
2026-01-21 15:58:08 +09:00
|
|
|
/**
|
|
|
|
|
* 앨범명 또는 폴더명으로 앨범 조회
|
|
|
|
|
* @param {object} db - 데이터베이스 연결
|
|
|
|
|
* @param {string} name - 앨범명 또는 폴더명
|
|
|
|
|
* @returns {object|null} 앨범 정보 또는 null
|
|
|
|
|
*/
|
|
|
|
|
export async function getAlbumByName(db, name) {
|
|
|
|
|
const [albums] = await db.query(
|
|
|
|
|
'SELECT * FROM albums WHERE folder_name = ? OR title = ?',
|
|
|
|
|
[name, name]
|
|
|
|
|
);
|
|
|
|
|
return albums.length > 0 ? albums[0] : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* ID로 앨범 조회
|
|
|
|
|
* @param {object} db - 데이터베이스 연결
|
|
|
|
|
* @param {number} id - 앨범 ID
|
|
|
|
|
* @returns {object|null} 앨범 정보 또는 null
|
|
|
|
|
*/
|
|
|
|
|
export async function getAlbumById(db, id) {
|
|
|
|
|
const [albums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
|
|
|
|
|
return albums.length > 0 ? albums[0] : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 13:42:01 +09:00
|
|
|
/**
|
|
|
|
|
* 앨범 상세 정보 조회 (트랙, 티저, 컨셉포토 포함)
|
|
|
|
|
* @param {object} db - 데이터베이스 연결
|
|
|
|
|
* @param {object} album - 앨범 기본 정보
|
|
|
|
|
* @returns {object} 상세 정보가 포함된 앨범
|
|
|
|
|
*/
|
|
|
|
|
export async function getAlbumDetails(db, album) {
|
2026-01-21 14:12:27 +09:00
|
|
|
// 트랙, 티저, 포토 병렬 조회
|
|
|
|
|
const [[tracks], [teasers], [photos]] = await Promise.all([
|
|
|
|
|
db.query(
|
|
|
|
|
'SELECT * FROM album_tracks WHERE album_id = ? ORDER BY track_number',
|
|
|
|
|
[album.id]
|
|
|
|
|
),
|
|
|
|
|
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]
|
|
|
|
|
),
|
|
|
|
|
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]
|
|
|
|
|
),
|
|
|
|
|
]);
|
2026-01-21 13:42:01 +09:00
|
|
|
|
2026-01-21 14:12:27 +09:00
|
|
|
album.tracks = tracks;
|
2026-01-21 13:42:01 +09:00
|
|
|
album.teasers = teasers;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 앨범 목록과 트랙 조회 (N+1 최적화)
|
|
|
|
|
* @param {object} db - 데이터베이스 연결
|
|
|
|
|
* @returns {array} 트랙 포함된 앨범 목록
|
|
|
|
|
*/
|
|
|
|
|
export async function getAlbumsWithTracks(db) {
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// 모든 트랙을 한 번에 조회
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-01-21 14:22:45 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 트랙 일괄 삽입
|
|
|
|
|
* @param {object} connection - DB 연결
|
|
|
|
|
* @param {number} albumId - 앨범 ID
|
|
|
|
|
* @param {array} tracks - 트랙 목록
|
|
|
|
|
*/
|
|
|
|
|
async function insertTracks(connection, albumId, tracks) {
|
|
|
|
|
if (!tracks || tracks.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const values = tracks.map(track => [
|
|
|
|
|
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.query(
|
|
|
|
|
`INSERT INTO album_tracks
|
|
|
|
|
(album_id, track_number, title, duration, is_title_track, lyricist, composer, arranger, lyrics, music_video_url)
|
|
|
|
|
VALUES ?`,
|
|
|
|
|
[values]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 앨범 생성
|
|
|
|
|
* @param {object} db - 데이터베이스 연결 풀
|
|
|
|
|
* @param {object} data - 앨범 데이터
|
|
|
|
|
* @param {Buffer|null} coverBuffer - 커버 이미지 버퍼
|
|
|
|
|
* @returns {object} 결과 메시지와 앨범 ID
|
|
|
|
|
*/
|
|
|
|
|
export async function createAlbum(db, data, coverBuffer) {
|
|
|
|
|
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
|
|
|
|
|
|
2026-01-21 14:58:07 +09:00
|
|
|
return withTransaction(db, async (connection) => {
|
2026-01-21 14:22:45 +09:00
|
|
|
// 커버 이미지 업로드
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// 트랙 일괄 삽입
|
|
|
|
|
await insertTracks(connection, albumId, tracks);
|
|
|
|
|
|
|
|
|
|
return { message: '앨범이 생성되었습니다.', albumId };
|
2026-01-21 14:58:07 +09:00
|
|
|
});
|
2026-01-21 14:22:45 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 앨범 수정
|
|
|
|
|
* @param {object} db - 데이터베이스 연결 풀
|
|
|
|
|
* @param {number} id - 앨범 ID
|
|
|
|
|
* @param {object} data - 앨범 데이터
|
|
|
|
|
* @param {Buffer|null} coverBuffer - 커버 이미지 버퍼
|
2026-01-21 14:58:07 +09:00
|
|
|
* @returns {object|null} 결과 메시지 또는 null(앨범 없음)
|
2026-01-21 14:22:45 +09:00
|
|
|
*/
|
|
|
|
|
export async function updateAlbum(db, id, data, coverBuffer) {
|
|
|
|
|
const { title, album_type, album_type_short, release_date, folder_name, description, tracks } = data;
|
|
|
|
|
|
2026-01-21 14:58:07 +09:00
|
|
|
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
|
|
|
|
|
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
|
|
|
|
|
if (existingAlbums.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-21 14:22:45 +09:00
|
|
|
|
2026-01-21 14:58:07 +09:00
|
|
|
const existing = existingAlbums[0];
|
2026-01-21 14:22:45 +09:00
|
|
|
|
2026-01-21 14:58:07 +09:00
|
|
|
return withTransaction(db, async (connection) => {
|
2026-01-21 14:22:45 +09:00
|
|
|
// 커버 이미지 처리
|
|
|
|
|
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]);
|
|
|
|
|
await insertTracks(connection, id, tracks);
|
|
|
|
|
|
|
|
|
|
return { message: '앨범이 수정되었습니다.' };
|
2026-01-21 14:58:07 +09:00
|
|
|
});
|
2026-01-21 14:22:45 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 앨범 삭제
|
|
|
|
|
* @param {object} db - 데이터베이스 연결 풀
|
|
|
|
|
* @param {number} id - 앨범 ID
|
|
|
|
|
* @returns {object|null} 결과 메시지 또는 null(앨범 없음)
|
|
|
|
|
*/
|
|
|
|
|
export async function deleteAlbum(db, id) {
|
2026-01-21 14:58:07 +09:00
|
|
|
// 앨범 존재 여부 먼저 확인 (트랜잭션 외부)
|
|
|
|
|
const [existingAlbums] = await db.query('SELECT * FROM albums WHERE id = ?', [id]);
|
|
|
|
|
if (existingAlbums.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-21 14:22:45 +09:00
|
|
|
|
2026-01-21 14:58:07 +09:00
|
|
|
const album = existingAlbums[0];
|
2026-01-21 14:22:45 +09:00
|
|
|
|
2026-01-21 14:58:07 +09:00
|
|
|
return withTransaction(db, async (connection) => {
|
2026-01-21 14:22:45 +09:00
|
|
|
// 커버 이미지 삭제
|
|
|
|
|
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]);
|
|
|
|
|
|
|
|
|
|
return { message: '앨범이 삭제되었습니다.' };
|
2026-01-21 14:58:07 +09:00
|
|
|
});
|
2026-01-21 14:22:45 +09:00
|
|
|
}
|