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, };