2026-01-19 12:49:29 +09:00
|
|
|
import { fetchVideoInfo } from '../../services/youtube/api.js';
|
|
|
|
|
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
2026-01-21 13:38:25 +09:00
|
|
|
import { CATEGORY_IDS } from '../../config/index.js';
|
2026-01-19 12:49:29 +09:00
|
|
|
|
2026-01-21 13:38:25 +09:00
|
|
|
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
|
2026-01-19 12:49:29 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* YouTube 관련 관리자 라우트
|
|
|
|
|
*/
|
|
|
|
|
export default async function youtubeRoutes(fastify) {
|
|
|
|
|
const { db, meilisearch } = fastify;
|
|
|
|
|
/**
|
|
|
|
|
* GET /api/admin/youtube/video-info
|
|
|
|
|
* YouTube 영상 정보 조회
|
|
|
|
|
*/
|
|
|
|
|
fastify.get('/video-info', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/youtube'],
|
|
|
|
|
summary: 'YouTube 영상 정보 조회',
|
|
|
|
|
security: [{ bearerAuth: [] }],
|
|
|
|
|
querystring: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
url: { type: 'string', description: 'YouTube URL' },
|
|
|
|
|
},
|
|
|
|
|
required: ['url'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { url } = request.query;
|
|
|
|
|
|
|
|
|
|
// YouTube URL에서 video ID 추출
|
|
|
|
|
const videoId = extractVideoId(url);
|
|
|
|
|
if (!videoId) {
|
|
|
|
|
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const video = await fetchVideoInfo(videoId);
|
|
|
|
|
if (!video) {
|
|
|
|
|
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
videoId: video.videoId,
|
|
|
|
|
title: video.title,
|
|
|
|
|
channelId: video.channelId,
|
|
|
|
|
channelName: video.channelTitle,
|
|
|
|
|
publishedAt: video.publishedAt,
|
|
|
|
|
date: video.date,
|
|
|
|
|
time: video.time,
|
|
|
|
|
videoType: video.videoType,
|
|
|
|
|
videoUrl: video.videoUrl,
|
|
|
|
|
};
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
|
|
|
|
|
return reply.code(500).send({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* POST /api/admin/youtube/schedule
|
|
|
|
|
* YouTube 일정 저장
|
|
|
|
|
*/
|
|
|
|
|
fastify.post('/schedule', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/youtube'],
|
|
|
|
|
summary: 'YouTube 일정 저장',
|
|
|
|
|
security: [{ bearerAuth: [] }],
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
videoId: { type: 'string' },
|
|
|
|
|
title: { type: 'string' },
|
|
|
|
|
channelId: { type: 'string' },
|
|
|
|
|
channelName: { type: 'string' },
|
|
|
|
|
date: { type: 'string' },
|
|
|
|
|
time: { type: 'string' },
|
|
|
|
|
videoType: { type: 'string' },
|
|
|
|
|
},
|
|
|
|
|
required: ['videoId', 'title', 'date'],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { videoId, title, channelId, channelName, date, time, videoType } = request.body;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 중복 체크
|
|
|
|
|
const [existing] = await db.query(
|
|
|
|
|
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
|
|
|
|
[videoId]
|
|
|
|
|
);
|
|
|
|
|
if (existing.length > 0) {
|
|
|
|
|
return reply.code(409).send({ error: '이미 등록된 영상입니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// schedules 테이블에 저장
|
|
|
|
|
const [result] = await db.query(
|
|
|
|
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
|
|
|
|
[YOUTUBE_CATEGORY_ID, title, date, time || null]
|
|
|
|
|
);
|
|
|
|
|
const scheduleId = result.insertId;
|
|
|
|
|
|
|
|
|
|
// schedule_youtube 테이블에 저장
|
|
|
|
|
await db.query(
|
|
|
|
|
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
|
|
|
|
[scheduleId, videoId, videoType || 'video', channelId, channelName]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Meilisearch 동기화
|
|
|
|
|
const [categoryRows] = await db.query(
|
|
|
|
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
|
|
|
|
[YOUTUBE_CATEGORY_ID]
|
|
|
|
|
);
|
|
|
|
|
const category = categoryRows[0] || {};
|
|
|
|
|
|
|
|
|
|
await addOrUpdateSchedule(meilisearch, {
|
|
|
|
|
id: scheduleId,
|
|
|
|
|
title,
|
|
|
|
|
date,
|
|
|
|
|
time: time || '',
|
|
|
|
|
category_id: YOUTUBE_CATEGORY_ID,
|
|
|
|
|
category_name: category.name || '',
|
|
|
|
|
category_color: category.color || '',
|
|
|
|
|
source_name: channelName || '',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, scheduleId };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
|
|
|
|
|
return reply.code(500).send({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-20 14:06:02 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* PUT /api/admin/youtube/schedule/:id
|
|
|
|
|
* YouTube 일정 수정 (멤버, 영상 유형 수정 가능)
|
|
|
|
|
*/
|
|
|
|
|
fastify.put('/schedule/:id', {
|
|
|
|
|
schema: {
|
|
|
|
|
tags: ['admin/youtube'],
|
|
|
|
|
summary: 'YouTube 일정 수정',
|
|
|
|
|
security: [{ bearerAuth: [] }],
|
|
|
|
|
params: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
id: { type: 'integer' },
|
|
|
|
|
},
|
|
|
|
|
required: ['id'],
|
|
|
|
|
},
|
|
|
|
|
body: {
|
|
|
|
|
type: 'object',
|
|
|
|
|
properties: {
|
|
|
|
|
memberIds: { type: 'array', items: { type: 'integer' } },
|
|
|
|
|
videoType: { type: 'string', enum: ['video', 'shorts'] },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
preHandler: [fastify.authenticate],
|
|
|
|
|
}, async (request, reply) => {
|
|
|
|
|
const { id } = request.params;
|
|
|
|
|
const { memberIds = [], videoType } = request.body;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// 일정 존재 확인
|
|
|
|
|
const [schedules] = await db.query(
|
|
|
|
|
'SELECT id, title, date, time FROM schedules WHERE id = ? AND category_id = ?',
|
|
|
|
|
[id, YOUTUBE_CATEGORY_ID]
|
|
|
|
|
);
|
|
|
|
|
if (schedules.length === 0) {
|
|
|
|
|
return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 영상 유형 수정
|
|
|
|
|
if (videoType) {
|
|
|
|
|
await db.query(
|
|
|
|
|
'UPDATE schedule_youtube SET video_type = ? WHERE schedule_id = ?',
|
|
|
|
|
[videoType, id]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 기존 멤버 삭제
|
|
|
|
|
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
|
|
|
|
|
|
|
|
|
// 새 멤버 추가
|
|
|
|
|
if (memberIds.length > 0) {
|
|
|
|
|
const values = memberIds.map(memberId => [id, memberId]);
|
|
|
|
|
await db.query(
|
|
|
|
|
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
|
|
|
|
|
[values]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 멤버 이름 조회 (Meilisearch 동기화용)
|
|
|
|
|
let memberNames = '';
|
|
|
|
|
if (memberIds.length > 0) {
|
|
|
|
|
const [members] = await db.query(
|
|
|
|
|
'SELECT name FROM members WHERE id IN (?) ORDER BY id',
|
|
|
|
|
[memberIds]
|
|
|
|
|
);
|
|
|
|
|
memberNames = members.map(m => m.name).join(',');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// YouTube 채널 정보 조회
|
|
|
|
|
const [youtubeInfo] = await db.query(
|
|
|
|
|
'SELECT channel_name FROM schedule_youtube WHERE schedule_id = ?',
|
|
|
|
|
[id]
|
|
|
|
|
);
|
|
|
|
|
const channelName = youtubeInfo[0]?.channel_name || '';
|
|
|
|
|
|
|
|
|
|
// 카테고리 정보 조회
|
|
|
|
|
const [categoryRows] = await db.query(
|
|
|
|
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
|
|
|
|
[YOUTUBE_CATEGORY_ID]
|
|
|
|
|
);
|
|
|
|
|
const category = categoryRows[0] || {};
|
|
|
|
|
|
|
|
|
|
// Meilisearch 동기화
|
|
|
|
|
const schedule = schedules[0];
|
|
|
|
|
await addOrUpdateSchedule(meilisearch, {
|
|
|
|
|
id: schedule.id,
|
|
|
|
|
title: schedule.title,
|
|
|
|
|
date: schedule.date,
|
|
|
|
|
time: schedule.time || '',
|
|
|
|
|
category_id: YOUTUBE_CATEGORY_ID,
|
|
|
|
|
category_name: category.name || '',
|
|
|
|
|
category_color: category.color || '',
|
|
|
|
|
member_names: memberNames,
|
|
|
|
|
source_name: channelName,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
} catch (err) {
|
|
|
|
|
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
|
|
|
|
|
return reply.code(500).send({ error: err.message });
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-01-19 12:49:29 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* YouTube URL에서 video ID 추출
|
|
|
|
|
*/
|
|
|
|
|
function extractVideoId(url) {
|
|
|
|
|
if (!url) return null;
|
|
|
|
|
|
|
|
|
|
const patterns = [
|
|
|
|
|
// https://www.youtube.com/watch?v=VIDEO_ID
|
|
|
|
|
/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
|
|
|
|
|
// https://youtu.be/VIDEO_ID
|
|
|
|
|
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
|
|
|
|
// https://www.youtube.com/shorts/VIDEO_ID
|
|
|
|
|
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
|
|
|
|
// https://www.youtube.com/embed/VIDEO_ID
|
|
|
|
|
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
|
|
|
|
// https://www.youtube.com/v/VIDEO_ID
|
|
|
|
|
/(?:youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const pattern of patterns) {
|
|
|
|
|
const match = url.match(pattern);
|
|
|
|
|
if (match) {
|
|
|
|
|
return match[1];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|