/* * InvidiousAPI.ts * Copyleft 2025 James Magahern * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published * by the Free Software Foundation, either version 3 of the License, * or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import fetch from 'node-fetch'; interface InvidiousVideoThumbnail { quality: string; url: string; width: number; height: number; } interface InvidiousResult { type: string; title: string; videoId: string; playlistId: string; author: string; videoThumbnails?: InvidiousVideoThumbnail[]; } export interface SearchResult { type: string; title: string; author: string; mediaUrl: string; thumbnailUrl: string; } export interface ThumbnailResponse { data: NodeJS.ReadableStream; contentType: string; } const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true; const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || process.env.INVIDIOUS_URL || 'http://invidious.nor'; const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`; export const getInvidiousSearchURL = (query: string): string => `${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`; export const getInvidiousThumbnailURL = (url: string): string => `${INVIDIOUS_BASE_URL}/${url}`; const preferredThumbnailAPIURL = (thumbnails: InvidiousVideoThumbnail[] | undefined): string => { if (!thumbnails || thumbnails.length === 0) { return '/assets/placeholder.jpg'; } const mediumThumbnail = thumbnails.find(t => t.quality === 'medium'); const thumbnail = mediumThumbnail || thumbnails[0]; return `/api/thumbnail?url=${encodeURIComponent(thumbnail.url)}`; }; const getMediaURL = (result: InvidiousResult): string => { if (result.type === 'video') { return `https://www.youtube.com/watch?v=${result.videoId}`; } else if (result.type === 'playlist') { return `https://www.youtube.com/playlist?list=${result.playlistId}`; } throw new Error(`Unknown result type: ${result.type}`); }; export const searchInvidious = async (query: string): Promise => { try { const response = await fetch(getInvidiousSearchURL(query)); if (!response.ok) { throw new Error(`Invidious HTTP error: ${response.status}`); } const data = await response.json() as Array; return data.filter(item => { return item.type === 'video' || item.type === 'playlist'; }).map(item => ({ type: item.type, title: item.title, author: item.author, mediaUrl: getMediaURL(item), thumbnailUrl: preferredThumbnailAPIURL(item.videoThumbnails) })); } catch (error) { console.error('Failed to search Invidious:', error); throw error; } } export const fetchThumbnail = async (thumbnailUrl: string): Promise => { let path = thumbnailUrl; if (thumbnailUrl.startsWith('http://') || thumbnailUrl.startsWith('https://')) { const url = new URL(thumbnailUrl); path = url.pathname + url.search; } path = path.replace(/^\/+/, ''); // Strip leading slash const response = await fetch(getInvidiousThumbnailURL(path)); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return { data: response.body, contentType: response.headers.get('content-type') || 'image/jpeg' }; };