|
const { addonBuilder } = require('stremio-addon-sdk'); |
|
const EasynewsSearcher = require('./easynews-searcher'); |
|
const TMDBHandler = require('./tmdb-handler'); |
|
const winston = require('winston'); |
|
|
|
|
|
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: '[email protected]' |
|
}; |
|
|
|
|
|
const searchCache = new Map(); |
|
const CACHE_TIMEOUT = 30 * 60 * 1000; |
|
|
|
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); |
|
|
|
|
|
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 ` |
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 450"> |
|
<rect width="100%" height="100%" fill="${backgroundColor}"/> |
|
<circle cx="150" cy="100" r="50" fill="#e74c3c"/> |
|
<text x="150" y="100" font-family="Arial" font-size="24" fill="white" text-anchor="middle" dominant-baseline="middle">${bestQuality}</text> |
|
${titleLines.map((line, i) => |
|
`<text x="150" y="${220 + i * 30}" font-family="Arial" font-size="24" fill="white" text-anchor="middle">${line}</text>` |
|
).join('')} |
|
<text x="150" y="350" font-family="Arial" font-size="20" fill="white" text-anchor="middle"> |
|
${content.type === 'series' |
|
? `S${content.season.toString().padStart(2, '0')}E${content.episode.toString().padStart(2, '0')}` |
|
: content.year || ''} |
|
</text> |
|
<text x="150" y="400" font-family="Arial" font-size="16" fill="white" text-anchor="middle"> |
|
${content.streams.length} sources |
|
</text> |
|
<text x="150" y="430" font-family="Arial" font-size="14" fill="white" text-anchor="middle"> |
|
${Array.from(content.qualities).join(', ')} |
|
</text> |
|
</svg> |
|
`.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: [] }; |
|
} |
|
|
|
|
|
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); |
|
}); |
|
|
|
|
|
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]; |
|
|
|
|
|
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 |
|
}; |
|
}); |
|
|
|
|
|
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:')) { |
|
|
|
const cached = searchCache.get(id); |
|
if (cached && (Date.now() - cached.timestamp) < CACHE_TIMEOUT) { |
|
results = cached.streams; |
|
} else { |
|
|
|
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 |
|
})); |
|
|
|
|
|
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: [] }; |
|
} |
|
}); |
|
|
|
|
|
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 { |
|
|
|
if (id.startsWith('easynews:')) { |
|
const cached = searchCache.get(id); |
|
if (cached && (Date.now() - cached.timestamp) < CACHE_TIMEOUT) { |
|
const result = cached.streams[0]; |
|
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 |
|
} |
|
}; |
|
} |
|
} |
|
|
|
|
|
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 }; |