611 lines
20 KiB
JavaScript
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,
|
|
};
|