import config from '../../config/index.js'; import { formatDate, formatTime } from '../../utils/date.js'; const API_KEY = config.google.apiKey; const API_BASE = 'https://www.googleapis.com/youtube/v3'; /** * ISO 8601 duration (PT1M30S) → 초 변환 */ function parseDuration(duration) { const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return 0; return ( parseInt(match[1] || 0) * 3600 + parseInt(match[2] || 0) * 60 + parseInt(match[3] || 0) ); } /** * 영상 URL 생성 */ function getVideoUrl(videoId, isShorts) { return isShorts ? `https://www.youtube.com/shorts/${videoId}` : `https://www.youtube.com/watch?v=${videoId}`; } /** * 채널의 업로드 플레이리스트 ID 조회 */ export async function getUploadsPlaylistId(channelId) { const url = `${API_BASE}/channels?part=contentDetails&id=${channelId}&key=${API_KEY}`; const res = await fetch(url); const data = await res.json(); if (data.error) { throw new Error(data.error.message); } if (!data.items?.length) { throw new Error('채널을 찾을 수 없습니다'); } return data.items[0].contentDetails.relatedPlaylists.uploads; } /** * 채널 정보 조회 (배너 이미지 포함) */ export async function getChannelInfo(channelId) { const url = `${API_BASE}/channels?part=snippet,brandingSettings&id=${channelId}&key=${API_KEY}`; const res = await fetch(url); const data = await res.json(); if (data.error) { throw new Error(data.error.message); } if (!data.items?.length) { throw new Error('채널을 찾을 수 없습니다'); } const channel = data.items[0]; const { snippet, brandingSettings } = channel; return { channelId, title: snippet.title, description: snippet.description, thumbnailUrl: snippet.thumbnails?.high?.url || snippet.thumbnails?.default?.url, bannerUrl: brandingSettings?.image?.bannerExternalUrl || null, }; } /** * 영상 ID 목록으로 duration 조회 (Shorts 판별용) */ async function getVideoDurations(videoIds) { const url = `${API_BASE}/videos?part=contentDetails&id=${videoIds.join(',')}&key=${API_KEY}`; const res = await fetch(url); const data = await res.json(); const durations = {}; if (data.items) { for (const v of data.items) { const seconds = parseDuration(v.contentDetails.duration); durations[v.id] = seconds <= 60; } } return durations; } /** * 최근 N개 영상 조회 * @param {string} channelId - 채널 ID * @param {number} maxResults - 최대 결과 수 * @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택) */ export async function fetchRecentVideos(channelId, maxResults = 10, uploadsPlaylistId = null) { const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId); const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=${maxResults}&key=${API_KEY}`; const res = await fetch(url); const data = await res.json(); if (data.error) { throw new Error(data.error.message); } const videoIds = data.items.map(item => item.snippet.resourceId.videoId); const shortsMap = await getVideoDurations(videoIds); return data.items.map(item => { const { snippet } = item; const videoId = snippet.resourceId.videoId; const isShorts = shortsMap[videoId] || false; const publishedAt = new Date(snippet.publishedAt); return { videoId, title: snippet.title, description: snippet.description || '', channelId: snippet.channelId, channelTitle: snippet.channelTitle, publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), videoType: isShorts ? 'shorts' : 'video', videoUrl: getVideoUrl(videoId, isShorts), }; }); } /** * 전체 영상 조회 (페이지네이션) * @param {string} channelId - 채널 ID * @param {string} uploadsPlaylistId - 캐싱된 uploads playlist ID (선택) */ export async function fetchAllVideos(channelId, uploadsPlaylistId = null) { const uploadsId = uploadsPlaylistId || await getUploadsPlaylistId(channelId); const videos = []; let pageToken = ''; do { const url = `${API_BASE}/playlistItems?part=snippet&playlistId=${uploadsId}&maxResults=50&key=${API_KEY}${pageToken ? `&pageToken=${pageToken}` : ''}`; const res = await fetch(url); const data = await res.json(); if (data.error) { throw new Error(data.error.message); } const videoIds = data.items.map(item => item.snippet.resourceId.videoId); const shortsMap = await getVideoDurations(videoIds); for (const item of data.items) { const { snippet } = item; const videoId = snippet.resourceId.videoId; const isShorts = shortsMap[videoId] || false; const publishedAt = new Date(snippet.publishedAt); videos.push({ videoId, title: snippet.title, description: snippet.description || '', channelId: snippet.channelId, channelTitle: snippet.channelTitle, publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), videoType: isShorts ? 'shorts' : 'video', videoUrl: getVideoUrl(videoId, isShorts), }); } pageToken = data.nextPageToken || ''; } while (pageToken); // 과거순 정렬 videos.sort((a, b) => a.publishedAt - b.publishedAt); return videos; } /** * 단일 영상 정보 조회 */ export async function fetchVideoInfo(videoId) { const url = `${API_BASE}/videos?part=snippet,contentDetails&id=${videoId}&key=${API_KEY}`; const res = await fetch(url); const data = await res.json(); if (!data.items?.length) { return null; } const video = data.items[0]; const { snippet, contentDetails } = video; const seconds = parseDuration(contentDetails.duration); const isShorts = seconds > 0 && seconds <= 60; const publishedAt = new Date(snippet.publishedAt); return { videoId, title: snippet.title, description: snippet.description || '', channelId: snippet.channelId, channelTitle: snippet.channelTitle, publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), videoType: isShorts ? 'shorts' : 'video', videoUrl: getVideoUrl(videoId, isShorts), }; }