CASTAD-v0.1/server/youtubeService.js

611 lines
20 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const { google } = require('googleapis');
const db = require('./db');
// 클라이언트 시크릿 파일 (Google Cloud Console에서 다운로드)
const CREDENTIALS_PATH = path.join(__dirname, 'client_secret.json');
const SCOPES = [
'https://www.googleapis.com/auth/youtube.upload',
'https://www.googleapis.com/auth/youtube',
'https://www.googleapis.com/auth/youtube.readonly',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile'
];
/**
* Google OAuth2 클라이언트 생성
* @param {string} redirectUri - 콜백 URI
*/
function createOAuth2Client(redirectUri = null) {
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error("client_secret.json 파일이 없습니다. Google Cloud Console에서 다운로드하여 server 폴더에 넣어주세요.");
}
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const credentials = JSON.parse(content);
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
const finalRedirectUri = redirectUri || redirect_uris[0] || 'http://localhost:3001/api/youtube/oauth/callback';
return new google.auth.OAuth2(client_id, client_secret, finalRedirectUri);
}
/**
* OAuth 인증 URL 생성
* @param {number} userId - 사용자 ID (state 파라미터로 전달)
* @param {string} redirectUri - 콜백 URI
*/
function generateAuthUrl(userId, redirectUri = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent', // 항상 refresh_token 받기 위해
state: JSON.stringify({ userId }) // 콜백에서 사용자 식별용
});
return authUrl;
}
/**
* 인증 코드로 토큰 교환 및 저장
* @param {string} code - 인증 코드
* @param {number} userId - 사용자 ID
* @param {string} redirectUri - 콜백 URI
*/
async function exchangeCodeForTokens(code, userId, redirectUri = null) {
const oAuth2Client = createOAuth2Client(redirectUri);
try {
const { tokens } = await oAuth2Client.getToken(code);
oAuth2Client.setCredentials(tokens);
// 사용자 정보 가져오기
const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client });
const userInfo = await oauth2.userinfo.get();
// YouTube 채널 정보 가져오기
const youtube = google.youtube({ version: 'v3', auth: oAuth2Client });
const channelRes = await youtube.channels.list({
part: 'snippet',
mine: true
});
const channel = channelRes.data.items?.[0];
// DB에 저장
const tokenExpiry = tokens.expiry_date ? new Date(tokens.expiry_date).toISOString() : null;
return new Promise((resolve, reject) => {
db.run(`
INSERT OR REPLACE INTO youtube_connections
(user_id, google_user_id, google_email, youtube_channel_id, youtube_channel_title,
access_token, refresh_token, token_expiry, scopes, connected_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
`, [
userId,
userInfo.data.id,
userInfo.data.email,
channel?.id || null,
channel?.snippet?.title || null,
tokens.access_token,
tokens.refresh_token,
tokenExpiry,
SCOPES.join(',')
], function(err) {
if (err) {
console.error('[YouTube OAuth] 토큰 저장 실패:', err);
reject(err);
} else {
// 기본 설정도 생성
db.run(`
INSERT OR IGNORE INTO youtube_settings (user_id) VALUES (?)
`, [userId]);
resolve({
success: true,
channelId: channel?.id,
channelTitle: channel?.snippet?.title,
email: userInfo.data.email
});
}
});
});
} catch (error) {
console.error('[YouTube OAuth] 토큰 교환 실패:', error);
throw error;
}
}
/**
* 사용자별 인증된 클라이언트 가져오기
* @param {number} userId - 사용자 ID
*/
async function getAuthenticatedClientForUser(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT * FROM youtube_connections WHERE user_id = ?
`, [userId], async (err, row) => {
if (err) {
reject(err);
return;
}
if (!row) {
reject(new Error('YouTube 채널이 연결되지 않았습니다. 설정에서 채널을 연결해주세요.'));
return;
}
const oAuth2Client = createOAuth2Client();
oAuth2Client.setCredentials({
access_token: row.access_token,
refresh_token: row.refresh_token,
expiry_date: row.token_expiry ? new Date(row.token_expiry).getTime() : null
});
// 토큰 만료 체크 및 갱신
try {
const tokenInfo = await oAuth2Client.getAccessToken();
// 새 토큰으로 갱신되었으면 DB 업데이트
if (tokenInfo.token !== row.access_token) {
const credentials = oAuth2Client.credentials;
db.run(`
UPDATE youtube_connections
SET access_token = ?, token_expiry = ?
WHERE user_id = ?
`, [
credentials.access_token,
credentials.expiry_date ? new Date(credentials.expiry_date).toISOString() : null,
userId
]);
}
resolve(oAuth2Client);
} catch (refreshError) {
console.error('[YouTube] 토큰 갱신 실패:', refreshError);
// 연결 해제 처리
db.run(`DELETE FROM youtube_connections WHERE user_id = ?`, [userId]);
reject(new Error('YouTube 인증이 만료되었습니다. 다시 연결해주세요.'));
}
});
});
}
/**
* 사용자의 YouTube 연결 상태 확인
*/
function getConnectionStatus(userId) {
return new Promise((resolve, reject) => {
db.get(`
SELECT youtube_channel_id, youtube_channel_title, google_email, connected_at
FROM youtube_connections WHERE user_id = ?
`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
}
/**
* YouTube 연결 해제
*/
function disconnectYouTube(userId) {
return new Promise((resolve, reject) => {
db.run(`DELETE FROM youtube_connections WHERE user_id = ?`, [userId], function(err) {
if (err) reject(err);
else resolve({ success: true, deleted: this.changes });
});
});
}
/**
* 사용자별 비디오 업로드
* @param {number} userId - 사용자 ID
* @param {string} videoPath - 비디오 파일 경로
* @param {object} seoData - SEO 메타데이터
* @param {object} options - 업로드 옵션
*/
async function uploadVideoForUser(userId, videoPath, seoData, options = {}) {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
// 사용자 설정 가져오기
const settings = await getUserYouTubeSettings(userId);
const fileSize = fs.statSync(videoPath).size;
// SEO 데이터 + 기본 설정 병합
const title = (seoData.title || 'CastAD 생성 영상').substring(0, 100);
const description = (seoData.description || '').substring(0, 5000);
const tags = [...(seoData.tags || []), ...(settings.default_tags ? JSON.parse(settings.default_tags) : [])];
const categoryId = options.categoryId || settings.default_category_id || '19';
const privacyStatus = options.privacyStatus || settings.default_privacy || 'private';
const res = await youtube.videos.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title,
description,
tags: tags.slice(0, 500), // YouTube 태그 제한
categoryId,
},
status: {
privacyStatus,
selfDeclaredMadeForKids: false,
},
},
media: {
body: fs.createReadStream(videoPath),
},
}, {
onUploadProgress: evt => {
const progress = (evt.bytesRead / fileSize) * 100;
if (options.onProgress) options.onProgress(progress);
},
});
const videoId = res.data.id;
const youtubeUrl = `https://youtu.be/${videoId}`;
console.log(`[YouTube] 업로드 성공! ${youtubeUrl}`);
// 플레이리스트에 추가
const playlistId = options.playlistId || settings.default_playlist_id;
if (playlistId) {
try {
await addVideoToPlaylist(youtube, playlistId, videoId);
console.log(`[YouTube] 플레이리스트(${playlistId})에 추가 완료`);
} catch (playlistError) {
console.error('[YouTube] 플레이리스트 추가 실패:', playlistError.message);
}
}
// 고정 댓글 달기
if (seoData.pinnedComment) {
try {
await postPinnedComment(youtube, videoId, seoData.pinnedComment);
console.log('[YouTube] 고정 댓글 추가 완료');
} catch (commentError) {
console.error('[YouTube] 고정 댓글 실패:', commentError.message);
}
}
// 업로드 히스토리 저장
db.run(`
INSERT INTO upload_history
(user_id, history_id, youtube_video_id, youtube_url, title, privacy_status, playlist_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed')
`, [userId, options.historyId || null, videoId, youtubeUrl, title, privacyStatus, playlistId]);
return { videoId, url: youtubeUrl };
} catch (error) {
console.error('[YouTube] 업로드 오류:', error.message);
// 에러 기록
db.run(`
INSERT INTO upload_history
(user_id, history_id, status, error_message)
VALUES (?, ?, 'failed', ?)
`, [userId, options.historyId || null, error.message]);
throw error;
}
}
/**
* 고정 댓글 달기
*/
async function postPinnedComment(youtube, videoId, commentText) {
const res = await youtube.commentThreads.insert({
part: 'snippet',
requestBody: {
snippet: {
videoId,
topLevelComment: {
snippet: {
textOriginal: commentText
}
}
}
}
});
// 댓글 고정 (채널 소유자만 가능)
// Note: YouTube API에서 직접 고정은 지원하지 않음, 수동으로 해야 함
return res.data;
}
/**
* 플레이리스트에 비디오 추가
*/
async function addVideoToPlaylist(youtube, playlistId, videoId) {
await youtube.playlistItems.insert({
part: 'snippet',
requestBody: {
snippet: {
playlistId,
resourceId: {
kind: 'youtube#video',
videoId,
},
},
},
});
}
/**
* 사용자의 플레이리스트 목록 조회
*/
async function getPlaylistsForUser(userId) {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.list({
part: 'snippet,contentDetails',
mine: true,
maxResults: 50,
});
const playlists = res.data.items.map(item => ({
id: item.id,
title: item.snippet.title,
description: item.snippet.description,
itemCount: item.contentDetails.itemCount,
thumbnail: item.snippet.thumbnails?.default?.url,
}));
// 캐시 업데이트
playlists.forEach(p => {
db.run(`
INSERT OR REPLACE INTO youtube_playlists
(user_id, playlist_id, title, item_count, cached_at)
VALUES (?, ?, ?, ?, datetime('now'))
`, [userId, p.id, p.title, p.itemCount]);
});
return playlists;
} catch (error) {
console.error('[YouTube] 플레이리스트 조회 오류:', error.message);
throw error;
}
}
/**
* 플레이리스트 생성
*/
async function createPlaylistForUser(userId, title, description = '', privacyStatus = 'public') {
try {
const auth = await getAuthenticatedClientForUser(userId);
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: {
title: title.substring(0, 150),
description: description.substring(0, 5000),
},
status: { privacyStatus },
},
});
const playlist = {
id: res.data.id,
title: res.data.snippet.title
};
// 캐시에 추가
db.run(`
INSERT INTO youtube_playlists
(user_id, playlist_id, title, item_count, cached_at)
VALUES (?, ?, ?, 0, datetime('now'))
`, [userId, playlist.id, playlist.title]);
return playlist;
} catch (error) {
console.error('[YouTube] 플레이리스트 생성 오류:', error.message);
throw error;
}
}
/**
* 사용자 YouTube 설정 조회
*/
function getUserYouTubeSettings(userId) {
return new Promise((resolve, reject) => {
db.get(`SELECT * FROM youtube_settings WHERE user_id = ?`, [userId], (err, row) => {
if (err) reject(err);
else resolve(row || {
default_privacy: 'private',
default_category_id: '19',
default_tags: '[]',
auto_upload: 0,
upload_timing: 'manual'
});
});
});
}
/**
* 사용자 YouTube 설정 업데이트
*/
function updateUserYouTubeSettings(userId, settings) {
return new Promise((resolve, reject) => {
const fields = [];
const values = [];
const allowedFields = [
'default_privacy', 'default_category_id', 'default_tags',
'default_hashtags', 'auto_upload', 'upload_timing',
'scheduled_day', 'scheduled_time', 'default_playlist_id', 'notify_on_upload'
];
for (const field of allowedFields) {
if (settings[field] !== undefined) {
fields.push(`${field} = ?`);
values.push(typeof settings[field] === 'object' ? JSON.stringify(settings[field]) : settings[field]);
}
}
if (fields.length === 0) {
resolve({ success: true });
return;
}
values.push(userId);
db.run(`
INSERT INTO youtube_settings (user_id, ${allowedFields.map(f => f).join(', ')})
VALUES (?, ${allowedFields.map(() => '?').join(', ')})
ON CONFLICT(user_id) DO UPDATE SET ${fields.join(', ')}, updatedAt = datetime('now')
`.replace('INSERT INTO youtube_settings (user_id, ', `UPDATE youtube_settings SET ${fields.join(', ')}, updatedAt = datetime('now') WHERE user_id = ?`).split('ON CONFLICT')[0] + ' WHERE user_id = ?', values, function(err) {
if (err) {
// INSERT 시도
db.run(`
INSERT OR REPLACE INTO youtube_settings (user_id, ${fields.map(f => f.split(' = ')[0]).join(', ')})
VALUES (?, ${fields.map(() => '?').join(', ')})
`, [userId, ...values.slice(0, -1)], function(err2) {
if (err2) reject(err2);
else resolve({ success: true });
});
} else {
resolve({ success: true });
}
});
});
}
/**
* 사용자의 업로드 히스토리 조회
*/
function getUploadHistory(userId, limit = 20) {
return new Promise((resolve, reject) => {
db.all(`
SELECT * FROM upload_history
WHERE user_id = ?
ORDER BY uploaded_at DESC
LIMIT ?
`, [userId, limit], (err, rows) => {
if (err) reject(err);
else resolve(rows || []);
});
});
}
// ============================================
// Legacy 함수들 (기존 코드 호환용)
// ============================================
const TOKEN_PATH = path.join(__dirname, 'tokens.json');
async function getAuthenticatedClient() {
if (!fs.existsSync(CREDENTIALS_PATH)) {
throw new Error("client_secret.json 파일이 없습니다.");
}
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf-8');
const credentials = JSON.parse(content);
const { client_secret, client_id, redirect_uris } = credentials.installed || credentials.web;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, "http://localhost:3001/oauth2callback");
if (fs.existsSync(TOKEN_PATH)) {
const token = fs.readFileSync(TOKEN_PATH, 'utf-8');
oAuth2Client.setCredentials(JSON.parse(token));
return oAuth2Client;
}
throw new Error("YouTube 인증 토큰이 없습니다.");
}
async function uploadVideo(videoPath, seoData, playlistId = null, privacyStatus = 'public') {
// Legacy: 기존 단일 계정 방식
try {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const fileSize = fs.statSync(videoPath).size;
const title = (seoData.title || 'CastAD 생성 영상').substring(0, 100);
const description = (seoData.description || '').substring(0, 5000);
const tags = seoData.tags || ['AI', 'CastAD'];
const res = await youtube.videos.insert({
part: 'snippet,status',
requestBody: {
snippet: { title, description, tags, categoryId: '22' },
status: { privacyStatus, selfDeclaredMadeForKids: false },
},
media: { body: fs.createReadStream(videoPath) },
});
const videoId = res.data.id;
console.log(`[YouTube] 업로드 성공! https://youtu.be/${videoId}`);
if (playlistId) {
await addVideoToPlaylist(youtube, playlistId, videoId);
}
return { videoId, url: `https://youtu.be/${videoId}` };
} catch (error) {
console.error('[YouTube] 업로드 오류:', error.message);
throw error;
}
}
async function getPlaylists(maxResults = 50) {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.list({ part: 'snippet,contentDetails', mine: true, maxResults });
return res.data.items.map(item => ({
id: item.id,
title: item.snippet.title,
itemCount: item.contentDetails.itemCount,
}));
}
async function createPlaylist(title, description = '', privacyStatus = 'public') {
const auth = await getAuthenticatedClient();
const youtube = google.youtube({ version: 'v3', auth });
const res = await youtube.playlists.insert({
part: 'snippet,status',
requestBody: {
snippet: { title, description },
status: { privacyStatus },
},
});
return { playlistId: res.data.id, title: res.data.snippet.title };
}
module.exports = {
// 새로운 다중 사용자 함수들
createOAuth2Client,
generateAuthUrl,
exchangeCodeForTokens,
getAuthenticatedClientForUser,
getConnectionStatus,
disconnectYouTube,
uploadVideoForUser,
getPlaylistsForUser,
createPlaylistForUser,
getUserYouTubeSettings,
updateUserYouTubeSettings,
getUploadHistory,
SCOPES,
// Legacy 함수들 (기존 코드 호환)
getAuthenticatedClient,
uploadVideo,
getPlaylists,
createPlaylist,
};