You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
150 lines
4.5 KiB
150 lines
4.5 KiB
import axios from 'axios'; |
|
import { env } from '../config/env.js'; |
|
|
|
const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3'; |
|
|
|
export function parseChannelInput(input: string): { type: 'id' | 'handle' | 'username', value: string } { |
|
input = input.trim(); |
|
|
|
// Direct channel ID (UC...) |
|
if (/^UC[\w-]{21}[AQgw]$/.test(input)) { |
|
return { type: 'id', value: input }; |
|
} |
|
|
|
// @handle format |
|
if (input.startsWith('@')) { |
|
return { type: 'handle', value: input.substring(1) }; |
|
} |
|
|
|
// Full YouTube URLs |
|
const urlPatterns = [ |
|
{ regex: /youtube\.com\/channel\/(UC[\w-]{21}[AQgw])/, type: 'id' as const }, |
|
{ regex: /youtube\.com\/@([\w-]+)/, type: 'handle' as const }, |
|
{ regex: /youtube\.com\/c\/([\w-]+)/, type: 'username' as const }, |
|
{ regex: /youtube\.com\/user\/([\w-]+)/, type: 'username' as const } |
|
]; |
|
|
|
for (const pattern of urlPatterns) { |
|
const match = input.match(pattern.regex); |
|
if (match) { |
|
return { type: pattern.type, value: match[1] }; |
|
} |
|
} |
|
|
|
throw new Error('Invalid YouTube channel format. Use channel ID, @handle, or valid YouTube URL'); |
|
} |
|
|
|
export async function fetchChannelInfo(input: string) { |
|
const parsed = parseChannelInput(input); |
|
|
|
let params: any = { |
|
part: 'snippet,statistics,contentDetails', |
|
key: env.youtubeApiKey |
|
}; |
|
|
|
// Use appropriate parameter based on input type |
|
if (parsed.type === 'id') { |
|
params.id = parsed.value; |
|
} else if (parsed.type === 'handle') { |
|
params.forHandle = parsed.value; |
|
} else { |
|
params.forUsername = parsed.value; |
|
} |
|
|
|
try { |
|
const response = await axios.get(`${YOUTUBE_API_BASE}/channels`, { params }); |
|
|
|
if (!response.data.items?.length) { |
|
throw new Error('Channel not found on YouTube'); |
|
} |
|
|
|
const channel = response.data.items[0]; |
|
return { |
|
id: channel.id, |
|
name: channel.snippet.title, |
|
customUrl: channel.snippet.customUrl || null, |
|
thumbnailUrl: channel.snippet.thumbnails.high.url, |
|
description: channel.snippet.description, |
|
subscriberCount: parseInt(channel.statistics.subscriberCount || '0'), |
|
videoCount: parseInt(channel.statistics.videoCount || '0'), |
|
uploadsPlaylistId: channel.contentDetails.relatedPlaylists.uploads |
|
}; |
|
} catch (error: any) { |
|
if (error.response?.status === 403) { |
|
throw new Error('YouTube API quota exceeded. Please try again later.'); |
|
} |
|
if (error.response?.status === 400) { |
|
throw new Error('Invalid channel identifier'); |
|
} |
|
throw error; |
|
} |
|
} |
|
|
|
export async function fetchChannelVideos( |
|
uploadsPlaylistId: string, |
|
maxResults: number = 50 |
|
) { |
|
try { |
|
// Step 1: Get video IDs from playlist |
|
const playlistResponse = await axios.get(`${YOUTUBE_API_BASE}/playlistItems`, { |
|
params: { |
|
part: 'contentDetails', |
|
playlistId: uploadsPlaylistId, |
|
maxResults, |
|
key: env.youtubeApiKey |
|
} |
|
}); |
|
|
|
if (!playlistResponse.data.items?.length) { |
|
return []; |
|
} |
|
|
|
const videoIds = playlistResponse.data.items |
|
.map((item: any) => item.contentDetails.videoId) |
|
.join(','); |
|
|
|
// Step 2: Get video details |
|
const videosResponse = await axios.get(`${YOUTUBE_API_BASE}/videos`, { |
|
params: { |
|
part: 'snippet,statistics,contentDetails', |
|
id: videoIds, |
|
key: env.youtubeApiKey |
|
} |
|
}); |
|
|
|
return videosResponse.data.items.map((video: any) => ({ |
|
id: video.id, |
|
title: video.snippet.title, |
|
description: video.snippet.description, |
|
thumbnailUrl: video.snippet.thumbnails.maxresdefault?.url || |
|
video.snippet.thumbnails.high.url, |
|
publishedAt: video.snippet.publishedAt, |
|
viewCount: parseInt(video.statistics.viewCount || '0'), |
|
likeCount: parseInt(video.statistics.likeCount || '0'), |
|
duration: video.contentDetails.duration |
|
})); |
|
} catch (error: any) { |
|
if (error.response?.status === 403) { |
|
throw new Error('YouTube API quota exceeded'); |
|
} |
|
throw error; |
|
} |
|
} |
|
|
|
// Helper to format ISO 8601 duration to readable format |
|
export function formatDuration(isoDuration: string): string { |
|
const match = isoDuration.match(/PT(\d+H)?(\d+M)?(\d+S)?/); |
|
if (!match) return '0:00'; |
|
|
|
const hours = (match[1] || '').replace('H', ''); |
|
const minutes = (match[2] || '').replace('M', ''); |
|
const seconds = (match[3] || '0').replace('S', ''); |
|
|
|
if (hours) { |
|
return `${hours}:${minutes.padStart(2, '0')}:${seconds.padStart(2, '0')}`; |
|
} |
|
return `${minutes || '0'}:${seconds.padStart(2, '0')}`; |
|
} |
|
|
|
|
|
|
|
|