- DB: album_teasers 테이블에 video_url 컬럼 추가 - 백엔드: 비디오 업로드 시 ffmpeg로 썸네일 추출 후 WebP 저장 - 백엔드: video_url에 MP4 URL 저장, 썸네일은 기존 URL 필드 사용 - 프론트엔드: 썸네일 이미지 표시, 클릭 시 video_url로 재생 - Flutter 앱: Teaser 모델에 videoUrl 필드 추가 및 비디오 재생 수정 - Docker: ffmpeg 설치 추가 (Dockerfile, docker-compose.dev.yml) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
180 lines
5.6 KiB
JavaScript
180 lines
5.6 KiB
JavaScript
import express from "express";
|
|
import pool from "../lib/db.js";
|
|
|
|
const router = express.Router();
|
|
|
|
// 앨범 상세 정보 조회 헬퍼 함수 (트랙, 티저, 컨셉포토 포함)
|
|
async function getAlbumDetails(album) {
|
|
// 트랙 정보 조회 (가사 포함)
|
|
const [tracks] = await pool.query(
|
|
"SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number",
|
|
[album.id]
|
|
);
|
|
album.tracks = tracks;
|
|
|
|
// 티저 이미지/비디오 조회 (3개 해상도 URL + video_url + media_type 포함)
|
|
const [teasers] = await pool.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;
|
|
|
|
// 컨셉 포토 조회 (멤버 정보 + 3개 해상도 URL + 크기 정보 포함)
|
|
const [photos] = await pool.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;
|
|
}
|
|
|
|
// 전체 앨범 조회 (트랙 포함)
|
|
router.get("/", async (req, res) => {
|
|
try {
|
|
const [albums] = await pool.query(
|
|
"SELECT id, title, folder_name, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC"
|
|
);
|
|
|
|
// 각 앨범에 트랙 정보 추가
|
|
for (const album of albums) {
|
|
const [tracks] = await pool.query(
|
|
"SELECT id, track_number, title, is_title_track, duration, lyricist, composer, arranger FROM tracks WHERE album_id = ? ORDER BY track_number",
|
|
[album.id]
|
|
);
|
|
album.tracks = tracks;
|
|
}
|
|
|
|
res.json(albums);
|
|
} catch (error) {
|
|
console.error("앨범 조회 오류:", error);
|
|
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
|
}
|
|
});
|
|
|
|
// 앨범명과 트랙명으로 트랙 상세 조회 (더 구체적인 경로이므로 /by-name/:name보다 앞에 배치)
|
|
router.get("/by-name/:albumName/track/:trackTitle", async (req, res) => {
|
|
try {
|
|
const albumName = decodeURIComponent(req.params.albumName);
|
|
const trackTitle = decodeURIComponent(req.params.trackTitle);
|
|
|
|
// 앨범 조회
|
|
const [albums] = await pool.query(
|
|
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
|
|
[albumName, albumName]
|
|
);
|
|
|
|
if (albums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const album = albums[0];
|
|
|
|
// 해당 앨범의 트랙 조회
|
|
const [tracks] = await pool.query(
|
|
"SELECT * FROM tracks WHERE album_id = ? AND title = ?",
|
|
[album.id, trackTitle]
|
|
);
|
|
|
|
if (tracks.length === 0) {
|
|
return res.status(404).json({ error: "트랙을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const track = tracks[0];
|
|
|
|
// 앨범의 다른 트랙 목록 조회
|
|
const [otherTracks] = await pool.query(
|
|
"SELECT id, track_number, title, is_title_track, duration FROM tracks WHERE album_id = ? ORDER BY track_number",
|
|
[album.id]
|
|
);
|
|
|
|
res.json({
|
|
...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,
|
|
});
|
|
} catch (error) {
|
|
console.error("트랙 조회 오류:", error);
|
|
res.status(500).json({ error: "트랙 정보를 가져오는데 실패했습니다." });
|
|
}
|
|
});
|
|
|
|
// 앨범 folder_name 또는 title로 조회
|
|
router.get("/by-name/:name", async (req, res) => {
|
|
try {
|
|
const name = decodeURIComponent(req.params.name);
|
|
// folder_name 또는 title로 검색 (PC는 title, 모바일은 folder_name 사용)
|
|
const [albums] = await pool.query(
|
|
"SELECT * FROM albums WHERE folder_name = ? OR title = ?",
|
|
[name, name]
|
|
);
|
|
|
|
if (albums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const album = await getAlbumDetails(albums[0]);
|
|
res.json(album);
|
|
} catch (error) {
|
|
console.error("앨범 조회 오류:", error);
|
|
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
|
}
|
|
});
|
|
|
|
// ID로 앨범 조회
|
|
router.get("/:id", async (req, res) => {
|
|
try {
|
|
const [albums] = await pool.query("SELECT * FROM albums WHERE id = ?", [
|
|
req.params.id,
|
|
]);
|
|
|
|
if (albums.length === 0) {
|
|
return res.status(404).json({ error: "앨범을 찾을 수 없습니다." });
|
|
}
|
|
|
|
const album = await getAlbumDetails(albums[0]);
|
|
res.json(album);
|
|
} catch (error) {
|
|
console.error("앨범 조회 오류:", error);
|
|
res.status(500).json({ error: "앨범 정보를 가져오는데 실패했습니다." });
|
|
}
|
|
});
|
|
|
|
export default router;
|