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