const { addonBuilder } = require('stremio-addon-sdk'); const EasynewsSearcher = require('./easynews-searcher'); const TMDBHandler = require('./tmdb-handler'); const winston = require('winston'); // Logger configuration const logger = winston.createLogger({ level: 'debug', format: winston.format.combine( winston.format.timestamp(), winston.format.printf(({ level, message, timestamp }) => { return `${timestamp} ${level}: ${message}`; }) ), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: 'error.log', level: 'error' }), new winston.transports.File({ filename: 'combined.log' }) ] }); const manifest = { id: 'org.stremio.easynews', version: '1.0.0', name: 'Easynews', description: 'Stream movies and series from Easynews', types: ['movie', 'series'], catalogs: [ { type: 'movie', id: 'easynews-movie-catalog', name: 'Easynews Movies', extra: [{ name: 'search', isRequired: true }] }, { type: 'series', id: 'easynews-series-catalog', name: 'Easynews Series', extra: [{ name: 'search', isRequired: true }] } ], resources: ['stream', 'catalog', 'meta'], idPrefixes: ['tt', 'tmdb', 'easynews'], background: 'https://www.easynews.com/images/logo.png', logo: 'https://www.easynews.com/img/redesign/en-logo.svg', contactEmail: 'none@none.com' }; // Cache for storing search results const searchCache = new Map(); const CACHE_TIMEOUT = 30 * 60 * 1000; // 30 minutes let easynewsSearcher; let tmdbHandler; let easynewsUsername; let easynewsPassword; function generatePoster(content) { const getBackgroundColor = (quality) => { switch(quality) { case '4K': return '#2c3e50'; case '1080p': return '#34495e'; case '720p': return '#2c3e50'; case '480p': return '#7f8c8d'; default: return '#95a5a6'; } }; const bestQuality = Array.from(content.qualities).sort((a, b) => { const order = { '4K': 4, '1080p': 3, '720p': 2, '480p': 1, 'SD': 0 }; return (order[b] || 0) - (order[a] || 0); })[0]; const backgroundColor = getBackgroundColor(bestQuality); // Create multiline text for title let titleLines = []; let words = content.title.split(' '); let currentLine = ''; words.forEach(word => { if ((currentLine + ' ' + word).length > 15) { titleLines.push(currentLine); currentLine = word; } else { currentLine = currentLine ? currentLine + ' ' + word : word; } }); if (currentLine) { titleLines.push(currentLine); } if (titleLines.length > 3) { titleLines = titleLines.slice(0, 3); titleLines[2] += '...'; } return ` ${bestQuality} ${titleLines.map((line, i) => `${line}` ).join('')} ${content.type === 'series' ? `S${content.season.toString().padStart(2, '0')}E${content.episode.toString().padStart(2, '0')}` : content.year || ''} ${content.streams.length} sources ${Array.from(content.qualities).join(', ')} `.trim(); } const builder = new addonBuilder(manifest); builder.defineCatalogHandler(async ({ type, id, extra }) => { if (!easynewsSearcher) { logger.warn('Easynews searcher not initialized'); return { metas: [] }; } const { search } = extra; if (!search) { logger.info('No search query provided'); return { metas: [] }; } logger.info(`Searching Easynews catalog for: ${search}`); try { const results = await easynewsSearcher.search(search); if (!results || results.length === 0) { return { metas: [] }; } // Group results by content const groupedContent = new Map(); results.forEach(result => { const isSeries = result.season !== null && result.episode !== null; if ((type === 'movie' && isSeries) || (type === 'series' && !isSeries)) { return; } let key; if (isSeries) { key = `${result.title} S${result.season.toString().padStart(2, '0')}E${result.episode.toString().padStart(2, '0')}`; } else { key = result.year ? `${result.title} (${result.year})` : result.title; } if (!groupedContent.has(key)) { groupedContent.set(key, { title: result.title, year: result.year, season: result.season, episode: result.episode, type: isSeries ? 'series' : 'movie', streams: [], qualities: new Set() }); } const content = groupedContent.get(key); content.streams.push(result); content.qualities.add(result.quality); }); // Store in cache and create metas const metas = Array.from(groupedContent.entries()).map(([key, content]) => { const cacheKey = `easynews:${encodeURIComponent(key)}`; searchCache.set(cacheKey, { streams: content.streams, timestamp: Date.now() }); const bestQuality = Array.from(content.qualities).sort((a, b) => { const order = { '4K': 4, '1080p': 3, '720p': 2, '480p': 1, 'SD': 0 }; return (order[b] || 0) - (order[a] || 0); })[0]; // Enhanced meta object for better Android compatibility return { id: cacheKey, type: content.type, name: content.title, year: content.year, released: content.year ? `${content.year}-01-01` : undefined, runtime: "120", genres: content.type === 'movie' ? ['Movie'] : ['Series'], background: null, logo: null, posterShape: 'regular', poster: `data:image/svg+xml;base64,${Buffer.from(generatePoster(content)).toString('base64')}`, description: `Quality: ${bestQuality}\nSources: ${content.streams.length}\nAvailable in: ${Array.from(content.qualities).join(', ')}`, releaseInfo: content.year ? content.year.toString() : undefined, imdbRating: null, videos: content.type === 'series' ? [{ id: cacheKey, title: `Season ${content.season} Episode ${content.episode}`, season: content.season, episode: content.episode, released: content.year ? `${content.year}-01-01` : undefined }] : undefined }; }); // Sort by most recent year first metas.sort((a, b) => (b.year || 0) - (a.year || 0)); return { metas }; } catch (error) { logger.error(`Error in catalog handler: ${error.message}`); return { metas: [] }; } }); builder.defineStreamHandler(async ({ type, id }) => { if (!easynewsSearcher) { logger.warn('Easynews searcher not initialized'); return { streams: [] }; } try { let results = []; if (id.startsWith('easynews:')) { // Check cache first const cached = searchCache.get(id); if (cached && (Date.now() - cached.timestamp) < CACHE_TIMEOUT) { results = cached.streams; } else { // If not in cache or expired, do a new search const searchTerm = decodeURIComponent(id.replace('easynews:', '')); results = await easynewsSearcher.search(searchTerm); } } else if ((id.startsWith('tt') || id.startsWith('tmdb')) && tmdbHandler) { try { const [baseId, seasonNum, episodeNum] = id.split(':'); const metadata = await tmdbHandler.getMetadata(baseId, type); if (!metadata) { logger.error(`Unable to find metadata for ${baseId}`); return { streams: [] }; } const title = metadata.title || metadata.name; let year = null; if (type === 'movie' && metadata.release_date) { year = new Date(metadata.release_date).getFullYear(); } else if (type === 'series' && metadata.first_air_date) { year = new Date(metadata.first_air_date).getFullYear(); } let searchTerm; if (type === 'series' && seasonNum && episodeNum) { searchTerm = `${title} S${seasonNum.padStart(2, '0')}E${episodeNum.padStart(2, '0')}`; } else if (type === 'movie') { searchTerm = year ? `${title} ${year}` : title; } else { searchTerm = title; } results = await easynewsSearcher.search(searchTerm); if (results.length === 0 && type === 'movie' && year) { results = await easynewsSearcher.search(title); } } catch (error) { logger.error(`TMDB error: ${error.message}. Falling back to ID-based search.`); results = await handleIdBasedSearch(id, type); } } else { results = await handleIdBasedSearch(id, type); } const streams = results.map(result => ({ title: result.filename, name: `${result.quality} ${result.qualityEmoji} [${result.fileSize}]`, url: result.linkUrl, behaviorHints: { notWebReady: true, bingeGroup: `easynews-${result.quality}`, proxyHeaders: { request: { "User-Agent": "VLC/3.0.0", "Authorization": `Basic ${Buffer.from(`${easynewsUsername}:${easynewsPassword}`).toString('base64')}` } } }, stream_quality: result.quality, size: result.fileSize, availability: 1 })); // Sort streams by quality streams.sort((a, b) => { const qualityOrder = { '4K': 5, '1080p': 4, '720p': 3, '480p': 2, 'SD': 1 }; const qualityA = a.name.match(/(4K|1080p|720p|480p|SD)/i)?.[1]?.toUpperCase() || 'SD'; const qualityB = b.name.match(/(4K|1080p|720p|480p|SD)/i)?.[1]?.toUpperCase() || 'SD'; return (qualityOrder[qualityB] || 0) - (qualityOrder[qualityA] || 0); }); return { streams }; } catch (error) { logger.error(`Error in stream handler: ${error.message}`); return { streams: [] }; } }); // Helper function to handle ID-based searches when TMDB is not available async function handleIdBasedSearch(id, type) { const [baseId, seasonNum, episodeNum] = id.split(':'); let searchTerm; if (type === 'series' && seasonNum && episodeNum) { searchTerm = `${baseId} S${seasonNum.padStart(2, '0')}E${episodeNum.padStart(2, '0')}`; } else { searchTerm = baseId; } return await easynewsSearcher.search(searchTerm); } function setConfiguration(config) { try { logger.info('Received configuration:', JSON.stringify({ ...config, username: config.username ? '[REDACTED]' : undefined, password: config.password ? '[REDACTED]' : undefined })); const { username, password } = config; easynewsUsername = username; easynewsPassword = password; easynewsSearcher = new EasynewsSearcher(username, password); const TMDB_API_KEY = process.env.TMDB_API_KEY || 'f051e7366c6105ad4f9aafe4733d9dae'; if (TMDB_API_KEY) { try { tmdbHandler = new TMDBHandler(TMDB_API_KEY); logger.info('TMDB handler initialized with API key'); } catch (error) { logger.warn('Failed to initialize TMDB handler:', error.message); tmdbHandler = null; } } else { logger.warn('No TMDB API key available - TMDB features will be disabled'); tmdbHandler = null; } logger.info('Configuration set for Easynews searcher'); return builder.getInterface(); } catch (error) { logger.error('Error in setConfiguration:', error); throw error; } } builder.defineMetaHandler(async ({ type, id }) => { logger.info(`Meta request for ${type}, id: ${id}`); try { // For cached Easynews results if (id.startsWith('easynews:')) { const cached = searchCache.get(id); if (cached && (Date.now() - cached.timestamp) < CACHE_TIMEOUT) { const result = cached.streams[0]; // Use first stream for metadata return { meta: { id, type: result.season ? 'series' : 'movie', name: result.title, year: result.year, runtime: "120", genres: result.season ? ['Series'] : ['Movie'], posterShape: 'regular', background: null, logo: null, description: `Available qualities: ${Array.from(new Set(cached.streams.map(s => s.quality))).join(', ')}\nTotal sources: ${cached.streams.length}`, releaseInfo: result.year ? result.year.toString() : undefined, imdbRating: null, released: result.year ? `${result.year}-01-01` : undefined, videos: result.season ? [{ id: id, title: `Season ${result.season} Episode ${result.episode}`, season: result.season, episode: result.episode, released: result.year ? `${result.year}-01-01` : undefined }] : undefined } }; } } // For TMDB/IMDB IDs if ((id.startsWith('tt') || id.startsWith('tmdb')) && tmdbHandler) { const [baseId, seasonNum, episodeNum] = id.split(':'); const metadata = await tmdbHandler.getMetadata(baseId, type); if (metadata) { const meta = { id: baseId, type: type, name: metadata.title || metadata.name, year: metadata.release_date ? new Date(metadata.release_date).getFullYear() : metadata.first_air_date ? new Date(metadata.first_air_date).getFullYear() : null, runtime: metadata.runtime ? metadata.runtime.toString() : "120", genres: metadata.genres ? metadata.genres.map(g => g.name) : [], posterShape: 'regular', background: metadata.backdrop_path ? `https://image.tmdb.org/t/p/original${metadata.backdrop_path}` : null, logo: null, description: metadata.overview, releaseInfo: metadata.release_date || metadata.first_air_date, imdbRating: metadata.vote_average ? metadata.vote_average.toFixed(1) : null, released: metadata.release_date || metadata.first_air_date, }; if (type === 'series' && seasonNum && episodeNum) { meta.videos = [{ id: id, title: `Season ${seasonNum} Episode ${episodeNum}`, season: parseInt(seasonNum), episode: parseInt(episodeNum), released: meta.released }]; } return { meta }; } } return { meta: null }; } catch (error) { logger.error(`Error in meta handler: ${error.message}`); return { meta: null }; } }); module.exports = { setConfiguration };