UNPKG

opex-yt-info

Version:

Node.js library for searching YouTube (videos, channels, live), getting video/playlist metadata, and fetching homepage/trending videos.

309 lines (284 loc) 13.9 kB
import ytSearchLib from 'yt-search'; import { getYouTubeVideoId, getYouTubePlaylistId } from 'opex-yt-id'; // --- Helper to Promisify yt-search --- const searchYts = (opts) => new Promise((resolve, reject) => { // Не добавляем pageEnd для videoId/listId запросов, yt-search сам разберется const optionsToSend = { ...opts }; if (!opts.videoId && !opts.listId && !opts.pageEnd) { optionsToSend.pageEnd = 1; // Добавляем только для общих поисковых запросов } ytSearchLib(optionsToSend, (err, data) => { // yt-search может вернуть ошибку в первом аргументе или null/undefined if (err) { reject(err); } else if (!data && (opts.videoId || opts.listId)) { // Если искали по ID и ничего не вернулось (даже не ошибка), считаем не найденным reject(new Error(`${opts.videoId ? 'Video' : 'Playlist'} not found`)); } else { resolve(data); } }); }); // --- Transformation Functions --- // Эта функция теперь будет использоваться и для getVideo function transformYtsVideo(v) { if (!v || typeof v !== 'object' || !v.videoId) return null; // Более строгая проверка // Добавляем 'type' вручную, т.к. yt-search не добавляет его при поиске по ID const type = v.type || 'video'; return { type: type, // Используем существующий или 'video' videoId: v.videoId, url: v.url || `https://www.youtube.com/watch?v=${v.videoId}`, title: v.title || 'Unknown Video', description: v.description || '', image: v.thumbnail || '', thumbnail: v.thumbnail || '', seconds: v.seconds || 0, timestamp: v.timestamp || '0:00', duration: v.duration || { seconds: v.seconds || 0, timestamp: v.timestamp || '0:00', toString: () => v.timestamp || '0:00' }, ago: v.ago || '', views: v.views || 0, author: v.author || { name: 'Unknown', url: '' }, // Поля, которые yt-search может вернуть при поиске по ID genre: v.genre, uploadDate: v.uploadDate, // формат YYYY-MM-DD }; } function transformYtsChannel(c) { if (!c || c.type !== 'channel') return null; return { type: 'channel', name: c.name || c.title || 'Unknown', url: c.url || '', baseUrl: c.baseUrl, id: c.id, title: c.title || c.name || 'Unknown', about: c.descriptionSnippet || c.about || '', image: c.thumbnail || '', thumbnail: c.thumbnail || '', videoCount: c.videoCount || 0, videoCountLabel: c.videoCountLabel || '0 videos', verified: c.verified || false, subCount: c.subCount || 0, subCountLabel: c.subCountLabel || '0 subscribers', }; } function transformYtsLive(l) { if (!l || l.type !== 'live') return null; return { type: 'live', videoId: l.videoId, url: l.url, title: l.title || 'Live Stream', description: l.description || '', image: l.thumbnail || '', thumbnail: l.thumbnail || '', watching: l.watching || 0, author: l.author || { name: 'Unknown', url: '' }, status: l.status || 'LIVE', startTime: l.startTime, startDate: l.startDate, }; } // Эта функция теперь будет использоваться и для getPlaylist function transformYtsPlaylistDetail(p) { if (!p || typeof p !== 'object' || !p.listId) return null; // Строгая проверка const parsePTDuration = (pt) => { if (!pt || typeof pt !== 'string' || !pt.startsWith('PT')) return { seconds: 0, timestamp: '0:00' }; let seconds = 0, minutes = 0, hours = 0; const time = pt.substring(2); const hourMatch = time.match(/(\d+)H/); const minMatch = time.match(/(\d+)M/); const secMatch = time.match(/(\d+)S/); if (hourMatch) hours = parseInt(hourMatch[1], 10); if (minMatch) minutes = parseInt(minMatch[1], 10); if (secMatch) seconds = parseInt(secMatch[1], 10); const totalSeconds = hours * 3600 + minutes * 60 + seconds; let timestamp = ''; if (hours > 0) timestamp += `${hours}:`; if (hours > 0 || minutes > 0) timestamp += `${(hours > 0 ? minutes.toString().padStart(2, '0') : minutes)}:`; timestamp += seconds.toString().padStart(2, '0'); if (hours === 0 && minutes === 0) timestamp = `0:${seconds.toString().padStart(2, '0')}`; // Handle 0:SS return { seconds: totalSeconds, timestamp }; }; return { title: p.title || 'Unknown Playlist', listId: p.listId, url: p.url || `https://www.youtube.com/playlist?list=${p.listId}`, size: p.size ?? (p.videos?.length ?? 0), // yt-search size is reliable here views: p.views || 0, date: p.date, // формат YYYY-MM-DD image: p.image || p.thumbnail || '', thumbnail: p.thumbnail || p.image || '', videos: (p.videos || []).map(pv => { const durationInfo = parsePTDuration(pv.duration); // pv.duration is like "PT4M13S" return { title: pv.title || 'Unknown Video', videoId: pv.videoId, listId: p.listId, thumbnail: pv.thumbnail || '', duration: { seconds: durationInfo.seconds, timestamp: durationInfo.timestamp, toString: () => durationInfo.timestamp }, author: { name: p.author?.name || 'Unknown', url: p.author?.url || '' } // Use playlist author }; }), author: p.author || { name: 'Unknown', url: '' }, }; } // --- Exported API Functions --- // searchVideos, searchChannels, searchLive, searchAll - остаются без изменений, как в предыдущем ответе export async function searchVideos(query, options = {}) { if (!query) return []; try { const ytsOptions = { query, ...options }; const results = await searchYts(ytsOptions); return (results?.videos || []).map(transformYtsVideo).filter(v => v !== null); } catch (error) { console.error(`[opex-yt-info] Error in searchVideos for "${query}": ${error.message}`); return []; } } export async function searchChannels(query, options = {}) { if (!query) return []; try { const ytsOptions = { query, ...options }; const results = await searchYts(ytsOptions); const channels = results?.channels || results?.accounts || []; return channels.map(transformYtsChannel).filter(c => c !== null); } catch (error) { console.error(`[opex-yt-info] Error in searchChannels for "${query}": ${error.message}`); return []; } } export async function searchLive(query, options = {}) { if (!query) return []; try { const ytsOptions = { query, ...options }; const results = await searchYts(ytsOptions); const liveResults = results?.live || []; const upcomingFromVideos = (results?.videos || []) .filter(v => v.type === 'video' && v.status === 'UPCOMING') .map(transformYtsLive); const combined = [...liveResults.map(transformYtsLive), ...upcomingFromVideos]; const uniqueLive = Array.from(new Map(combined.filter(l => l !== null).map(item => [item.videoId, item])).values()); return uniqueLive; } catch (error) { console.error(`[opex-yt-info] Error in searchLive for "${query}": ${error.message}`); return []; } } export async function searchAll(query, options = {}) { if (!query) return []; try { const ytsOptions = { query, ...options }; const results = await searchYts(ytsOptions); const videos = (results?.videos || []).map(transformYtsVideo).filter(v => v !== null); const channels = (results?.channels || results?.accounts || []).map(transformYtsChannel).filter(c => c !== null); const live = (results?.live || []).map(transformYtsLive).filter(l => l !== null); const playlists = (results?.playlists || results?.lists || []).map(p => ({ type: 'list', listId: p.listId, url: p.url, title: p.title || 'Unknown Playlist', thumbnail: p.thumbnail || '', image: p.thumbnail || '', videoCount: typeof p.videoCount === 'number' ? p.videoCount : parseInt(String(p.videoCount).replace(/[^0-9]/g, ''), 10) || 0, author: p.author || { name: 'Unknown', url: '' }, })).filter(p => p !== null && p.listId); const liveIds = new Set(live.map(l => l.videoId)); const filteredVideos = videos.filter(v => !liveIds.has(v.videoId)); return [...filteredVideos, ...channels, ...live, ...playlists]; } catch (error) { console.error(`[opex-yt-info] Error in searchAll for "${query}": ${error.message}`); return []; } } /** * Gets basic metadata for a specific YouTube video using yt-search. * Accepts either a Video ID or a YouTube Video URL. * @param {string} videoIdOrUrl The ID or URL of the video. * @param {import('./index').GetOptions} [options] Options (hl, gl, userAgent). * @returns {Promise<import('./index').VideoDetail | null>} Basic video details or null if not found/error. */ export async function getVideo(videoIdOrUrl, options = {}) { if (!videoIdOrUrl) { console.error(`[opex-yt-info] No video ID or URL provided to getVideo.`); return null; } let videoId = videoIdOrUrl; if (videoIdOrUrl.includes('youtube.com/') || videoIdOrUrl.includes('youtu.be/')) { const extractedId = getYouTubeVideoId(videoIdOrUrl); if (extractedId) { videoId = extractedId; } else { console.error(`[opex-yt-info] Could not extract a valid Video ID from URL: ${videoIdOrUrl}`); return null; } } if (!/^[a-zA-Z0-9_-]{11}$/.test(videoId)) { console.error(`[opex-yt-info] Invalid Video ID format: ${videoId}`); return null; } try { // Правильно передаем объект опций в yt-search const ytsOptions = { videoId: videoId, ...options }; const videoData = await searchYts(ytsOptions); // yt-search возвращает объект видео напрямую при успехе // Проверяем, что это объект и ID совпадает if (videoData && typeof videoData === 'object' && videoData.videoId === videoId) { // Используем трансформер, который добавляет недостающие поля (как type) return transformYtsVideo(videoData); } else { // Этот блок не должен вызываться, если yt-search кидает ошибку при ненайденном видео console.warn(`[opex-yt-info] Video not found or unexpected response for ID: ${videoId}`); return null; } } catch (error) { // Ловим ошибку от yt-search (вероятно, "video unavailable" или другая) console.warn(`[opex-yt-info] Failed to get video ${videoId}: ${error.message || error}`); return null; } } /** * Gets basic metadata and a list of videos for a specific YouTube playlist using yt-search. * Accepts either a Playlist ID or a YouTube Playlist URL. * @param {string} playlistIdOrUrl The ID or URL of the playlist. * @param {import('./index').GetOptions} [options] Options (hl, gl, userAgent). * @returns {Promise<import('./index').PlaylistDetail | null>} Basic playlist details or null if not found/error. */ export async function getPlaylist(playlistIdOrUrl, options = {}) { if (!playlistIdOrUrl) { console.error(`[opex-yt-info] No playlist ID or URL provided to getPlaylist.`); return null; } let playlistId = playlistIdOrUrl; if (playlistIdOrUrl.includes('youtube.com/playlist')) { const extractedId = getYouTubePlaylistId(playlistIdOrUrl); if (extractedId) { playlistId = extractedId; } else { // yt-search может не справиться с URL, лучше прекратить console.error(`[opex-yt-info] Could not extract Playlist ID from URL: ${playlistIdOrUrl}`); return null; } } // Проверка формата ID if (!/^(PL|FL|UU|LL|RD|OL)[a-zA-Z0-9_-]{10,}$/.test(playlistId)) { console.warn(`[opex-yt-info] Playlist ID format may be invalid: ${playlistId}. Attempting lookup anyway.`); } try { // Правильно передаем объект опций в yt-search const ytsOptions = { listId: playlistId, ...options }; const playlistData = await searchYts(ytsOptions); // yt-search возвращает объект плейлиста напрямую if (playlistData && typeof playlistData === 'object' && playlistData.listId === playlistId) { return transformYtsPlaylistDetail(playlistData); } else { console.warn(`[opex-yt-info] Playlist not found or unexpected response for ID: ${playlistId}`); return null; } } catch (error) { console.warn(`[opex-yt-info] Failed to get playlist ${playlistId}: ${error.message || error}`); return null; } }