Compare commits
5 commits
main
...
plugintest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1343da0dde | ||
|
|
335012c792 | ||
|
|
3e5a547bd8 | ||
|
|
3b7c5b85b9 | ||
|
|
6d2d50175a |
16 changed files with 2593 additions and 182 deletions
1
app.json
1
app.json
|
|
@ -85,3 +85,4 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
971
moviesmod.js
Normal file
971
moviesmod.js
Normal file
|
|
@ -0,0 +1,971 @@
|
||||||
|
/**
|
||||||
|
* MoviesMod Provider for Stremio Addon
|
||||||
|
* Supports both movies and TV series
|
||||||
|
*/
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
const cheerio = require('cheerio');
|
||||||
|
const FormData = require('form-data');
|
||||||
|
const { CookieJar } = require('tough-cookie');
|
||||||
|
const { wrapper } = require('axios-cookiejar-support');
|
||||||
|
const { URLSearchParams, URL } = require('url');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const { findBestMatch } = require('string-similarity');
|
||||||
|
|
||||||
|
function escapeRegExp(string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Domain Fetching ---
|
||||||
|
let moviesModDomain = 'https://moviesmod.chat'; // Fallback domain
|
||||||
|
let domainCacheTimestamp = 0;
|
||||||
|
const DOMAIN_CACHE_TTL = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
|
|
||||||
|
async function getMoviesModDomain() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - domainCacheTimestamp < DOMAIN_CACHE_TTL) {
|
||||||
|
return moviesModDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[MoviesMod] Fetching latest domain...');
|
||||||
|
const response = await axios.get('https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json', { timeout: 10000 });
|
||||||
|
if (response.data && response.data.moviesmod) {
|
||||||
|
moviesModDomain = response.data.moviesmod;
|
||||||
|
domainCacheTimestamp = now;
|
||||||
|
console.log(`[MoviesMod] Updated domain to: ${moviesModDomain}`);
|
||||||
|
} else {
|
||||||
|
console.warn('[MoviesMod] Domain JSON fetched, but "moviesmod" key was not found. Using fallback.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Failed to fetch latest domain, using fallback. Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
return moviesModDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Caching Configuration ---
|
||||||
|
const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true';
|
||||||
|
console.log(`[MoviesMod Cache] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`);
|
||||||
|
const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.moviesmod_cache') : path.join(__dirname, '.cache', 'moviesmod');
|
||||||
|
const CACHE_TTL = 4 * 60 * 60 * 1000; // 4 hours in milliseconds
|
||||||
|
|
||||||
|
// --- Caching Helper Functions ---
|
||||||
|
const ensureCacheDir = async () => {
|
||||||
|
if (!CACHE_ENABLED) return;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'EEXIST') {
|
||||||
|
console.error(`[MoviesMod Cache] Error creating cache directory: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFromCache = async (key) => {
|
||||||
|
if (!CACHE_ENABLED) return null;
|
||||||
|
const cacheFile = path.join(CACHE_DIR, `${key}.json`);
|
||||||
|
try {
|
||||||
|
const data = await fs.readFile(cacheFile, 'utf-8');
|
||||||
|
const cached = JSON.parse(data);
|
||||||
|
|
||||||
|
if (Date.now() > cached.expiry) {
|
||||||
|
console.log(`[MoviesMod Cache] EXPIRED for key: ${key}`);
|
||||||
|
await fs.unlink(cacheFile).catch(() => {});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MoviesMod Cache] HIT for key: ${key}`);
|
||||||
|
return cached.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
console.error(`[MoviesMod Cache] READ ERROR for key ${key}: ${error.message}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveToCache = async (key, data) => {
|
||||||
|
if (!CACHE_ENABLED) return;
|
||||||
|
const cacheFile = path.join(CACHE_DIR, `${key}.json`);
|
||||||
|
const cacheData = {
|
||||||
|
expiry: Date.now() + CACHE_TTL,
|
||||||
|
data: data
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await fs.writeFile(cacheFile, JSON.stringify(cacheData, null, 2), 'utf-8');
|
||||||
|
console.log(`[MoviesMod Cache] SAVED for key: ${key}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod Cache] WRITE ERROR for key ${key}: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize cache directory on startup
|
||||||
|
ensureCacheDir();
|
||||||
|
|
||||||
|
// Helper function to extract quality from text
|
||||||
|
function extractQuality(text) {
|
||||||
|
if (!text) return 'Unknown';
|
||||||
|
|
||||||
|
const qualityMatch = text.match(/(480p|720p|1080p|2160p|4k)/i);
|
||||||
|
if (qualityMatch) {
|
||||||
|
return qualityMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from full text
|
||||||
|
const cleanMatch = text.match(/(480p|720p|1080p|2160p|4k)[^)]*\)/i);
|
||||||
|
if (cleanMatch) {
|
||||||
|
return cleanMatch[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQualityForSort(qualityString) {
|
||||||
|
if (!qualityString) return 0;
|
||||||
|
const match = qualityString.match(/(\d{3,4})p/i);
|
||||||
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTechDetails(qualityString) {
|
||||||
|
if (!qualityString) return [];
|
||||||
|
const details = [];
|
||||||
|
const lowerText = qualityString.toLowerCase();
|
||||||
|
if (lowerText.includes('10bit')) details.push('10-bit');
|
||||||
|
if (lowerText.includes('hevc') || lowerText.includes('x265')) details.push('HEVC');
|
||||||
|
if (lowerText.includes('hdr')) details.push('HDR');
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for content on MoviesMod
|
||||||
|
async function searchMoviesMod(query) {
|
||||||
|
try {
|
||||||
|
const baseUrl = await getMoviesModDomain();
|
||||||
|
const searchUrl = `${baseUrl}/?s=${encodeURIComponent(query)}`;
|
||||||
|
const { data } = await axios.get(searchUrl);
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
$('.latestPost').each((i, element) => {
|
||||||
|
const linkElement = $(element).find('a');
|
||||||
|
const title = linkElement.attr('title');
|
||||||
|
const url = linkElement.attr('href');
|
||||||
|
if (title && url) {
|
||||||
|
results.push({ title, url });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error searching: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract download links from a movie/series page
|
||||||
|
async function extractDownloadLinks(moviePageUrl) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(moviePageUrl);
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
const links = [];
|
||||||
|
const contentBox = $('.thecontent');
|
||||||
|
|
||||||
|
// Get all relevant headers (for movies and TV shows) in document order
|
||||||
|
const headers = contentBox.find('h3:contains("Season"), h4');
|
||||||
|
|
||||||
|
headers.each((i, el) => {
|
||||||
|
const header = $(el);
|
||||||
|
const headerText = header.text().trim();
|
||||||
|
|
||||||
|
// Define the content block for this header
|
||||||
|
const blockContent = header.nextUntil('h3, h4');
|
||||||
|
|
||||||
|
if (header.is('h3') && headerText.toLowerCase().includes('season')) {
|
||||||
|
// TV Show Logic
|
||||||
|
const linkElements = blockContent.find('a.maxbutton-episode-links, a.maxbutton-batch-zip');
|
||||||
|
linkElements.each((j, linkEl) => {
|
||||||
|
const buttonText = $(linkEl).text().trim();
|
||||||
|
const linkUrl = $(linkEl).attr('href');
|
||||||
|
if (linkUrl && !buttonText.toLowerCase().includes('batch')) {
|
||||||
|
links.push({
|
||||||
|
quality: `${headerText} - ${buttonText}`,
|
||||||
|
url: linkUrl
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (header.is('h4')) {
|
||||||
|
// Movie Logic
|
||||||
|
const linkElement = blockContent.find('a[href*="modrefer.in"]').first();
|
||||||
|
if (linkElement.length > 0) {
|
||||||
|
const link = linkElement.attr('href');
|
||||||
|
const cleanQuality = extractQuality(headerText);
|
||||||
|
links.push({
|
||||||
|
quality: cleanQuality,
|
||||||
|
url: link
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return links;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error extracting download links: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve intermediate links (dramadrip, episodes.modpro.blog, modrefer.in)
|
||||||
|
async function resolveIntermediateLink(initialUrl, refererUrl, quality) {
|
||||||
|
try {
|
||||||
|
const urlObject = new URL(initialUrl);
|
||||||
|
|
||||||
|
if (urlObject.hostname.includes('dramadrip.com')) {
|
||||||
|
const { data: dramaData } = await axios.get(initialUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const $$ = cheerio.load(dramaData);
|
||||||
|
|
||||||
|
let episodePageLink = null;
|
||||||
|
const seasonMatch = quality.match(/Season \d+/i);
|
||||||
|
// Extract the specific quality details, e.g., "1080p x264"
|
||||||
|
const specificQualityMatch = quality.match(/(480p|720p|1080p|2160p|4k)[ \w\d-]*/i);
|
||||||
|
|
||||||
|
if (seasonMatch && specificQualityMatch) {
|
||||||
|
const seasonIdentifier = seasonMatch[0].toLowerCase();
|
||||||
|
// Clean up the identifier to get only the essential parts
|
||||||
|
let specificQualityIdentifier = specificQualityMatch[0].toLowerCase().replace(/msubs.*/i, '').replace(/esubs.*/i, '').replace(/\{.*/, '').trim();
|
||||||
|
const qualityParts = specificQualityIdentifier.split(/\s+/); // -> ['1080p', 'x264']
|
||||||
|
|
||||||
|
$$('a[href*="episodes.modpro.blog"], a[href*="cinematickit.org"]').each((i, el) => {
|
||||||
|
const link = $$(el);
|
||||||
|
const linkText = link.text().trim().toLowerCase();
|
||||||
|
const seasonHeader = link.closest('.wp-block-buttons').prevAll('h2.wp-block-heading').first().text().trim().toLowerCase();
|
||||||
|
|
||||||
|
const seasonIsMatch = seasonHeader.includes(seasonIdentifier);
|
||||||
|
// Ensure that the link text contains all parts of our specific quality
|
||||||
|
const allPartsMatch = qualityParts.every(part => linkText.includes(part));
|
||||||
|
|
||||||
|
if (seasonIsMatch && allPartsMatch) {
|
||||||
|
episodePageLink = link.attr('href');
|
||||||
|
console.log(`[MoviesMod] Found specific match for "${quality}" -> "${link.text().trim()}": ${episodePageLink}`);
|
||||||
|
return false; // Break loop, we found our specific link
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!episodePageLink) {
|
||||||
|
console.error(`[MoviesMod] Could not find a specific quality match on dramadrip page for: ${quality}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass quality to recursive call
|
||||||
|
return await resolveIntermediateLink(episodePageLink, initialUrl, quality);
|
||||||
|
|
||||||
|
} else if (urlObject.hostname.includes('cinematickit.org')) {
|
||||||
|
// Handle cinematickit.org pages
|
||||||
|
const { data } = await axios.get(initialUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
const finalLinks = [];
|
||||||
|
|
||||||
|
// Look for episode links on cinematickit.org
|
||||||
|
$('a[href*="driveseed.org"]').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link && text && !text.toLowerCase().includes('batch')) {
|
||||||
|
finalLinks.push({
|
||||||
|
server: text.replace(/\s+/g, ' '),
|
||||||
|
url: link,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no driveseed links found, try other patterns
|
||||||
|
if (finalLinks.length === 0) {
|
||||||
|
$('a[href*="modrefer.in"], a[href*="dramadrip.com"]').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link && text) {
|
||||||
|
finalLinks.push({
|
||||||
|
server: text.replace(/\s+/g, ' '),
|
||||||
|
url: link,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalLinks;
|
||||||
|
|
||||||
|
} else if (urlObject.hostname.includes('episodes.modpro.blog')) {
|
||||||
|
const { data } = await axios.get(initialUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
const finalLinks = [];
|
||||||
|
|
||||||
|
$('.entry-content a[href*="driveseed.org"], .entry-content a[href*="tech.unblockedgames.world"], .entry-content a[href*="tech.creativeexpressionsblog.com"]').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link && text && !text.toLowerCase().includes('batch')) {
|
||||||
|
finalLinks.push({
|
||||||
|
server: text.replace(/\s+/g, ' '),
|
||||||
|
url: link,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return finalLinks;
|
||||||
|
|
||||||
|
} else if (urlObject.hostname.includes('modrefer.in')) {
|
||||||
|
const encodedUrl = urlObject.searchParams.get('url');
|
||||||
|
if (!encodedUrl) {
|
||||||
|
console.error('[MoviesMod] Could not find encoded URL in modrefer.in link.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedUrl = Buffer.from(encodedUrl, 'base64').toString('utf8');
|
||||||
|
const { data } = await axios.get(decodedUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Referer': refererUrl,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
const finalLinks = [];
|
||||||
|
|
||||||
|
$('.timed-content-client_show_0_5_0 a').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link) {
|
||||||
|
finalLinks.push({
|
||||||
|
server: text,
|
||||||
|
url: link,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return finalLinks;
|
||||||
|
} else {
|
||||||
|
console.warn(`[MoviesMod] Unknown hostname: ${urlObject.hostname}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error resolving intermediate link: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to resolve tech.unblockedgames.world links to driveleech URLs (adapted from UHDMovies)
|
||||||
|
async function resolveTechUnblockedLink(sidUrl) {
|
||||||
|
console.log(`[MoviesMod] Resolving SID link: ${sidUrl}`);
|
||||||
|
const { origin } = new URL(sidUrl);
|
||||||
|
const jar = new CookieJar();
|
||||||
|
const session = wrapper(axios.create({
|
||||||
|
jar,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
|
||||||
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Upgrade-Insecure-Requests': '1'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 0: Get the _wp_http value
|
||||||
|
console.log(" [SID] Step 0: Fetching initial page...");
|
||||||
|
const responseStep0 = await session.get(sidUrl);
|
||||||
|
let $ = cheerio.load(responseStep0.data);
|
||||||
|
const initialForm = $('#landing');
|
||||||
|
const wp_http_step1 = initialForm.find('input[name="_wp_http"]').val();
|
||||||
|
const action_url_step1 = initialForm.attr('action');
|
||||||
|
|
||||||
|
if (!wp_http_step1 || !action_url_step1) {
|
||||||
|
console.error(" [SID] Error: Could not find _wp_http in initial form.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: POST to the first form's action URL
|
||||||
|
console.log(" [SID] Step 1: Submitting initial form...");
|
||||||
|
const step1Data = new URLSearchParams({ '_wp_http': wp_http_step1 });
|
||||||
|
const responseStep1 = await session.post(action_url_step1, step1Data, {
|
||||||
|
headers: { 'Referer': sidUrl, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Parse verification page for second form
|
||||||
|
console.log(" [SID] Step 2: Parsing verification page...");
|
||||||
|
$ = cheerio.load(responseStep1.data);
|
||||||
|
const verificationForm = $('#landing');
|
||||||
|
const action_url_step2 = verificationForm.attr('action');
|
||||||
|
const wp_http2 = verificationForm.find('input[name="_wp_http2"]').val();
|
||||||
|
const token = verificationForm.find('input[name="token"]').val();
|
||||||
|
|
||||||
|
if (!action_url_step2) {
|
||||||
|
console.error(" [SID] Error: Could not find verification form.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: POST to the verification URL
|
||||||
|
console.log(" [SID] Step 3: Submitting verification...");
|
||||||
|
const step2Data = new URLSearchParams({ '_wp_http2': wp_http2, 'token': token });
|
||||||
|
const responseStep2 = await session.post(action_url_step2, step2Data, {
|
||||||
|
headers: { 'Referer': responseStep1.request.res.responseUrl, 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 4: Find dynamic cookie and link from JavaScript
|
||||||
|
console.log(" [SID] Step 4: Parsing final page for JS data...");
|
||||||
|
let finalLinkPath = null;
|
||||||
|
let cookieName = null;
|
||||||
|
let cookieValue = null;
|
||||||
|
|
||||||
|
const scriptContent = responseStep2.data;
|
||||||
|
const cookieMatch = scriptContent.match(/s_343\('([^']+)',\s*'([^']+)'/);
|
||||||
|
const linkMatch = scriptContent.match(/c\.setAttribute\("href",\s*"([^"]+)"\)/);
|
||||||
|
|
||||||
|
if (cookieMatch) {
|
||||||
|
cookieName = cookieMatch[1].trim();
|
||||||
|
cookieValue = cookieMatch[2].trim();
|
||||||
|
}
|
||||||
|
if (linkMatch) {
|
||||||
|
finalLinkPath = linkMatch[1].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalLinkPath || !cookieName || !cookieValue) {
|
||||||
|
console.error(" [SID] Error: Could not extract dynamic cookie/link from JS.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUrl = new URL(finalLinkPath, origin).href;
|
||||||
|
console.log(` [SID] Dynamic link found: ${finalUrl}`);
|
||||||
|
console.log(` [SID] Dynamic cookie found: ${cookieName}`);
|
||||||
|
|
||||||
|
// Step 5: Set cookie and make final request
|
||||||
|
console.log(" [SID] Step 5: Setting cookie and making final request...");
|
||||||
|
await jar.setCookie(`${cookieName}=${cookieValue}`, origin);
|
||||||
|
|
||||||
|
const finalResponse = await session.get(finalUrl, {
|
||||||
|
headers: { 'Referer': responseStep2.request.res.responseUrl }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 6: Extract driveleech URL from meta refresh tag
|
||||||
|
$ = cheerio.load(finalResponse.data);
|
||||||
|
const metaRefresh = $('meta[http-equiv="refresh"]');
|
||||||
|
if (metaRefresh.length > 0) {
|
||||||
|
const content = metaRefresh.attr('content');
|
||||||
|
const urlMatch = content.match(/url=(.*)/i);
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
const driveleechUrl = urlMatch[1].replace(/"/g, "").replace(/'/g, "");
|
||||||
|
console.log(` [SID] SUCCESS! Resolved Driveleech URL: ${driveleechUrl}`);
|
||||||
|
return driveleechUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(" [SID] Error: Could not find meta refresh tag with Driveleech URL.");
|
||||||
|
return null;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` [SID] Error during SID resolution: ${error.message}`);
|
||||||
|
if (error.response) {
|
||||||
|
console.error(` [SID] Status: ${error.response.status}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve driveseed.org links to get download options
|
||||||
|
async function resolveDriveseedLink(driveseedUrl) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(driveseedUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Referer': 'https://links.modpro.blog/',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectMatch = data.match(/window\.location\.replace\("([^"]+)"\)/);
|
||||||
|
|
||||||
|
if (redirectMatch && redirectMatch[1]) {
|
||||||
|
const finalPath = redirectMatch[1];
|
||||||
|
const finalUrl = `https://driveseed.org${finalPath}`;
|
||||||
|
|
||||||
|
const finalResponse = await axios.get(finalUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Referer': driveseedUrl,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(finalResponse.data);
|
||||||
|
const downloadOptions = [];
|
||||||
|
let size = null;
|
||||||
|
let fileName = null;
|
||||||
|
|
||||||
|
// Extract size and filename from the list
|
||||||
|
$('ul.list-group li').each((i, el) => {
|
||||||
|
const text = $(el).text();
|
||||||
|
if (text.includes('Size :')) {
|
||||||
|
size = text.split(':')[1].trim();
|
||||||
|
} else if (text.includes('Name :')) {
|
||||||
|
fileName = text.split(':')[1].trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find Resume Cloud button (primary)
|
||||||
|
const resumeCloudLink = $('a:contains("Resume Cloud")').attr('href');
|
||||||
|
if (resumeCloudLink) {
|
||||||
|
downloadOptions.push({
|
||||||
|
title: 'Resume Cloud',
|
||||||
|
type: 'resume',
|
||||||
|
url: `https://driveseed.org${resumeCloudLink}`,
|
||||||
|
priority: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Resume Worker Bot (fallback)
|
||||||
|
const workerSeedLink = $('a:contains("Resume Worker Bot")').attr('href');
|
||||||
|
if (workerSeedLink) {
|
||||||
|
downloadOptions.push({
|
||||||
|
title: 'Resume Worker Bot',
|
||||||
|
type: 'worker',
|
||||||
|
url: workerSeedLink,
|
||||||
|
priority: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find Instant Download (final fallback)
|
||||||
|
const instantDownloadLink = $('a:contains("Instant Download")').attr('href');
|
||||||
|
if (instantDownloadLink) {
|
||||||
|
downloadOptions.push({
|
||||||
|
title: 'Instant Download',
|
||||||
|
type: 'instant',
|
||||||
|
url: instantDownloadLink,
|
||||||
|
priority: 3
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by priority
|
||||||
|
downloadOptions.sort((a, b) => a.priority - b.priority);
|
||||||
|
return { downloadOptions, size, fileName };
|
||||||
|
}
|
||||||
|
return { downloadOptions: [], size: null, fileName: null };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error resolving Driveseed link: ${error.message}`);
|
||||||
|
return { downloadOptions: [], size: null, fileName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Resume Cloud link to final download URL
|
||||||
|
async function resolveResumeCloudLink(resumeUrl) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(resumeUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Referer': 'https://driveseed.org/',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const $ = cheerio.load(data);
|
||||||
|
const downloadLink = $('a:contains("Cloud Resume Download")').attr('href');
|
||||||
|
return downloadLink || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error resolving Resume Cloud link: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Worker Seed link to final download URL
|
||||||
|
async function resolveWorkerSeedLink(workerSeedUrl) {
|
||||||
|
try {
|
||||||
|
console.log(`[MoviesMod] Resolving Worker-seed link: ${workerSeedUrl}`);
|
||||||
|
|
||||||
|
const jar = new CookieJar();
|
||||||
|
const session = wrapper(axios.create({
|
||||||
|
jar,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Step 1: GET the page to get the script content and cookies
|
||||||
|
console.log(`[MoviesMod] Step 1: Fetching page to get script content and cookies...`);
|
||||||
|
const { data: pageHtml } = await session.get(workerSeedUrl);
|
||||||
|
|
||||||
|
// Step 2: Use regex to extract the token and the correct ID from the script
|
||||||
|
const scriptTags = pageHtml.match(/<script type="text\/javascript">([\s\S]*?)<\/script>/g);
|
||||||
|
|
||||||
|
if (!scriptTags) {
|
||||||
|
console.error('[MoviesMod] Could not find any script tags on the page.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptContent = scriptTags.find(s => s.includes("formData.append('token'"));
|
||||||
|
|
||||||
|
if (!scriptContent) {
|
||||||
|
console.error('[MoviesMod] Could not find the relevant script tag containing formData.append.');
|
||||||
|
|
||||||
|
// Debug: Log available script content
|
||||||
|
console.log(`[MoviesMod] Found ${scriptTags.length} script tags. Checking for token patterns...`);
|
||||||
|
scriptTags.forEach((script, i) => {
|
||||||
|
if (script.includes('token') || script.includes('formData')) {
|
||||||
|
console.log(`[MoviesMod] Script ${i} snippet:`, script.substring(0, 300));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenMatch = scriptContent.match(/formData\.append\('token', '([^']+)'\)/);
|
||||||
|
const idMatch = scriptContent.match(/fetch\('\/download\?id=([^']+)',/);
|
||||||
|
|
||||||
|
if (!tokenMatch || !tokenMatch[1] || !idMatch || !idMatch[1]) {
|
||||||
|
console.error('[MoviesMod] Could not extract token or correct ID from the script.');
|
||||||
|
console.log('[MoviesMod] Script content snippet:', scriptContent.substring(0, 500));
|
||||||
|
|
||||||
|
// Try alternative patterns
|
||||||
|
const altTokenMatch = scriptContent.match(/token['"]?\s*[:=]\s*['"]([^'"]+)['"]/);
|
||||||
|
const altIdMatch = scriptContent.match(/id['"]?\s*[:=]\s*['"]([^'"]+)['"]/);
|
||||||
|
|
||||||
|
if (altTokenMatch && altIdMatch) {
|
||||||
|
console.log('[MoviesMod] Found alternative patterns, trying those...');
|
||||||
|
const token = altTokenMatch[1];
|
||||||
|
const id = altIdMatch[1];
|
||||||
|
console.log(`[MoviesMod] Alternative token: ${token.substring(0, 20)}...`);
|
||||||
|
console.log(`[MoviesMod] Alternative id: ${id}`);
|
||||||
|
|
||||||
|
// Continue with these values
|
||||||
|
return await makeWorkerSeedRequest(session, token, id, workerSeedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = tokenMatch[1];
|
||||||
|
const correctId = idMatch[1];
|
||||||
|
console.log(`[MoviesMod] Step 2: Extracted token: ${token.substring(0, 20)}...`);
|
||||||
|
console.log(`[MoviesMod] Step 2: Extracted correct ID: ${correctId}`);
|
||||||
|
|
||||||
|
return await makeWorkerSeedRequest(session, token, correctId, workerSeedUrl);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error resolving WorkerSeed link: ${error.message}`);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('[MoviesMod] Error response data:', error.response.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to make the actual WorkerSeed API request
|
||||||
|
async function makeWorkerSeedRequest(session, token, correctId, workerSeedUrl) {
|
||||||
|
// Step 3: Make the POST request with the correct data using the same session
|
||||||
|
const apiUrl = `https://workerseed.dev/download?id=${correctId}`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('token', token);
|
||||||
|
|
||||||
|
console.log(`[MoviesMod] Step 3: POSTing to endpoint: ${apiUrl} with extracted token.`);
|
||||||
|
|
||||||
|
// Use the session instance, which will automatically include the cookies
|
||||||
|
const { data: apiResponse } = await session.post(apiUrl, formData, {
|
||||||
|
headers: {
|
||||||
|
...formData.getHeaders(),
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Referer': workerSeedUrl,
|
||||||
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiResponse && apiResponse.url) {
|
||||||
|
console.log(`[MoviesMod] SUCCESS! Final video link from Worker-seed API: ${apiResponse.url}`);
|
||||||
|
return apiResponse.url;
|
||||||
|
} else {
|
||||||
|
console.log('[MoviesMod] Worker-seed API did not return a URL. Full response:');
|
||||||
|
console.log(apiResponse);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Video Seed (Instant Download) link
|
||||||
|
async function resolveVideoSeedLink(videoSeedUrl) {
|
||||||
|
try {
|
||||||
|
const urlParams = new URLSearchParams(new URL(videoSeedUrl).search);
|
||||||
|
const keys = urlParams.get('url');
|
||||||
|
|
||||||
|
if (keys) {
|
||||||
|
const apiUrl = `${new URL(videoSeedUrl).origin}/api`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('keys', keys);
|
||||||
|
|
||||||
|
const apiResponse = await axios.post(apiUrl, formData, {
|
||||||
|
headers: {
|
||||||
|
...formData.getHeaders(),
|
||||||
|
'x-token': new URL(videoSeedUrl).hostname
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (apiResponse.data && apiResponse.data.url) {
|
||||||
|
return apiResponse.data.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error resolving VideoSeed link: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate if a video URL is working (not 404 or broken)
|
||||||
|
async function validateVideoUrl(url, timeout = 10000) {
|
||||||
|
try {
|
||||||
|
console.log(`[MoviesMod] Validating URL: ${url.substring(0, 100)}...`);
|
||||||
|
const response = await axios.head(url, {
|
||||||
|
timeout,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'Range': 'bytes=0-1' // Just request first byte to test
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if status is OK (200-299) or partial content (206)
|
||||||
|
if (response.status >= 200 && response.status < 400) {
|
||||||
|
console.log(`[MoviesMod] ✓ URL validation successful (${response.status})`);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log(`[MoviesMod] ✗ URL validation failed with status: ${response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[MoviesMod] ✗ URL validation failed: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function to get streams for TMDB content
|
||||||
|
async function getMoviesModStreams(tmdbId, mediaType, seasonNum = null, episodeNum = null) {
|
||||||
|
try {
|
||||||
|
console.log(`[MoviesMod] Fetching streams for TMDB ${mediaType}/${tmdbId}${seasonNum ? `, S${seasonNum}E${episodeNum}`: ''}`);
|
||||||
|
|
||||||
|
// Define a cache key based on the media type and ID. For series, cache per season.
|
||||||
|
const cacheKey = `moviesmod_driveseed_v6_${tmdbId}_${mediaType}${seasonNum ? `_s${seasonNum}` : ''}`;
|
||||||
|
let resolvedQualities = await getFromCache(cacheKey);
|
||||||
|
|
||||||
|
if (!resolvedQualities) {
|
||||||
|
console.log(`[MoviesMod Cache] MISS for key: ${cacheKey}. Fetching from source.`);
|
||||||
|
|
||||||
|
// We need to fetch title and year from TMDB API
|
||||||
|
const TMDB_API_KEY = process.env.TMDB_API_KEY;
|
||||||
|
if (!TMDB_API_KEY) throw new Error('TMDB_API_KEY not configured.');
|
||||||
|
|
||||||
|
const { default: fetch } = await import('node-fetch');
|
||||||
|
const tmdbUrl = `https://api.themoviedb.org/3/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}?api_key=${TMDB_API_KEY}&language=en-US`;
|
||||||
|
const tmdbDetails = await (await fetch(tmdbUrl)).json();
|
||||||
|
|
||||||
|
const title = mediaType === 'tv' ? tmdbDetails.name : tmdbDetails.title;
|
||||||
|
const year = mediaType === 'tv' ? tmdbDetails.first_air_date?.substring(0, 4) : tmdbDetails.release_date?.substring(0, 4);
|
||||||
|
if (!title) throw new Error('Could not get title from TMDB');
|
||||||
|
|
||||||
|
console.log(`[MoviesMod] Found metadata: ${title} (${year})`);
|
||||||
|
const searchResults = await searchMoviesMod(title);
|
||||||
|
if (searchResults.length === 0) throw new Error(`No search results found for "${title}"`);
|
||||||
|
|
||||||
|
// --- NEW: Use string similarity to find the best match ---
|
||||||
|
const titles = searchResults.map(r => r.title);
|
||||||
|
const bestMatch = findBestMatch(title, titles);
|
||||||
|
|
||||||
|
console.log(`[MoviesMod] Best match for "${title}" is "${bestMatch.bestMatch.target}" with a rating of ${bestMatch.bestMatch.rating.toFixed(2)}`);
|
||||||
|
|
||||||
|
let selectedResult = null;
|
||||||
|
// Set a minimum similarity threshold (e.g., 0.3) to avoid obviously wrong matches
|
||||||
|
if (bestMatch.bestMatch.rating > 0.3) {
|
||||||
|
selectedResult = searchResults[bestMatch.bestMatchIndex];
|
||||||
|
|
||||||
|
// Additional check for year if it's a movie
|
||||||
|
if (mediaType === 'movie' && year) {
|
||||||
|
if (!selectedResult.title.includes(year)) {
|
||||||
|
console.warn(`[MoviesMod] Title match found, but year mismatch. Matched: "${selectedResult.title}", Expected year: ${year}. Discarding match.`);
|
||||||
|
selectedResult = null; // Discard if year doesn't match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedResult) {
|
||||||
|
// If no good match is found, try a stricter direct search using regex with word boundaries
|
||||||
|
console.log('[MoviesMod] Similarity match failed or was below threshold. Trying stricter name/year search with word boundaries...');
|
||||||
|
const titleRegex = new RegExp(`\\b${escapeRegExp(title.toLowerCase())}\\b`);
|
||||||
|
|
||||||
|
if (mediaType === 'movie') {
|
||||||
|
selectedResult = searchResults.find(r =>
|
||||||
|
titleRegex.test(r.title.toLowerCase()) &&
|
||||||
|
(!year || r.title.includes(year))
|
||||||
|
);
|
||||||
|
} else { // for 'tv'
|
||||||
|
// For TV, be more lenient on year, but check for title and 'season' keyword.
|
||||||
|
selectedResult = searchResults.find(r =>
|
||||||
|
titleRegex.test(r.title.toLowerCase()) &&
|
||||||
|
r.title.toLowerCase().includes('season')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedResult) {
|
||||||
|
throw new Error(`No suitable search result found for "${title} (${year})". Best similarity match was too low or failed year check.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MoviesMod] Selected: ${selectedResult.title}`);
|
||||||
|
const downloadLinks = await extractDownloadLinks(selectedResult.url);
|
||||||
|
if (downloadLinks.length === 0) throw new Error('No download links found');
|
||||||
|
|
||||||
|
let relevantLinks = downloadLinks;
|
||||||
|
if ((mediaType === 'tv' || mediaType === 'series') && seasonNum !== null) {
|
||||||
|
relevantLinks = downloadLinks.filter(link => link.quality.toLowerCase().includes(`season ${seasonNum}`) || link.quality.toLowerCase().includes(`s${seasonNum}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out 480p links before processing
|
||||||
|
relevantLinks = relevantLinks.filter(link => !link.quality.toLowerCase().includes('480p'));
|
||||||
|
console.log(`[MoviesMod] ${relevantLinks.length} links remaining after 480p filter.`);
|
||||||
|
|
||||||
|
if (relevantLinks.length > 0) {
|
||||||
|
console.log(`[MoviesMod] Found ${relevantLinks.length} relevant quality links.`);
|
||||||
|
const qualityPromises = relevantLinks.map(async (link) => {
|
||||||
|
const finalLinks = await resolveIntermediateLink(link.url, selectedResult.url, link.quality);
|
||||||
|
if (finalLinks && finalLinks.length > 0) {
|
||||||
|
return { quality: link.quality, finalLinks: finalLinks };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
resolvedQualities = (await Promise.all(qualityPromises)).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
resolvedQualities = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveToCache(cacheKey, resolvedQualities);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedQualities || resolvedQualities.length === 0) {
|
||||||
|
console.log('[MoviesMod] No intermediate links found from cache or scraping.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MoviesMod] Processing ${resolvedQualities.length} qualities to get final streams.`);
|
||||||
|
const streams = [];
|
||||||
|
const processedFileNames = new Set();
|
||||||
|
|
||||||
|
const qualityProcessingPromises = resolvedQualities.map(async (qualityInfo) => {
|
||||||
|
const { quality, finalLinks } = qualityInfo;
|
||||||
|
|
||||||
|
let targetLinks = finalLinks;
|
||||||
|
if ((mediaType === 'tv' || mediaType === 'series') && episodeNum !== null) {
|
||||||
|
targetLinks = finalLinks.filter(fl => fl.server.toLowerCase().includes(`episode ${episodeNum}`) || fl.server.toLowerCase().includes(`ep ${episodeNum}`) || fl.server.toLowerCase().includes(`e${episodeNum}`));
|
||||||
|
if (targetLinks.length === 0) {
|
||||||
|
console.log(`[MoviesMod] No episode ${episodeNum} found for ${quality}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStreamPromises = targetLinks.map(async (targetLink) => {
|
||||||
|
try {
|
||||||
|
let currentUrl = targetLink.url;
|
||||||
|
|
||||||
|
// Step 1: Handle SID links if they appear
|
||||||
|
if (currentUrl.includes('tech.unblockedgames.world') || currentUrl.includes('tech.creativeexpressionsblog.com')) {
|
||||||
|
console.log(`[MoviesMod] Bypassing SID link: ${currentUrl}`);
|
||||||
|
const resolvedUrl = await resolveTechUnblockedLink(currentUrl);
|
||||||
|
if (!resolvedUrl) {
|
||||||
|
console.warn(`[MoviesMod] Failed to bypass tech.unblockedgames.world for: ${currentUrl}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.log(`[MoviesMod] Bypassed. Continuing with resolved URL: ${resolvedUrl}`);
|
||||||
|
currentUrl = resolvedUrl; // The resolved URL should be a driveseed link
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Now process the (potentially resolved) driveseed link
|
||||||
|
if (currentUrl.includes('driveseed.org')) {
|
||||||
|
const { downloadOptions, size: driveseedSize, fileName } = await resolveDriveseedLink(currentUrl);
|
||||||
|
|
||||||
|
if (fileName && processedFileNames.has(fileName)) {
|
||||||
|
console.log(`[MoviesMod] Skipping duplicate file: ${fileName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (fileName) processedFileNames.add(fileName);
|
||||||
|
|
||||||
|
if (!downloadOptions || downloadOptions.length === 0) return null;
|
||||||
|
|
||||||
|
// Try each download method in order of priority until we find a working one
|
||||||
|
let selectedResult = null;
|
||||||
|
for (const option of downloadOptions) {
|
||||||
|
try {
|
||||||
|
console.log(`[MoviesMod] Trying ${option.title} for ${quality}...`);
|
||||||
|
let finalDownloadUrl = null;
|
||||||
|
|
||||||
|
if (option.type === 'resume') {
|
||||||
|
finalDownloadUrl = await resolveResumeCloudLink(option.url);
|
||||||
|
} else if (option.type === 'worker') {
|
||||||
|
finalDownloadUrl = await resolveWorkerSeedLink(option.url);
|
||||||
|
} else if (option.type === 'instant') {
|
||||||
|
finalDownloadUrl = await resolveVideoSeedLink(option.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalDownloadUrl) {
|
||||||
|
// Validate the URL before using it
|
||||||
|
const isValid = await validateVideoUrl(finalDownloadUrl);
|
||||||
|
if (isValid) {
|
||||||
|
selectedResult = { url: finalDownloadUrl, method: option.title };
|
||||||
|
console.log(`[MoviesMod] ✓ Successfully resolved ${quality} using ${option.title}`);
|
||||||
|
break; // Found working URL, stop trying other methods
|
||||||
|
} else {
|
||||||
|
console.log(`[MoviesMod] ✗ ${option.title} returned invalid/broken URL, trying next method...`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[MoviesMod] ✗ ${option.title} failed to resolve URL, trying next method...`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`[MoviesMod] ✗ ${option.title} threw error: ${error.message}, trying next method...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedResult) {
|
||||||
|
console.log(`[MoviesMod] ✗ All download methods failed for ${quality}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualQuality = extractQuality(quality);
|
||||||
|
const sizeInfo = driveseedSize || quality.match(/\[([^\]]+)\]/)?.[1];
|
||||||
|
const cleanFileName = fileName ? fileName.replace(/\.[^/.]+$/, "").replace(/[._]/g, ' ') : `Stream from ${quality}`;
|
||||||
|
const techDetails = getTechDetails(quality);
|
||||||
|
const techDetailsString = techDetails.length > 0 ? ` • ${techDetails.join(' • ')}` : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `MoviesMod\n${actualQuality}`,
|
||||||
|
title: `${cleanFileName}\n${sizeInfo || ''}${techDetailsString}`,
|
||||||
|
url: selectedResult.url,
|
||||||
|
quality: actualQuality,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
console.warn(`[MoviesMod] Unsupported URL type for final processing: ${currentUrl}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[MoviesMod] Error processing target link ${targetLink.url}: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (await Promise.all(finalStreamPromises)).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allResults = await Promise.all(qualityProcessingPromises);
|
||||||
|
allResults.flat().forEach(s => streams.push(s));
|
||||||
|
|
||||||
|
// Sort by quality descending
|
||||||
|
streams.sort((a, b) => {
|
||||||
|
const qualityA = parseQualityForSort(a.quality);
|
||||||
|
const qualityB = parseQualityForSort(b.quality);
|
||||||
|
return qualityB - qualityA;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[MoviesMod] Successfully extracted and sorted ${streams.length} streams`);
|
||||||
|
return streams;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MoviesMod] Error getting streams: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getMoviesModStreams
|
||||||
|
};
|
||||||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "nuvio",
|
"name": "nuvio",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/standalone": "^7.28.0",
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@gorhom/bottom-sheet": "^5.1.2",
|
"@gorhom/bottom-sheet": "^5.1.2",
|
||||||
|
|
@ -71,7 +72,8 @@
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-wheel-color-picker": "^1.3.1",
|
"react-native-wheel-color-picker": "^1.3.1",
|
||||||
"react-navigation-shared-element": "^3.1.3",
|
"react-navigation-shared-element": "^3.1.3",
|
||||||
"subsrt": "^1.1.1"
|
"subsrt": "^1.1.1",
|
||||||
|
"sucrase": "^3.35.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|
@ -2206,6 +2208,15 @@
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/standalone": {
|
||||||
|
"version": "7.28.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/standalone/-/standalone-7.28.0.tgz",
|
||||||
|
"integrity": "sha512-KwedtFtU9oE/1zih0Cxb7Orudibv40KfklaQvUexeQ/2b6dktEHwa/Uqpdqr0AM1vD1+QZSQOXHwRQpt/JO0hQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/standalone": "^7.28.0",
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@gorhom/bottom-sheet": "^5.1.2",
|
"@gorhom/bottom-sheet": "^5.1.2",
|
||||||
|
|
@ -72,7 +73,8 @@
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-wheel-color-picker": "^1.3.1",
|
"react-native-wheel-color-picker": "^1.3.1",
|
||||||
"react-navigation-shared-element": "^3.1.3",
|
"react-navigation-shared-element": "^3.1.3",
|
||||||
"subsrt": "^1.1.1"
|
"subsrt": "^1.1.1",
|
||||||
|
"sucrase": "^3.35.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { usePersistentSeasons } from './usePersistentSeasons';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Stream } from '../types/metadata';
|
import { Stream } from '../types/metadata';
|
||||||
import { storageService } from '../services/storageService';
|
import { storageService } from '../services/storageService';
|
||||||
|
import { pluginManager } from '../services/PluginManager';
|
||||||
|
|
||||||
// Constants for timeouts and retries
|
// Constants for timeouts and retries
|
||||||
const API_TIMEOUT = 10000; // 10 seconds
|
const API_TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
@ -183,6 +184,76 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
// Loading indicators should probably be managed based on callbacks completing.
|
// Loading indicators should probably be managed based on callbacks completing.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processPluginSource = async (type: string, id: string, seasonNum?: number, episodeNum?: number, isEpisode = false) => {
|
||||||
|
const sourceStartTime = Date.now();
|
||||||
|
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
||||||
|
const sourceName = 'plugins';
|
||||||
|
|
||||||
|
logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmdbApiKey = await AsyncStorage.getItem('tmdbApiKey');
|
||||||
|
let effectiveApiKey = tmdbApiKey;
|
||||||
|
|
||||||
|
// Fallback to default key if none was found
|
||||||
|
if (!effectiveApiKey) {
|
||||||
|
effectiveApiKey = '439c478a771f35c05022f9feabcca01c';
|
||||||
|
logger.warn(`[$${logPrefix}:${sourceName}] No TMDB API key found in storage. Falling back to default key.`);
|
||||||
|
// Persist the key for future fetches
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem('tmdbApiKey', effectiveApiKey);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`[$${logPrefix}:${sourceName}] Failed to persist default TMDB API key:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streams = await pluginManager.getAllStreams({
|
||||||
|
tmdbId: String(metadata?.tmdbId || id),
|
||||||
|
mediaType: type as 'movie' | 'tv',
|
||||||
|
seasonNum,
|
||||||
|
episodeNum,
|
||||||
|
tmdbApiKey: effectiveApiKey,
|
||||||
|
title: (metadata as any)?.name || undefined,
|
||||||
|
year: metadata?.year || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processTime = Date.now() - sourceStartTime;
|
||||||
|
logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams from plugins after ${processTime}ms`);
|
||||||
|
|
||||||
|
if (streams.length > 0) {
|
||||||
|
// Group streams by plugin name
|
||||||
|
const streamsByPlugin: GroupedStreams = streams.reduce((acc, stream) => {
|
||||||
|
const pluginName = stream.name.split('\n')[0] || 'Unknown Plugin';
|
||||||
|
if (!acc[pluginName]) {
|
||||||
|
acc[pluginName] = { addonName: pluginName, streams: [] };
|
||||||
|
}
|
||||||
|
acc[pluginName].streams.push(stream);
|
||||||
|
return acc;
|
||||||
|
}, {} as GroupedStreams);
|
||||||
|
|
||||||
|
const updateState = (prevState: GroupedStreams): GroupedStreams => {
|
||||||
|
logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state with plugin streams`);
|
||||||
|
return {
|
||||||
|
...prevState,
|
||||||
|
...streamsByPlugin,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEpisode) {
|
||||||
|
setEpisodeStreams(updateState);
|
||||||
|
setLoadingEpisodeStreams(false);
|
||||||
|
} else {
|
||||||
|
setGroupedStreams(updateState);
|
||||||
|
setLoadingStreams(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found from plugins`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ [${logPrefix}:${sourceName}] Plugin fetch failed:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadCast = async () => {
|
const loadCast = async () => {
|
||||||
logger.log('[loadCast] Starting cast fetch for:', id);
|
logger.log('[loadCast] Starting cast fetch for:', id);
|
||||||
setLoadingCast(true);
|
setLoadingCast(true);
|
||||||
|
|
@ -681,148 +752,63 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadStreams = async () => {
|
const loadStreams = async () => {
|
||||||
const startTime = Date.now();
|
if (!metadata) {
|
||||||
try {
|
logger.warn('[loadStreams] No metadata available, aborting stream load');
|
||||||
console.log('🚀 [loadStreams] START - Loading streams for:', id);
|
return;
|
||||||
updateLoadingState();
|
|
||||||
|
|
||||||
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
|
||||||
console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
|
|
||||||
let tmdbId;
|
|
||||||
let stremioId = id; // Default to original ID
|
|
||||||
|
|
||||||
if (id.startsWith('tmdb:')) {
|
|
||||||
tmdbId = id.split(':')[1];
|
|
||||||
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
|
|
||||||
|
|
||||||
// Try to get IMDb ID from metadata first, then convert if needed
|
|
||||||
if (metadata?.imdb_id) {
|
|
||||||
stremioId = metadata.imdb_id;
|
|
||||||
console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
|
|
||||||
} else if (imdbId) {
|
|
||||||
stremioId = imdbId;
|
|
||||||
console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId);
|
|
||||||
} else {
|
|
||||||
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
|
|
||||||
try {
|
|
||||||
let externalIds = null;
|
|
||||||
if (type === 'movie') {
|
|
||||||
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
|
|
||||||
externalIds = movieDetails?.external_ids;
|
|
||||||
} else if (type === 'series') {
|
|
||||||
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (externalIds?.imdb_id) {
|
|
||||||
stremioId = externalIds.imdb_id;
|
|
||||||
console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (id.startsWith('tt')) {
|
|
||||||
// This is already an IMDB ID, perfect for Stremio
|
|
||||||
stremioId = id;
|
|
||||||
console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
|
|
||||||
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
|
||||||
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
|
|
||||||
} else {
|
|
||||||
tmdbId = id;
|
|
||||||
stremioId = id;
|
|
||||||
console.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start Stremio request using the converted ID format
|
|
||||||
console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
|
|
||||||
processStremioSource(type, stremioId, false);
|
|
||||||
|
|
||||||
// Add a delay before marking loading as complete to give Stremio addons more time
|
|
||||||
setTimeout(() => {
|
|
||||||
setLoadingStreams(false);
|
|
||||||
}, 10000); // 10 second delay to allow streams to load
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ [loadStreams] Failed to load streams:', error);
|
|
||||||
setError('Failed to load streams');
|
|
||||||
setLoadingStreams(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.log('🎬 [loadStreams] Initiating stream fetch for:', metadata.id);
|
||||||
|
setLoadingStreams(true);
|
||||||
|
setGroupedStreams({});
|
||||||
|
|
||||||
|
// Create a promise for each source type
|
||||||
|
const stremioPromise = processStremioSource(type, metadata.id);
|
||||||
|
const pluginPromise = processPluginSource(type, metadata.id);
|
||||||
|
|
||||||
|
// Run all sources in parallel
|
||||||
|
await Promise.all([stremioPromise, pluginPromise]);
|
||||||
|
|
||||||
|
// The loading state is managed within each process function,
|
||||||
|
// but we can set a final loading state to false after a delay
|
||||||
|
// to catch any sources that don't return.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loadingStreams) {
|
||||||
|
setLoadingStreams(false);
|
||||||
|
logger.log('🏁 [loadStreams] Stream fetch concluded (timeout check)');
|
||||||
|
}
|
||||||
|
}, 15000); // 15 second global timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadEpisodeStreams = async (episodeId: string) => {
|
const loadEpisodeStreams = async (episodeId: string) => {
|
||||||
const startTime = Date.now();
|
if (!metadata || !metadata.videos) {
|
||||||
try {
|
logger.warn('[loadEpisodeStreams] Metadata or videos not available, aborting');
|
||||||
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
|
return;
|
||||||
updateEpisodeLoadingState();
|
|
||||||
|
|
||||||
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
|
||||||
console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
|
|
||||||
let tmdbId;
|
|
||||||
let stremioEpisodeId = episodeId; // Default to original episode ID
|
|
||||||
|
|
||||||
if (id.startsWith('tmdb:')) {
|
|
||||||
tmdbId = id.split(':')[1];
|
|
||||||
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
|
|
||||||
|
|
||||||
// Try to get IMDb ID from metadata first, then convert if needed
|
|
||||||
if (metadata?.imdb_id) {
|
|
||||||
// Replace the series ID in episodeId with the IMDb ID
|
|
||||||
const [, season, episode] = episodeId.split(':');
|
|
||||||
stremioEpisodeId = `series:${metadata.imdb_id}:${season}:${episode}`;
|
|
||||||
console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
|
|
||||||
} else if (imdbId) {
|
|
||||||
const [, season, episode] = episodeId.split(':');
|
|
||||||
stremioEpisodeId = `series:${imdbId}:${season}:${episode}`;
|
|
||||||
console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
|
|
||||||
} else {
|
|
||||||
// Convert TMDB ID to IMDb ID for Stremio addons
|
|
||||||
try {
|
|
||||||
const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
|
|
||||||
|
|
||||||
if (externalIds?.imdb_id) {
|
|
||||||
const [, season, episode] = episodeId.split(':');
|
|
||||||
stremioEpisodeId = `series:${externalIds.imdb_id}:${season}:${episode}`;
|
|
||||||
console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
|
|
||||||
} else {
|
|
||||||
console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (id.startsWith('tt')) {
|
|
||||||
// This is already an IMDB ID, perfect for Stremio
|
|
||||||
console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
|
|
||||||
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
|
||||||
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
|
|
||||||
} else {
|
|
||||||
tmdbId = id;
|
|
||||||
console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract episode info from the episodeId for logging
|
|
||||||
const [, season, episode] = episodeId.split(':');
|
|
||||||
const episodeQuery = `?s=${season}&e=${episode}`;
|
|
||||||
console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
|
|
||||||
|
|
||||||
console.log('🔄 [loadEpisodeStreams] Starting stream requests');
|
|
||||||
|
|
||||||
// Start Stremio request using the converted episode ID format
|
|
||||||
console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
|
|
||||||
processStremioSource('series', stremioEpisodeId, true);
|
|
||||||
|
|
||||||
// Add a delay before marking loading as complete to give Stremio addons more time
|
|
||||||
setTimeout(() => {
|
|
||||||
setLoadingEpisodeStreams(false);
|
|
||||||
}, 10000); // 10 second delay to allow streams to load
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
|
|
||||||
setError('Failed to load episode streams');
|
|
||||||
setLoadingEpisodeStreams(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const episode = metadata.videos.find(v => v.id === episodeId);
|
||||||
|
if (!episode) {
|
||||||
|
logger.warn('[loadEpisodeStreams] Episode not found:', episodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`🎬 [loadEpisodeStreams] Initiating stream fetch for episode: ${episode.title} (S${episode.season}E${episode.episode})`);
|
||||||
|
setLoadingEpisodeStreams(true);
|
||||||
|
setEpisodeStreams({});
|
||||||
|
|
||||||
|
// Create a promise for each source type
|
||||||
|
const stremioPromise = processStremioSource(type, episodeId, true);
|
||||||
|
const pluginPromise = processPluginSource(type, id, episode.season, episode.episode, true);
|
||||||
|
|
||||||
|
// Run all sources in parallel
|
||||||
|
await Promise.all([stremioPromise, pluginPromise]);
|
||||||
|
|
||||||
|
// Set a final loading state to false after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loadingEpisodeStreams) {
|
||||||
|
setLoadingEpisodeStreams(false);
|
||||||
|
logger.log('🏁 [loadEpisodeStreams] Episode stream fetch concluded (timeout check)');
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSeasonChange = useCallback((seasonNumber: number) => {
|
const handleSeasonChange = useCallback((seasonNumber: number) => {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import LogoSourceSettings from '../screens/LogoSourceSettings';
|
||||||
import ThemeScreen from '../screens/ThemeScreen';
|
import ThemeScreen from '../screens/ThemeScreen';
|
||||||
import ProfilesScreen from '../screens/ProfilesScreen';
|
import ProfilesScreen from '../screens/ProfilesScreen';
|
||||||
import OnboardingScreen from '../screens/OnboardingScreen';
|
import OnboardingScreen from '../screens/OnboardingScreen';
|
||||||
|
import PluginsScreen from '../screens/PluginsScreen';
|
||||||
|
|
||||||
// Stack navigator types
|
// Stack navigator types
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
|
@ -104,6 +105,7 @@ export type RootStackParamList = {
|
||||||
LogoSourceSettings: undefined;
|
LogoSourceSettings: undefined;
|
||||||
ThemeSettings: undefined;
|
ThemeSettings: undefined;
|
||||||
ProfilesSettings: undefined;
|
ProfilesSettings: undefined;
|
||||||
|
Plugins: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
@ -1013,6 +1015,21 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="Plugins"
|
||||||
|
component={PluginsScreen}
|
||||||
|
options={{
|
||||||
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
|
presentation: 'card',
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</View>
|
</View>
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ const CalendarScreen = () => {
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
|
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
|
||||||
|
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
refresh(true);
|
refresh(true);
|
||||||
|
|
|
||||||
|
|
@ -908,7 +908,7 @@ const LibraryScreen = () => {
|
||||||
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
|
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
|
||||||
: 'Trakt Collection'
|
: 'Trakt Collection'
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
253
src/screens/PluginsScreen.tsx
Normal file
253
src/screens/PluginsScreen.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
import React, { useCallback, useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
SafeAreaView,
|
||||||
|
StatusBar,
|
||||||
|
Alert,
|
||||||
|
Platform,
|
||||||
|
Dimensions,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator,
|
||||||
|
} from 'react-native';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { pluginManager } from '../services/PluginManager';
|
||||||
|
import { SettingItem, SettingsCard } from './SettingsScreen'; // Assuming these are exported
|
||||||
|
|
||||||
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
|
interface LoadedPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PluginsScreen: React.FC = () => {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { currentTheme } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const [pluginUrl, setPluginUrl] = useState('');
|
||||||
|
const [loadedPlugins, setLoadedPlugins] = useState<LoadedPlugin[]>([]);
|
||||||
|
const [isLoadingPlugin, setIsLoadingPlugin] = useState(false);
|
||||||
|
|
||||||
|
const refreshPluginsList = useCallback(() => {
|
||||||
|
const plugins = pluginManager.getScraperPlugins();
|
||||||
|
setLoadedPlugins(
|
||||||
|
plugins.map(p => ({
|
||||||
|
name: p.name,
|
||||||
|
version: p.version,
|
||||||
|
sourceUrl: p.sourceUrl,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshPluginsList();
|
||||||
|
}, [refreshPluginsList]);
|
||||||
|
|
||||||
|
const handleLoadPlugin = useCallback(async () => {
|
||||||
|
if (!pluginUrl.trim() || !pluginUrl.startsWith('http')) {
|
||||||
|
Alert.alert('Invalid URL', 'Please enter a valid plugin URL.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoadingPlugin(true);
|
||||||
|
const success = await pluginManager.loadPluginFromUrl(pluginUrl.trim());
|
||||||
|
setIsLoadingPlugin(false);
|
||||||
|
if (success) {
|
||||||
|
Alert.alert('Success', 'Plugin loaded successfully.');
|
||||||
|
setPluginUrl('');
|
||||||
|
refreshPluginsList();
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'Error',
|
||||||
|
'Failed to load the plugin. Check the URL and console for errors.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [pluginUrl, refreshPluginsList]);
|
||||||
|
|
||||||
|
const handleRemovePlugin = useCallback(
|
||||||
|
(sourceUrl: string) => {
|
||||||
|
if (!sourceUrl) return;
|
||||||
|
Alert.alert(
|
||||||
|
'Remove Plugin',
|
||||||
|
'Are you sure you want to remove this plugin?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => {
|
||||||
|
pluginManager.removePlugin(sourceUrl);
|
||||||
|
refreshPluginsList();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[refreshPluginsList]
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||||
|
const topSpacing = Platform.OS === 'android' ? StatusBar.currentHeight || 0 : insets.top;
|
||||||
|
const headerHeight = headerBaseHeight + topSpacing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
|
<StatusBar barStyle={'light-content'} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
|
||||||
|
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
|
||||||
|
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||||
|
Plugins
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
<SettingsCard title="Add Plugin">
|
||||||
|
<View style={[styles.pluginInputContainer, { borderBottomColor: currentTheme.colors.elevation2 }]}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.pluginInput, { color: currentTheme.colors.highEmphasis }]}
|
||||||
|
placeholder="Enter plugin URL..."
|
||||||
|
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||||
|
value={pluginUrl}
|
||||||
|
onChangeText={setPluginUrl}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleLoadPlugin}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.loadButton, { backgroundColor: isLoadingPlugin ? currentTheme.colors.mediumGray : currentTheme.colors.primary }]}
|
||||||
|
onPress={handleLoadPlugin}
|
||||||
|
disabled={isLoadingPlugin}
|
||||||
|
>
|
||||||
|
{isLoadingPlugin ? (
|
||||||
|
<ActivityIndicator size="small" color={currentTheme.colors.white} />
|
||||||
|
) : (
|
||||||
|
<Text style={[styles.loadButtonText, { color: currentTheme.colors.white }]}>Load</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Loaded Scrapers">
|
||||||
|
{loadedPlugins.length > 0 ? (
|
||||||
|
loadedPlugins.map((plugin, index) => (
|
||||||
|
<SettingItem
|
||||||
|
key={plugin.sourceUrl || index}
|
||||||
|
icon="extension"
|
||||||
|
title={`${plugin.name} v${plugin.version}`}
|
||||||
|
description={plugin.sourceUrl ? 'External' : 'Built-in'}
|
||||||
|
isLast={index === loadedPlugins.length - 1}
|
||||||
|
renderControl={() =>
|
||||||
|
plugin.sourceUrl ? (
|
||||||
|
<TouchableOpacity onPress={() => handleRemovePlugin(plugin.sourceUrl!)} style={styles.removeButton}>
|
||||||
|
<MaterialIcons name="close" size={20} color={currentTheme.colors.warning} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<SettingItem
|
||||||
|
icon="extension"
|
||||||
|
title="No Custom Scrapers"
|
||||||
|
description="Add a plugin URL above to get started"
|
||||||
|
isLast={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsCard>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: Math.max(1, width * 0.05),
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
paddingBottom: 8,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: Math.max(1, width * 0.05),
|
||||||
|
bottom: 8,
|
||||||
|
zIndex: 10,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: Math.min(24, width * 0.06),
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
width: '100%',
|
||||||
|
paddingBottom: 100,
|
||||||
|
},
|
||||||
|
pluginInputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
pluginInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
loadButton: {
|
||||||
|
marginLeft: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
loadButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PluginsScreen;
|
||||||
|
|
@ -13,7 +13,9 @@ import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Image,
|
Image,
|
||||||
Button,
|
Button,
|
||||||
Linking
|
Linking,
|
||||||
|
TextInput,
|
||||||
|
ActivityIndicator
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
|
@ -29,18 +31,19 @@ import { useTheme } from '../contexts/ThemeContext';
|
||||||
import { catalogService } from '../services/catalogService';
|
import { catalogService } from '../services/catalogService';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import * as Sentry from '@sentry/react-native';
|
import * as Sentry from '@sentry/react-native';
|
||||||
|
import { pluginManager } from '../services/PluginManager';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
|
||||||
// Card component with minimalistic style
|
// Card component with minimalistic style
|
||||||
interface SettingsCardProps {
|
export interface SettingsCardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
|
export const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,7 +68,7 @@ const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SettingItemProps {
|
export interface SettingItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|
@ -75,7 +78,7 @@ interface SettingItemProps {
|
||||||
badge?: string | number;
|
badge?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SettingItem: React.FC<SettingItemProps> = ({
|
export const SettingItem: React.FC<SettingItemProps> = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -128,6 +131,12 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface LoadedPlugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const SettingsScreen: React.FC = () => {
|
const SettingsScreen: React.FC = () => {
|
||||||
const { settings, updateSetting } = useSettings();
|
const { settings, updateSetting } = useSettings();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
@ -157,6 +166,13 @@ const SettingsScreen: React.FC = () => {
|
||||||
const [addonCount, setAddonCount] = useState<number>(0);
|
const [addonCount, setAddonCount] = useState<number>(0);
|
||||||
const [catalogCount, setCatalogCount] = useState<number>(0);
|
const [catalogCount, setCatalogCount] = useState<number>(0);
|
||||||
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
|
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [loadedPlugins, setLoadedPlugins] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const refreshPluginsList = useCallback(() => {
|
||||||
|
const plugins = pluginManager.getScraperPlugins();
|
||||||
|
setLoadedPlugins(plugins.map(p => `${p.name} v${p.version}`));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -199,12 +215,14 @@ const SettingsScreen: React.FC = () => {
|
||||||
// Load data initially and when catalogs are updated
|
// Load data initially and when catalogs are updated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData, lastUpdate]);
|
refreshPluginsList();
|
||||||
|
}, [loadData, refreshPluginsList]);
|
||||||
|
|
||||||
// Add focus listener to reload data when screen comes into focus
|
// Add focus listener to reload data when screen comes into focus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = navigation.addListener('focus', () => {
|
const unsubscribe = navigation.addListener('focus', () => {
|
||||||
loadData();
|
loadData();
|
||||||
|
refreshPluginsList();
|
||||||
});
|
});
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
|
|
@ -388,6 +406,17 @@ const SettingsScreen: React.FC = () => {
|
||||||
isLast={true}
|
isLast={true}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Plugins">
|
||||||
|
<SettingItem
|
||||||
|
title="Manage Plugins"
|
||||||
|
description="Add or remove external plugins"
|
||||||
|
icon="extension"
|
||||||
|
onPress={() => navigation.navigate('Plugins')}
|
||||||
|
renderControl={ChevronRight}
|
||||||
|
isLast={true}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
{/* Playback & Experience */}
|
{/* Playback & Experience */}
|
||||||
<SettingsCard title="PLAYBACK">
|
<SettingsCard title="PLAYBACK">
|
||||||
|
|
@ -538,7 +567,7 @@ const styles = StyleSheet.create({
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
paddingBottom: 90,
|
paddingBottom: 100,
|
||||||
},
|
},
|
||||||
cardContainer: {
|
cardContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -652,6 +681,36 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
},
|
},
|
||||||
|
pluginInputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
pluginInput: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
loadButton: {
|
||||||
|
marginLeft: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
loadButtonText: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
removeButton: {
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 20,
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default SettingsScreen;
|
export default SettingsScreen;
|
||||||
|
|
@ -360,11 +360,17 @@ export const StreamsScreen = () => {
|
||||||
}
|
}
|
||||||
}, [selectedProvider, availableProviders]);
|
}, [selectedProvider, availableProviders]);
|
||||||
|
|
||||||
// Update useEffect to check for sources
|
// This effect will run when metadata is available or when other dependencies change.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkProviders = async () => {
|
// We need metadata to proceed to load streams.
|
||||||
|
if (!metadata) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkProvidersAndLoad = async () => {
|
||||||
// Check for Stremio addons
|
// Check for Stremio addons
|
||||||
const hasStremioProviders = await stremioService.hasStreamProviders();
|
const hasStremioProviders = await stremioService.hasStreamProviders();
|
||||||
|
// NOTE: This will be expanded to check for plugins later
|
||||||
const hasProviders = hasStremioProviders;
|
const hasProviders = hasStremioProviders;
|
||||||
|
|
||||||
if (!isMounted.current) return;
|
if (!isMounted.current) return;
|
||||||
|
|
@ -378,33 +384,34 @@ export const StreamsScreen = () => {
|
||||||
}, 500);
|
}, 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
} else {
|
} else {
|
||||||
if (type === 'series' && episodeId) {
|
if (type === 'series' && episodeId) {
|
||||||
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
|
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
|
||||||
setLoadingProviders({
|
setLoadingProviders({
|
||||||
'stremio': true
|
'stremio': true
|
||||||
});
|
});
|
||||||
setSelectedEpisode(episodeId);
|
setSelectedEpisode(episodeId);
|
||||||
setStreamsLoadStart(Date.now());
|
setStreamsLoadStart(Date.now());
|
||||||
loadEpisodeStreams(episodeId);
|
loadEpisodeStreams(episodeId);
|
||||||
} else if (type === 'movie') {
|
} else if (type === 'movie') {
|
||||||
logger.log(`🎬 Loading movie streams for: ${id}`);
|
logger.log(`🎬 Loading movie streams for: ${id}`);
|
||||||
setStreamsLoadStart(Date.now());
|
setStreamsLoadStart(Date.now());
|
||||||
loadStreams();
|
loadStreams();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset autoplay state when content changes
|
// Reset autoplay state when content changes
|
||||||
setAutoplayTriggered(false);
|
setAutoplayTriggered(false);
|
||||||
if (settings.autoplayBestStream) {
|
if (settings.autoplayBestStream) {
|
||||||
setIsAutoplayWaiting(true);
|
setIsAutoplayWaiting(true);
|
||||||
logger.log('🔄 Autoplay enabled, waiting for best stream...');
|
logger.log('🔄 Autoplay enabled, waiting for best stream...');
|
||||||
} else {
|
} else {
|
||||||
setIsAutoplayWaiting(false);
|
setIsAutoplayWaiting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
checkProviders();
|
checkProvidersAndLoad();
|
||||||
}, [type, id, episodeId, settings.autoplayBestStream]);
|
// Adding metadata to the dependency array ensures this runs after metadata is fetched.
|
||||||
|
}, [metadata, type, id, episodeId, settings.autoplayBestStream]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// Trigger entrance animations
|
// Trigger entrance animations
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
// Export all screens from a single file
|
// Export all screens from a single file
|
||||||
export { default as HomeScreen } from './HomeScreen';
|
export { default as PlayerSettingsScreen } from './PlayerSettingsScreen';
|
||||||
|
export { default as ProfilesScreen } from './ProfilesScreen';
|
||||||
export { default as SearchScreen } from './SearchScreen';
|
export { default as SearchScreen } from './SearchScreen';
|
||||||
export { default as AddonsScreen } from './AddonsScreen';
|
|
||||||
export { default as SettingsScreen } from './SettingsScreen';
|
export { default as SettingsScreen } from './SettingsScreen';
|
||||||
export { default as MetadataScreen } from './MetadataScreen';
|
|
||||||
export { default as CatalogScreen } from './CatalogScreen';
|
|
||||||
export { default as DiscoverScreen } from './DiscoverScreen';
|
|
||||||
export { default as LibraryScreen } from './LibraryScreen';
|
|
||||||
export { default as ShowRatingsScreen } from './ShowRatingsScreen';
|
export { default as ShowRatingsScreen } from './ShowRatingsScreen';
|
||||||
export { default as CatalogSettingsScreen } from './CatalogSettingsScreen';
|
|
||||||
export { default as StreamsScreen } from './StreamsScreen';
|
export { default as StreamsScreen } from './StreamsScreen';
|
||||||
export { default as OnboardingScreen } from './OnboardingScreen';
|
export { default as ThemeScreen } from './ThemeScreen';
|
||||||
|
export { default as TMDBSettingsScreen } from './TMDBSettingsScreen';
|
||||||
|
export { default as TraktSettingsScreen } from './TraktSettingsScreen';
|
||||||
|
export { default as PluginsScreen } from './PluginsScreen';
|
||||||
355
src/services/PluginManager.ts
Normal file
355
src/services/PluginManager.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore – no types for Babel standalone
|
||||||
|
const Babel = require('@babel/standalone');
|
||||||
|
|
||||||
|
const PLUGIN_URLS_STORAGE_KEY = '@plugin_urls';
|
||||||
|
|
||||||
|
// --- Type Definitions ---
|
||||||
|
|
||||||
|
interface Plugin {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
author: string;
|
||||||
|
description: string;
|
||||||
|
type: 'scraper' | 'other';
|
||||||
|
getStreams: (options: GetStreamsOptions) => Promise<Stream[]>;
|
||||||
|
sourceUrl?: string; // To track the origin of the plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stream {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
quality: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetStreamsOptions {
|
||||||
|
tmdbId: string;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
seasonNum?: number;
|
||||||
|
episodeNum?: number;
|
||||||
|
tmdbApiKey: string;
|
||||||
|
// Optional metadata to avoid extra API calls
|
||||||
|
title?: string;
|
||||||
|
year?: string | number;
|
||||||
|
// Injected properties
|
||||||
|
logger: typeof logger;
|
||||||
|
cache: Cache;
|
||||||
|
fetch: typeof fetch;
|
||||||
|
fetchWithCookies: (url: string, options?: RequestInit) => Promise<Response>;
|
||||||
|
setCookie: (key: string, value: string) => void;
|
||||||
|
parseHTML: (html: string) => cheerio.CheerioAPI;
|
||||||
|
URL: typeof URL;
|
||||||
|
URLSearchParams: typeof URLSearchParams;
|
||||||
|
FormData: typeof FormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cache {
|
||||||
|
get: <T>(key: string) => Promise<T | null>;
|
||||||
|
set: (key: string, value: any, ttlInSeconds: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Simple In-Memory Cache with TTL ---
|
||||||
|
|
||||||
|
const cacheStore = new Map<string, { value: any; expiry: number }>();
|
||||||
|
const simpleCache: Cache = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const item = cacheStore.get(key);
|
||||||
|
if (!item) return null;
|
||||||
|
if (Date.now() > item.expiry) {
|
||||||
|
cacheStore.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item.value as T;
|
||||||
|
},
|
||||||
|
async set(key: string, value: any, ttlInSeconds: number): Promise<void> {
|
||||||
|
const expiry = Date.now() + ttlInSeconds * 1000;
|
||||||
|
cacheStore.set(key, { value, expiry });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Cookie-enabled Fetch ---
|
||||||
|
|
||||||
|
class CookieJar {
|
||||||
|
private cookies: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
set(setCookieHeader: string | undefined) {
|
||||||
|
if (!setCookieHeader) return;
|
||||||
|
// Simple parsing, doesn't handle all attributes like Path, Expires, etc.
|
||||||
|
setCookieHeader.split(';').forEach(cookiePart => {
|
||||||
|
const [key, ...valueParts] = cookiePart.split('=');
|
||||||
|
if (key && valueParts.length > 0) {
|
||||||
|
this.cookies.set(key.trim(), valueParts.join('=').trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a method to set a cookie directly by key/value
|
||||||
|
setCookie(key: string, value: string) {
|
||||||
|
if (key && value) {
|
||||||
|
this.cookies.set(key.trim(), value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(): string {
|
||||||
|
// Return all cookies as a single string
|
||||||
|
return Array.from(this.cookies.entries())
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Plugin Manager ---
|
||||||
|
|
||||||
|
class PluginManager {
|
||||||
|
private plugins: Plugin[] = [];
|
||||||
|
private static instance: PluginManager;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.loadBuiltInPlugins();
|
||||||
|
this.loadPersistedPlugins().catch(err => {
|
||||||
|
logger.error('[PluginManager] Error during async initialization', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static getInstance(): PluginManager {
|
||||||
|
if (!PluginManager.instance) {
|
||||||
|
PluginManager.instance = new PluginManager();
|
||||||
|
}
|
||||||
|
return PluginManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadPersistedPlugins() {
|
||||||
|
try {
|
||||||
|
const storedUrlsJson = await AsyncStorage.getItem(PLUGIN_URLS_STORAGE_KEY);
|
||||||
|
if (storedUrlsJson) {
|
||||||
|
const urls = JSON.parse(storedUrlsJson);
|
||||||
|
if (Array.isArray(urls)) {
|
||||||
|
logger.log('[PluginManager] Loading persisted plugins...', urls);
|
||||||
|
for (const url of urls) {
|
||||||
|
await this.loadPluginFromUrl(url, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PluginManager] Failed to load persisted plugins:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistPluginUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const storedUrlsJson = await AsyncStorage.getItem(PLUGIN_URLS_STORAGE_KEY);
|
||||||
|
let urls: string[] = [];
|
||||||
|
if (storedUrlsJson) {
|
||||||
|
urls = JSON.parse(storedUrlsJson);
|
||||||
|
}
|
||||||
|
if (!urls.includes(url)) {
|
||||||
|
urls.push(url);
|
||||||
|
await AsyncStorage.setItem(PLUGIN_URLS_STORAGE_KEY, JSON.stringify(urls));
|
||||||
|
logger.log(`[PluginManager] Persisted plugin URL: ${url}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PluginManager] Failed to persist plugin URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async removePersistedPluginUrl(url: string) {
|
||||||
|
try {
|
||||||
|
const storedUrlsJson = await AsyncStorage.getItem(PLUGIN_URLS_STORAGE_KEY);
|
||||||
|
if (storedUrlsJson) {
|
||||||
|
let urls: string[] = JSON.parse(storedUrlsJson);
|
||||||
|
const index = urls.indexOf(url);
|
||||||
|
if (index > -1) {
|
||||||
|
urls.splice(index, 1);
|
||||||
|
await AsyncStorage.setItem(PLUGIN_URLS_STORAGE_KEY, JSON.stringify(urls));
|
||||||
|
logger.log(`[PluginManager] Removed persisted plugin URL: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PluginManager] Failed to remove persisted plugin URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadPluginFromUrl(url: string, persist = true): Promise<boolean> {
|
||||||
|
logger.log(`[PluginManager] Attempting to load plugin from URL: ${url}`);
|
||||||
|
|
||||||
|
if (this.plugins.some(p => p.sourceUrl === url)) {
|
||||||
|
logger.log(`[PluginManager] Plugin from URL ${url} is already loaded.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch plugin from URL: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const pluginCode = await response.text();
|
||||||
|
const newPlugin = this.runPlugin(pluginCode, url);
|
||||||
|
|
||||||
|
if (newPlugin) {
|
||||||
|
if (persist) {
|
||||||
|
await this.persistPluginUrl(url);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(`[PluginManager] Plugin from ${url} executed but failed to register.`);
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[PluginManager] Failed to load plugin from URL ${url}:`, error);
|
||||||
|
if (persist === false) {
|
||||||
|
logger.log(`[PluginManager] Removing failed persisted plugin URL: ${url}`);
|
||||||
|
await this.removePersistedPluginUrl(url);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBuiltInPlugins() {
|
||||||
|
try {
|
||||||
|
// Provide registerPlugin globally for built-in modules
|
||||||
|
(global as any).registerPlugin = (plugin: Plugin) => {
|
||||||
|
if (plugin && typeof plugin.getStreams === 'function') {
|
||||||
|
this.plugins.push(plugin);
|
||||||
|
logger.log(`[PluginManager] Successfully registered plugin: ${plugin.name} v${plugin.version}`);
|
||||||
|
} else {
|
||||||
|
logger.error('[PluginManager] An invalid plugin was passed to registerPlugin.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Require and execute the built-in MoviesMod plugin module (IIFE)
|
||||||
|
require('./plugins/moviesmod.plugin.js');
|
||||||
|
|
||||||
|
delete (global as any).registerPlugin;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PluginManager] Failed to load built-in MoviesMod plugin:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public removePlugin(sourceUrl: string) {
|
||||||
|
const pluginIndex = this.plugins.findIndex(p => p.sourceUrl === sourceUrl);
|
||||||
|
if (pluginIndex > -1) {
|
||||||
|
const plugin = this.plugins[pluginIndex];
|
||||||
|
this.plugins.splice(pluginIndex, 1);
|
||||||
|
logger.log(`[PluginManager] Removed plugin: ${plugin.name}`);
|
||||||
|
if (plugin.sourceUrl) {
|
||||||
|
this.removePersistedPluginUrl(plugin.sourceUrl).catch(err => {
|
||||||
|
logger.error(`[PluginManager] Failed to remove persisted URL: ${plugin.sourceUrl}`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private runPlugin(pluginCode: string, sourceUrl?: string): Plugin | null {
|
||||||
|
let registeredPlugin: Plugin | null = null;
|
||||||
|
const pluginsBefore = this.plugins.length;
|
||||||
|
|
||||||
|
// Attempt to strip the JSDoc-style header comment which may cause parsing issues in some JS engines.
|
||||||
|
const strippedCode = pluginCode.replace(/^\s*\/\*\*[\s\S]*?\*\/\s*/, '');
|
||||||
|
logger.log('[PluginManager] Executing plugin code...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Temporarily expose registerPlugin on the global object for the sandboxed code to use.
|
||||||
|
// This is simpler and more reliable than using `with` or the Function constructor's scope.
|
||||||
|
(global as any).registerPlugin = (plugin: Plugin) => {
|
||||||
|
if (plugin && typeof plugin.getStreams === 'function') {
|
||||||
|
if (sourceUrl) plugin.sourceUrl = sourceUrl;
|
||||||
|
this.plugins.push(plugin);
|
||||||
|
registeredPlugin = plugin;
|
||||||
|
logger.log(`[PluginManager] Successfully registered plugin: ${plugin.name} v${plugin.version}`);
|
||||||
|
} else {
|
||||||
|
logger.error('[PluginManager] An invalid plugin was passed to registerPlugin.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The plugin IIFE will execute immediately.
|
||||||
|
// Using eval is a trade-off for simplicity and to avoid potential Function constructor issues.
|
||||||
|
// The code is user-provided, so this is a calculated risk.
|
||||||
|
|
||||||
|
// Transpile the code first to support modern JS features like async/await in the runtime.
|
||||||
|
const transformedCode = (Babel as any).transform(strippedCode, {
|
||||||
|
presets: ['env'],
|
||||||
|
sourceType: 'script',
|
||||||
|
}).code;
|
||||||
|
|
||||||
|
eval(transformedCode);
|
||||||
|
|
||||||
|
if (this.plugins.length === pluginsBefore) {
|
||||||
|
logger.warn('[PluginManager] Plugin code executed, but no plugin was registered.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[PluginManager] Error executing plugin code:', error);
|
||||||
|
} finally {
|
||||||
|
// Clean up the global scope to prevent pollution
|
||||||
|
delete (global as any).registerPlugin;
|
||||||
|
}
|
||||||
|
return registeredPlugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getScraperPlugins(): Plugin[] {
|
||||||
|
return this.plugins.filter(p => p.type === 'scraper');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAllStreams(options: Omit<GetStreamsOptions, 'logger' | 'cache' | 'fetch' | 'fetchWithCookies' | 'setCookie' | 'parseHTML' | 'URL' | 'URLSearchParams' | 'FormData'>): Promise<Stream[]> {
|
||||||
|
const scrapers = this.getScraperPlugins();
|
||||||
|
if (scrapers.length === 0) {
|
||||||
|
logger.log('[PluginManager] No scraper plugins loaded.');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const allStreams: Stream[] = [];
|
||||||
|
|
||||||
|
const cookieJar = new CookieJar();
|
||||||
|
const fetchWithCookies = async (url: string, opts: RequestInit = {}): Promise<Response> => {
|
||||||
|
const domain = new URL(url).hostname;
|
||||||
|
opts.headers = { ...opts.headers, 'Cookie': cookieJar.get() };
|
||||||
|
|
||||||
|
const response = await fetch(url, opts);
|
||||||
|
|
||||||
|
const setCookieHeader = response.headers.get('Set-Cookie');
|
||||||
|
if (setCookieHeader) {
|
||||||
|
cookieJar.set(setCookieHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const streamPromises = scrapers.map(scraper => {
|
||||||
|
const injectedOptions: GetStreamsOptions = {
|
||||||
|
...options,
|
||||||
|
logger: logger,
|
||||||
|
cache: simpleCache,
|
||||||
|
fetch: fetch,
|
||||||
|
fetchWithCookies: fetchWithCookies,
|
||||||
|
// Expose a function to set a cookie in the jar
|
||||||
|
setCookie: (key: string, value: string) => {
|
||||||
|
cookieJar.setCookie(key, value);
|
||||||
|
},
|
||||||
|
parseHTML: cheerio.load,
|
||||||
|
URL: URL,
|
||||||
|
URLSearchParams: URLSearchParams,
|
||||||
|
FormData: FormData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return scraper.getStreams(injectedOptions)
|
||||||
|
.catch(error => {
|
||||||
|
logger.error(`[PluginManager] Scraper '${scraper.name}' failed:`, error);
|
||||||
|
return []; // Return empty array on failure
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(streamPromises);
|
||||||
|
results.forEach(streams => allStreams.push(...streams));
|
||||||
|
|
||||||
|
logger.log(`[PluginManager] Found a total of ${allStreams.length} streams from ${scrapers.length} scrapers.`);
|
||||||
|
return allStreams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pluginManager = PluginManager.getInstance();
|
||||||
|
|
@ -68,6 +68,7 @@ export interface StreamingContent {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
imdb_id?: string;
|
imdb_id?: string;
|
||||||
|
tmdbId?: number;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
releaseInfo?: string;
|
releaseInfo?: string;
|
||||||
traktSource?: 'watchlist' | 'continue-watching' | 'watched';
|
traktSource?: 'watchlist' | 'continue-watching' | 'watched';
|
||||||
|
|
@ -592,6 +593,7 @@ class CatalogService {
|
||||||
writer: (meta as any).writer || undefined,
|
writer: (meta as any).writer || undefined,
|
||||||
country: (meta as any).country || undefined,
|
country: (meta as any).country || undefined,
|
||||||
imdb_id: (meta as any).imdb_id || undefined,
|
imdb_id: (meta as any).imdb_id || undefined,
|
||||||
|
tmdbId: (meta as any).tmdbId || undefined,
|
||||||
slug: (meta as any).slug || undefined,
|
slug: (meta as any).slug || undefined,
|
||||||
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
|
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
|
||||||
trailerStreams: (meta as any).trailerStreams || undefined,
|
trailerStreams: (meta as any).trailerStreams || undefined,
|
||||||
|
|
|
||||||
0
src/services/plugins/TXT.txt
Normal file
0
src/services/plugins/TXT.txt
Normal file
749
src/services/plugins/moviesmod.plugin.js
Normal file
749
src/services/plugins/moviesmod.plugin.js
Normal file
|
|
@ -0,0 +1,749 @@
|
||||||
|
/**
|
||||||
|
* MoviesMod Provider Plugin
|
||||||
|
*
|
||||||
|
* This plugin is a conversion of the original moviesmod.js provider.
|
||||||
|
* It's designed to run in a sandboxed environment within the Nuvio app,
|
||||||
|
* with specific helper functions and objects injected by the PluginManager.
|
||||||
|
*
|
||||||
|
* Required Injected Context:
|
||||||
|
* - `logger`: A logging utility (e.g., console).
|
||||||
|
* - `cache`: An object with `get(key)` and `set(key, value, ttl_in_seconds)` methods for caching data.
|
||||||
|
* - `fetch`: A standard `fetch` implementation.
|
||||||
|
* - `fetchWithCookies`: A `fetch` wrapper that maintains a cookie jar for a session.
|
||||||
|
* - `parseHTML`: A function that takes an HTML string and returns a Cheerio-like object for DOM parsing.
|
||||||
|
* - `URL`: The standard URL constructor.
|
||||||
|
* - `URLSearchParams`: The standard URLSearchParams constructor.
|
||||||
|
* - `FormData`: The standard FormData constructor.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- Embedded Dependencies ---
|
||||||
|
|
||||||
|
// string-similarity: To find the best matching search result.
|
||||||
|
function compareTwoStrings(first, second) {
|
||||||
|
first = first.replace(/\s+/g, '');
|
||||||
|
second = second.replace(/\s+/g, '');
|
||||||
|
if (first === second) return 1;
|
||||||
|
if (first.length < 2 || second.length < 2) return 0;
|
||||||
|
let firstBigrams = new Map();
|
||||||
|
for (let i = 0; i < first.length - 1; i++) {
|
||||||
|
const bigram = first.substring(i, i + 2);
|
||||||
|
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) + 1 : 1;
|
||||||
|
firstBigrams.set(bigram, count);
|
||||||
|
}
|
||||||
|
let intersectionSize = 0;
|
||||||
|
for (let i = 0; i < second.length - 1; i++) {
|
||||||
|
const bigram = second.substring(i, i + 2);
|
||||||
|
const count = firstBigrams.has(bigram) ? firstBigrams.get(bigram) : 0;
|
||||||
|
if (count > 0) {
|
||||||
|
firstBigrams.set(bigram, count - 1);
|
||||||
|
intersectionSize++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (2.0 * intersectionSize) / (first.length + second.length - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBestMatch(mainString, targetStrings) {
|
||||||
|
const ratings = targetStrings.map(target => ({
|
||||||
|
target,
|
||||||
|
rating: compareTwoStrings(mainString, target)
|
||||||
|
}));
|
||||||
|
let bestMatchIndex = 0;
|
||||||
|
for (let i = 1; i < ratings.length; i++) {
|
||||||
|
if (ratings[i].rating > ratings[bestMatchIndex].rating) {
|
||||||
|
bestMatchIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ratings, bestMatch: ratings[bestMatchIndex], bestMatchIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(string) {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Plugin Definition ---
|
||||||
|
|
||||||
|
const plugin = {
|
||||||
|
name: 'MoviesMod',
|
||||||
|
version: '1.1.0',
|
||||||
|
author: 'Nuvio',
|
||||||
|
description: 'Scraper for MoviesMod. Provides movie and TV show streams.',
|
||||||
|
type: 'scraper',
|
||||||
|
getStreams: mainGetStreams,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- Domain and Caching ---
|
||||||
|
|
||||||
|
let moviesModDomain = 'https://moviesmod.chat'; // Fallback domain
|
||||||
|
let domainCacheTimestamp = 0;
|
||||||
|
const DOMAIN_CACHE_TTL = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
|
|
||||||
|
async function getMoviesModDomain(injected) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - domainCacheTimestamp < DOMAIN_CACHE_TTL) {
|
||||||
|
return moviesModDomain;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
injected.logger.log('[MoviesMod] Fetching latest domain...');
|
||||||
|
const response = await injected.fetch('https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json', { timeout: 10000 });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.moviesmod) {
|
||||||
|
moviesModDomain = data.moviesmod;
|
||||||
|
domainCacheTimestamp = now;
|
||||||
|
injected.logger.log(`[MoviesMod] Updated domain to: ${moviesModDomain}`);
|
||||||
|
} else {
|
||||||
|
injected.logger.warn('[MoviesMod] Domain JSON fetched, but "moviesmod" key was not found. Using fallback.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Failed to fetch latest domain, using fallback. Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
return moviesModDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
|
||||||
|
function extractQuality(text) {
|
||||||
|
if (!text) return 'Unknown';
|
||||||
|
const qualityMatch = text.match(/(480p|720p|1080p|2160p|4k)/i);
|
||||||
|
if (qualityMatch) return qualityMatch[1];
|
||||||
|
const cleanMatch = text.match(/(480p|720p|1080p|2160p|4k)[^)]*\)/i);
|
||||||
|
if (cleanMatch) return cleanMatch[0];
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQualityForSort(qualityString) {
|
||||||
|
if (!qualityString) return 0;
|
||||||
|
const match = qualityString.match(/(\d{3,4})p/i);
|
||||||
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTechDetails(qualityString) {
|
||||||
|
if (!qualityString) return [];
|
||||||
|
const details = [];
|
||||||
|
const lowerText = qualityString.toLowerCase();
|
||||||
|
if (lowerText.includes('10bit')) details.push('10-bit');
|
||||||
|
if (lowerText.includes('hevc') || lowerText.includes('x265')) details.push('HEVC');
|
||||||
|
if (lowerText.includes('hdr')) details.push('HDR');
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Core Scraping Logic ---
|
||||||
|
|
||||||
|
async function searchMoviesMod(query, injected) {
|
||||||
|
try {
|
||||||
|
const baseUrl = await getMoviesModDomain(injected);
|
||||||
|
const searchUrl = `${baseUrl}/?s=${encodeURIComponent(query)}`;
|
||||||
|
const response = await injected.fetch(searchUrl);
|
||||||
|
const data = await response.text();
|
||||||
|
const $ = injected.parseHTML(data);
|
||||||
|
const results = [];
|
||||||
|
$('.latestPost').each((i, element) => {
|
||||||
|
const linkElement = $(element).find('a');
|
||||||
|
const title = linkElement.attr('title');
|
||||||
|
const url = linkElement.attr('href');
|
||||||
|
if (title && url) {
|
||||||
|
results.push({ title, url });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error searching: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractDownloadLinks(moviePageUrl, injected) {
|
||||||
|
try {
|
||||||
|
const response = await injected.fetch(moviePageUrl);
|
||||||
|
const data = await response.text();
|
||||||
|
const $ = injected.parseHTML(data);
|
||||||
|
const links = [];
|
||||||
|
const contentBox = $('.thecontent');
|
||||||
|
const headers = contentBox.find('h3:contains("Season"), h4');
|
||||||
|
|
||||||
|
headers.each((i, el) => {
|
||||||
|
const header = $(el);
|
||||||
|
const headerText = header.text().trim();
|
||||||
|
const blockContent = header.nextUntil('h3, h4');
|
||||||
|
|
||||||
|
if (header.is('h3') && headerText.toLowerCase().includes('season')) {
|
||||||
|
const linkElements = blockContent.find('a.maxbutton-episode-links, a.maxbutton-batch-zip');
|
||||||
|
linkElements.each((j, linkEl) => {
|
||||||
|
const buttonText = $(linkEl).text().trim();
|
||||||
|
const linkUrl = $(linkEl).attr('href');
|
||||||
|
if (linkUrl && !buttonText.toLowerCase().includes('batch')) {
|
||||||
|
links.push({ quality: `${headerText} - ${buttonText}`, url: linkUrl });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (header.is('h4')) {
|
||||||
|
const linkElement = blockContent.find('a[href*="modrefer.in"]').first();
|
||||||
|
if (linkElement.length > 0) {
|
||||||
|
const link = linkElement.attr('href');
|
||||||
|
links.push({ quality: extractQuality(headerText), url: link });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error extracting download links: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveIntermediateLink(initialUrl, refererUrl, quality, injected) {
|
||||||
|
try {
|
||||||
|
const urlObject = new injected.URL(initialUrl);
|
||||||
|
|
||||||
|
if (urlObject.hostname.includes('dramadrip.com')) {
|
||||||
|
const response = await injected.fetchWithCookies(initialUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const dramaData = await response.text();
|
||||||
|
const $$ = injected.parseHTML(dramaData);
|
||||||
|
let episodePageLink = null;
|
||||||
|
const seasonMatch = quality.match(/Season \d+/i);
|
||||||
|
const specificQualityMatch = quality.match(/(480p|720p|1080p|2160p|4k)[ \w\d-]*/i);
|
||||||
|
|
||||||
|
if (seasonMatch && specificQualityMatch) {
|
||||||
|
const seasonIdentifier = seasonMatch[0].toLowerCase();
|
||||||
|
const qualityParts = specificQualityMatch[0].toLowerCase().replace(/msubs.*/i, '').replace(/esubs.*/i, '').replace(/\{.*/, '').trim().split(/\s+/);
|
||||||
|
|
||||||
|
$$('a[href*="episodes.modpro.blog"], a[href*="cinematickit.org"]').each((i, el) => {
|
||||||
|
const link = $$(el);
|
||||||
|
const linkText = link.text().trim().toLowerCase();
|
||||||
|
const seasonHeader = link.closest('.wp-block-buttons').prevAll('h2.wp-block-heading').first().text().trim().toLowerCase();
|
||||||
|
const seasonIsMatch = seasonHeader.includes(seasonIdentifier);
|
||||||
|
const allPartsMatch = qualityParts.every(part => linkText.includes(part));
|
||||||
|
|
||||||
|
if (seasonIsMatch && allPartsMatch) {
|
||||||
|
episodePageLink = link.attr('href');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!episodePageLink) return [];
|
||||||
|
return await resolveIntermediateLink(episodePageLink, initialUrl, quality, injected);
|
||||||
|
|
||||||
|
} else if (urlObject.hostname.includes('cinematickit.org')) {
|
||||||
|
const response = await injected.fetchWithCookies(initialUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const data = await response.text();
|
||||||
|
const $ = injected.parseHTML(data);
|
||||||
|
const finalLinks = [];
|
||||||
|
$('a[href*="driveseed.org"]').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link && text && !text.toLowerCase().includes('batch')) {
|
||||||
|
finalLinks.push({ server: text.replace(/\s+/g, ' '), url: link });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (finalLinks.length === 0) {
|
||||||
|
$('a[href*="modrefer.in"], a[href*="dramadrip.com"]').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link && text) {
|
||||||
|
finalLinks.push({ server: text.replace(/\s+/g, ' '), url: link });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return finalLinks;
|
||||||
|
|
||||||
|
} else if (urlObject.hostname.includes('episodes.modpro.blog')) {
|
||||||
|
const response = await injected.fetchWithCookies(initialUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const data = await response.text();
|
||||||
|
const $ = injected.parseHTML(data);
|
||||||
|
const finalLinks = [];
|
||||||
|
$('.entry-content a[href*="driveseed.org"], .entry-content a[href*="tech.unblockedgames.world"], .entry-content a[href*="tech.creativeexpressionsblog.com"]').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link && text && !text.toLowerCase().includes('batch')) {
|
||||||
|
finalLinks.push({ server: text.replace(/\s+/g, ' '), url: link });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return finalLinks;
|
||||||
|
|
||||||
|
} else if (urlObject.hostname.includes('modrefer.in')) {
|
||||||
|
const encodedUrl = new injected.URL(initialUrl).searchParams.get('url');
|
||||||
|
if (!encodedUrl) return [];
|
||||||
|
const decodedUrl = atob(encodedUrl);
|
||||||
|
const response = await injected.fetchWithCookies(decodedUrl, { headers: { 'Referer': refererUrl } });
|
||||||
|
const data = await response.text();
|
||||||
|
const $ = injected.parseHTML(data);
|
||||||
|
const finalLinks = [];
|
||||||
|
$('.timed-content-client_show_0_5_0 a').each((i, el) => {
|
||||||
|
const link = $(el).attr('href');
|
||||||
|
const text = $(el).text().trim();
|
||||||
|
if (link) {
|
||||||
|
finalLinks.push({ server: text, url: link });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return finalLinks;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error resolving intermediate link: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTechUnblockedLink(sidUrl, injected) {
|
||||||
|
injected.logger.log(`[MoviesMod] Resolving SID link: ${sidUrl}`);
|
||||||
|
const { origin } = new injected.URL(sidUrl);
|
||||||
|
try {
|
||||||
|
const responseStep0 = await injected.fetchWithCookies(sidUrl);
|
||||||
|
let html = await responseStep0.text();
|
||||||
|
let $ = injected.parseHTML(html);
|
||||||
|
const initialForm = $('#landing');
|
||||||
|
const wp_http_step1 = initialForm.find('input[name="_wp_http"]').val();
|
||||||
|
const action_url_step1 = initialForm.attr('action');
|
||||||
|
if (!wp_http_step1 || !action_url_step1) {
|
||||||
|
injected.logger.warn('[MoviesMod SID] Could not find initial form.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step1Data = new injected.URLSearchParams({ '_wp_http': wp_http_step1 });
|
||||||
|
const responseStep1 = await injected.fetchWithCookies(action_url_step1, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Referer': sidUrl, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: step1Data.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
html = await responseStep1.text();
|
||||||
|
$ = injected.parseHTML(html);
|
||||||
|
const verificationForm = $('#landing');
|
||||||
|
const action_url_step2 = verificationForm.attr('action');
|
||||||
|
const wp_http2 = verificationForm.find('input[name="_wp_http2"]').val();
|
||||||
|
const token = verificationForm.find('input[name="token"]').val();
|
||||||
|
if (!action_url_step2) {
|
||||||
|
injected.logger.warn('[MoviesMod SID] Could not find verification form.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step2Data = new injected.URLSearchParams({ '_wp_http2': wp_http2, 'token': token });
|
||||||
|
const responseStep2 = await injected.fetchWithCookies(action_url_step2, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Referer': responseStep1.url, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: step2Data.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const scriptContent = await responseStep2.text();
|
||||||
|
|
||||||
|
const cookieMatch = scriptContent.match(/s_343\('([^']+)',\s*'([^']+)'/);
|
||||||
|
const linkMatch = scriptContent.match(/c\.setAttribute\("href",\s*"([^"]+)"\)/);
|
||||||
|
|
||||||
|
if (!linkMatch) {
|
||||||
|
injected.logger.warn('[MoviesMod SID] Could not find final link in script.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (cookieMatch) {
|
||||||
|
const cookieName = cookieMatch[1].trim();
|
||||||
|
const cookieValue = cookieMatch[2].trim();
|
||||||
|
injected.logger.log(`[MoviesMod SID] Found dynamic cookie: ${cookieName}. Setting it now.`);
|
||||||
|
injected.setCookie(cookieName, cookieValue);
|
||||||
|
} else {
|
||||||
|
injected.logger.warn('[MoviesMod SID] Could not find dynamic cookie in script.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalUrl = new injected.URL(linkMatch[1], origin).href;
|
||||||
|
injected.logger.log(`[MoviesMod SID] Following final link: ${finalUrl}`);
|
||||||
|
const finalResponse = await injected.fetchWithCookies(finalUrl, { headers: { 'Referer': responseStep2.url } });
|
||||||
|
|
||||||
|
html = await finalResponse.text();
|
||||||
|
$ = injected.parseHTML(html);
|
||||||
|
const metaRefresh = $('meta[http-equiv="refresh"]');
|
||||||
|
if (metaRefresh.length > 0) {
|
||||||
|
const content = metaRefresh.attr('content');
|
||||||
|
const urlMatch = content.match(/url=(.*)/i);
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
const driveleechUrl = urlMatch[1].replace(/"/g, "").replace(/'/g, "");
|
||||||
|
injected.logger.log(`[MoviesMod SID] Success! Resolved Driveleech URL: ${driveleechUrl}`);
|
||||||
|
return driveleechUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
injected.logger.warn('[MoviesMod SID] Could not find meta refresh tag in final response.');
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error during SID resolution: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDriveseedLink(driveseedUrl, injected) {
|
||||||
|
try {
|
||||||
|
const response = await injected.fetch(driveseedUrl, { headers: { 'Referer': 'https://links.modpro.blog/' } });
|
||||||
|
const data = await response.text();
|
||||||
|
const redirectMatch = data.match(/window\.location\.replace\("([^"]+)"\)/);
|
||||||
|
if (redirectMatch && redirectMatch[1]) {
|
||||||
|
const finalUrl = `https://driveseed.org${redirectMatch[1]}`;
|
||||||
|
const finalResponse = await injected.fetch(finalUrl, { headers: { 'Referer': driveseedUrl } });
|
||||||
|
const $ = injected.parseHTML(await finalResponse.text());
|
||||||
|
const downloadOptions = [];
|
||||||
|
let size = null;
|
||||||
|
let fileName = null;
|
||||||
|
|
||||||
|
$('ul.list-group li').each((i, el) => {
|
||||||
|
const text = $(el).text();
|
||||||
|
if (text.includes('Size :')) size = text.split(':')[1].trim();
|
||||||
|
else if (text.includes('Name :')) fileName = text.split(':')[1].trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeCloudLink = $('a:contains("Resume Cloud")').attr('href');
|
||||||
|
if (resumeCloudLink) downloadOptions.push({ title: 'Resume Cloud', type: 'resume', url: `https://driveseed.org${resumeCloudLink}`, priority: 1 });
|
||||||
|
const workerSeedLink = $('a:contains("Resume Worker Bot")').attr('href');
|
||||||
|
if (workerSeedLink) downloadOptions.push({ title: 'Resume Worker Bot', type: 'worker', url: workerSeedLink, priority: 2 });
|
||||||
|
const instantDownloadLink = $('a:contains("Instant Download")').attr('href');
|
||||||
|
if (instantDownloadLink) downloadOptions.push({ title: 'Instant Download', type: 'instant', url: instantDownloadLink, priority: 3 });
|
||||||
|
|
||||||
|
downloadOptions.sort((a, b) => a.priority - b.priority);
|
||||||
|
return { downloadOptions, size, fileName };
|
||||||
|
}
|
||||||
|
return { downloadOptions: [], size: null, fileName: null };
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error resolving Driveseed link: ${error.message}`);
|
||||||
|
return { downloadOptions: [], size: null, fileName: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveResumeCloudLink(resumeUrl, injected) {
|
||||||
|
try {
|
||||||
|
const response = await injected.fetch(resumeUrl, { headers: { 'Referer': 'https://driveseed.org/' } });
|
||||||
|
const $ = injected.parseHTML(await response.text());
|
||||||
|
return $('a:contains("Cloud Resume Download")').attr('href') || null;
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error resolving Resume Cloud link: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorkerSeedLink(workerSeedUrl, injected) {
|
||||||
|
try {
|
||||||
|
injected.logger.log(`[MoviesMod] Resolving WorkerSeed link: ${workerSeedUrl}`);
|
||||||
|
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
const pageResponse = await injected.fetchWithCookies(workerSeedUrl, {
|
||||||
|
headers: { 'User-Agent': userAgent }
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageHtml = await pageResponse.text();
|
||||||
|
const scriptContent = pageHtml.match(/<script type="text\/javascript">([\s\S]*?)<\/script>/g)?.find(s => s.includes("formData.append('token'") || s.includes("fetch('/download?id="));
|
||||||
|
|
||||||
|
if (!scriptContent) {
|
||||||
|
injected.logger.warn('[MoviesMod] WorkerSeed: Could not find relevant script tag.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenMatch = scriptContent.match(/formData\.append\('token', '([^']+)'\)/);
|
||||||
|
let idMatch = scriptContent.match(/fetch\('\/download\?id=([^']+)',/);
|
||||||
|
|
||||||
|
// Add fallbacks from original script
|
||||||
|
if (!tokenMatch) {
|
||||||
|
tokenMatch = scriptContent.match(/token['"]?\s*[:=]\s*['"]([^'"]+)['"]/);
|
||||||
|
}
|
||||||
|
if (!idMatch) {
|
||||||
|
idMatch = scriptContent.match(/id['"]?\s*[:=]\s*['"]([^'"]+)['"]/);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tokenMatch || !tokenMatch[1] || !idMatch || !idMatch[1]) {
|
||||||
|
injected.logger.error('[MoviesMod] WorkerSeed: Could not extract token or ID from script.');
|
||||||
|
injected.logger.log('[MoviesMod] WorkerSeed script snippet:', scriptContent.substring(0, 500));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = tokenMatch[1];
|
||||||
|
const correctId = idMatch[1];
|
||||||
|
injected.logger.log(`[MoviesMod] WorkerSeed: Extracted token and ID.`);
|
||||||
|
const apiUrl = `https://workerseed.dev/download?id=${correctId}`;
|
||||||
|
|
||||||
|
// Use multipart/form-data (FormData) as the original site expects.
|
||||||
|
const formData = new injected.FormData();
|
||||||
|
formData.append('token', token);
|
||||||
|
|
||||||
|
const apiResponse = await injected.fetchWithCookies(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': userAgent,
|
||||||
|
'Referer': workerSeedUrl,
|
||||||
|
'x-requested-with': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
// NOTE: We purposely do NOT set Content-Type so RN fetch will add the correct multipart boundary.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiResponse.ok) {
|
||||||
|
injected.logger.error(`[MoviesMod] WorkerSeed API request failed with status ${apiResponse.status}`);
|
||||||
|
const errorText = await apiResponse.text();
|
||||||
|
injected.logger.error(`[MoviesMod] WorkerSeed API response: ${errorText.substring(0, 300)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await apiResponse.text();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(responseText);
|
||||||
|
if (data.url) {
|
||||||
|
injected.logger.log(`[MoviesMod] ✓ Successfully resolved worker-seed link`);
|
||||||
|
return data.url;
|
||||||
|
} else {
|
||||||
|
injected.logger.warn('[MoviesMod] WorkerSeed API did not return a URL in JSON.');
|
||||||
|
injected.logger.log(data);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: try to parse HTML for a direct link (sometimes returned instead of JSON)
|
||||||
|
injected.logger.warn('[MoviesMod] WorkerSeed API returned HTML – attempting fallback parse.');
|
||||||
|
const maybeLinkMatch = responseText.match(/href="(https?:[^"']+\.(?:mp4|mkv|m3u8)[^"']*)"/i);
|
||||||
|
if (maybeLinkMatch && maybeLinkMatch[1]) {
|
||||||
|
const directUrl = maybeLinkMatch[1];
|
||||||
|
injected.logger.log(`[MoviesMod] ✓ Fallback found direct link in HTML: ${directUrl}`);
|
||||||
|
return directUrl;
|
||||||
|
}
|
||||||
|
injected.logger.error('[MoviesMod] Fallback HTML parse failed to locate a video link.');
|
||||||
|
injected.logger.error(responseText.substring(0, 300));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error resolving WorkerSeed link: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveVideoSeedLink(videoSeedUrl, injected) {
|
||||||
|
try {
|
||||||
|
const urlParams = new injected.URLSearchParams(new injected.URL(videoSeedUrl).search);
|
||||||
|
const keys = urlParams.get('url');
|
||||||
|
if (!keys) return null;
|
||||||
|
const apiUrl = `${new injected.URL(videoSeedUrl).origin}/api`;
|
||||||
|
const formData = new injected.FormData();
|
||||||
|
formData.append('keys', keys);
|
||||||
|
|
||||||
|
const apiResponse = await injected.fetch(apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: { 'x-token': new injected.URL(videoSeedUrl).hostname }
|
||||||
|
});
|
||||||
|
const data = await apiResponse.json();
|
||||||
|
return data.url || null;
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error resolving VideoSeed link: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUrlFlixLink(url, injected) {
|
||||||
|
try {
|
||||||
|
injected.logger.log(`[MoviesMod] Resolving UrlFlix link: ${url}`);
|
||||||
|
const response = await injected.fetch(url, { headers: { 'Referer': 'https://driveseed.org/' }});
|
||||||
|
const data = await response.text();
|
||||||
|
|
||||||
|
// Regex to find video URLs in script tags. Looks for common player config patterns.
|
||||||
|
const urlMatch = data.match(/(?:file|src|source)\s*:\s*["'](https?:\/\/[^"']+\.(?:mp4|mkv|m3u8)[^"']*)["']/);
|
||||||
|
|
||||||
|
if (urlMatch && urlMatch[1]) {
|
||||||
|
const finalUrl = urlMatch[1].replace(/\\/g, ''); // Remove escaping backslashes
|
||||||
|
injected.logger.log(`[MoviesMod] ✓ Found final link on UrlFlix page: ${finalUrl}`);
|
||||||
|
return finalUrl;
|
||||||
|
} else {
|
||||||
|
injected.logger.warn('[MoviesMod] ✗ Could not find a direct video link on UrlFlix page.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error resolving UrlFlix link: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateVideoUrl(url, injected, timeout = 10000) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id = setTimeout(() => controller.abort(), timeout);
|
||||||
|
const response = await injected.fetch(url, { method: 'HEAD', headers: { 'Range': 'bytes=0-1' }, signal: controller.signal });
|
||||||
|
clearTimeout(id);
|
||||||
|
if (response.status >= 200 && response.status < 400) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Some servers block HEAD; fall back to GET with small range
|
||||||
|
const getResp = await injected.fetch(url, { method: 'GET', headers: { 'Range': 'bytes=0-1' }, signal: controller.signal });
|
||||||
|
return getResp.status >= 200 && getResp.status < 400;
|
||||||
|
} catch (error) {
|
||||||
|
// In the constrained environment (RN fetch + CORS) HEAD/GET may fail even though the URL works in player.
|
||||||
|
// We'll optimistically accept the URL to avoid discarding good streams.
|
||||||
|
injected.logger.log(`[MoviesMod] URL validation errored (${error.message}). Assuming valid.`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Main Plugin Entrypoint ---
|
||||||
|
|
||||||
|
async function mainGetStreams(options) {
|
||||||
|
// Accept optional title/year passed from the host so we can skip TMDB calls when metadata is already available.
|
||||||
|
const { tmdbId, mediaType, seasonNum, episodeNum, tmdbApiKey, title: providedTitle, year: providedYear, ...injected } = options;
|
||||||
|
injected.logger.log(`[MoviesMod] Fetching streams for TMDB ${mediaType}/${tmdbId}${seasonNum ? ", S"+seasonNum+"E"+episodeNum: ''}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheKey = `moviesmod_v7_${tmdbId}_${mediaType}${seasonNum ? `_s${seasonNum}` : ''}`;
|
||||||
|
let resolvedQualities = await injected.cache.get(cacheKey);
|
||||||
|
|
||||||
|
if (!resolvedQualities) {
|
||||||
|
injected.logger.log(`[MoviesMod Cache] MISS for key: ${cacheKey}.`);
|
||||||
|
// Prefer provided metadata to avoid extra API calls.
|
||||||
|
let title = providedTitle;
|
||||||
|
let year = providedYear ? String(providedYear) : undefined;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
// Fallback to TMDB API if title is not provided.
|
||||||
|
const tmdbUrl = `https://api.themoviedb.org/3/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}?api_key=${tmdbApiKey}&language=en-US`;
|
||||||
|
const tmdbResponse = await injected.fetch(tmdbUrl);
|
||||||
|
const tmdbDetails = await tmdbResponse.json();
|
||||||
|
title = mediaType === 'tv' ? tmdbDetails.name : tmdbDetails.title;
|
||||||
|
year = mediaType === 'tv' ? tmdbDetails.first_air_date?.substring(0, 4) : tmdbDetails.release_date?.substring(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) throw new Error('Could not get title from metadata or TMDB');
|
||||||
|
|
||||||
|
const searchResults = await searchMoviesMod(title, injected);
|
||||||
|
if (!searchResults.length) throw new Error(`No search results for "${title}"`);
|
||||||
|
|
||||||
|
const { bestMatch } = findBestMatch(title, searchResults.map(r => r.title));
|
||||||
|
let selectedResult = null;
|
||||||
|
if (bestMatch.rating > 0.3) {
|
||||||
|
selectedResult = searchResults.find(r => r.title === bestMatch.target);
|
||||||
|
if (mediaType === 'movie' && year && !selectedResult.title.includes(year)) {
|
||||||
|
selectedResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selectedResult) {
|
||||||
|
const titleRegex = new RegExp(`\\b${escapeRegExp(title.toLowerCase())}\\b`);
|
||||||
|
selectedResult = searchResults.find(r => titleRegex.test(r.title.toLowerCase()) && (!year || r.title.includes(year) || mediaType !== 'movie'));
|
||||||
|
}
|
||||||
|
if (!selectedResult) throw new Error(`No suitable search result found for "${title} (${year})".`);
|
||||||
|
|
||||||
|
injected.logger.log(`[MoviesMod] Selected: ${selectedResult.title}`);
|
||||||
|
const downloadLinks = await extractDownloadLinks(selectedResult.url, injected);
|
||||||
|
if (!downloadLinks.length) throw new Error('No download links found');
|
||||||
|
|
||||||
|
let relevantLinks = downloadLinks.filter(link => !link.quality.toLowerCase().includes('480p'));
|
||||||
|
if (mediaType === 'tv' && seasonNum !== null) {
|
||||||
|
relevantLinks = relevantLinks.filter(link => link.quality.toLowerCase().includes(`season ${seasonNum}`) || link.quality.toLowerCase().includes(`s${seasonNum}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relevantLinks.length > 0) {
|
||||||
|
const qualityPromises = relevantLinks.map(async (link) => {
|
||||||
|
const finalLinks = await resolveIntermediateLink(link.url, selectedResult.url, link.quality, injected);
|
||||||
|
return finalLinks?.length > 0 ? { quality: link.quality, finalLinks } : null;
|
||||||
|
});
|
||||||
|
resolvedQualities = (await Promise.all(qualityPromises)).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
resolvedQualities = [];
|
||||||
|
}
|
||||||
|
await injected.cache.set(cacheKey, resolvedQualities, 4 * 3600); // 4 hour cache
|
||||||
|
} else {
|
||||||
|
injected.logger.log(`[MoviesMod Cache] HIT for key: ${cacheKey}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resolvedQualities || resolvedQualities.length === 0) return [];
|
||||||
|
|
||||||
|
const streams = [];
|
||||||
|
const processedFileNames = new Set();
|
||||||
|
const qualityProcessingPromises = resolvedQualities.map(async ({ quality, finalLinks }) => {
|
||||||
|
let targetLinks = finalLinks;
|
||||||
|
if (mediaType === 'tv' && episodeNum !== null) {
|
||||||
|
const ep = `episode ${episodeNum}`;
|
||||||
|
const epShort = `ep ${episodeNum}`;
|
||||||
|
const epShorter = `e${episodeNum}`;
|
||||||
|
targetLinks = finalLinks.filter(fl => fl.server.toLowerCase().includes(ep) || fl.server.toLowerCase().includes(epShort) || fl.server.toLowerCase().includes(epShorter));
|
||||||
|
if (targetLinks.length === 0) return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStreamPromises = targetLinks.map(async (targetLink) => {
|
||||||
|
try {
|
||||||
|
let currentUrl = targetLink.url;
|
||||||
|
if (currentUrl.includes('tech.unblockedgames.world') || currentUrl.includes('tech.creativeexpressionsblog.com')) {
|
||||||
|
currentUrl = await resolveTechUnblockedLink(currentUrl, injected);
|
||||||
|
}
|
||||||
|
if (!currentUrl) return null;
|
||||||
|
|
||||||
|
// If the link is still a tech.unblockedgames.world/creativeexpressionsblog.com SID link
|
||||||
|
// and we failed to resolve it, we will still pass it through so the player (or user)
|
||||||
|
// can attempt to open it. These links usually redirect to Google Drive after a short UI
|
||||||
|
// delay which many external players can cope with.
|
||||||
|
if (!currentUrl.includes('driveseed.org')) {
|
||||||
|
// Explicitly filter out unresolved urlflix links
|
||||||
|
if (currentUrl.includes('urlflix.xyz')) {
|
||||||
|
injected.logger.log(`[MoviesMod] Filtering out unresolved urlflix link: ${currentUrl}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
injected.logger.warn(`[MoviesMod] Could not bypass SID → driveseed for URL. Passing unresolved link through.`);
|
||||||
|
return {
|
||||||
|
name: `MoviesMod`,
|
||||||
|
title: `Unresolved link – may require redirect`,
|
||||||
|
url: currentUrl,
|
||||||
|
quality: 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { downloadOptions, size, fileName } = await resolveDriveseedLink(currentUrl, injected);
|
||||||
|
if (!downloadOptions?.length || (fileName && processedFileNames.has(fileName))) return null;
|
||||||
|
if (fileName) processedFileNames.add(fileName);
|
||||||
|
|
||||||
|
for (const option of downloadOptions) {
|
||||||
|
let finalDownloadUrl = null;
|
||||||
|
if (option.type === 'resume') finalDownloadUrl = await resolveResumeCloudLink(option.url, injected);
|
||||||
|
else if (option.type === 'worker') finalDownloadUrl = await resolveWorkerSeedLink(option.url, injected);
|
||||||
|
else if (option.type === 'instant') finalDownloadUrl = await resolveVideoSeedLink(option.url, injected);
|
||||||
|
|
||||||
|
if (finalDownloadUrl) {
|
||||||
|
if (finalDownloadUrl.includes('urlflix.xyz')) {
|
||||||
|
injected.logger.log(`[MoviesMod] Detected UrlFlix link, attempting to resolve: ${finalDownloadUrl}`);
|
||||||
|
finalDownloadUrl = await resolveUrlFlixLink(finalDownloadUrl, injected);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalDownloadUrl && await validateVideoUrl(finalDownloadUrl, injected)) {
|
||||||
|
const actualQuality = extractQuality(quality);
|
||||||
|
const sizeInfo = size || quality.match(/\[([^\]]+)\]/)?.[1];
|
||||||
|
const cleanFileName = fileName ? fileName.replace(/\.[^/.]+$/, "").replace(/[._]/g, ' ') : `Stream from ${quality}`;
|
||||||
|
const techDetails = getTechDetails(quality);
|
||||||
|
const techDetailsString = techDetails.length > 0 ? ` • ${techDetails.join(' • ')}` : '';
|
||||||
|
return {
|
||||||
|
name: `MoviesMod`,
|
||||||
|
title: `${option.title} • ${actualQuality} • ${cleanFileName}\n${sizeInfo || ''}${techDetailsString}`,
|
||||||
|
url: finalDownloadUrl,
|
||||||
|
quality: actualQuality,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
injected.logger.error(`[MoviesMod] Error processing target link ${targetLink.url}: ${e.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (await Promise.all(finalStreamPromises)).filter(Boolean);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allResults = await Promise.all(qualityProcessingPromises);
|
||||||
|
allResults.flat().forEach(s => streams.push(s));
|
||||||
|
streams.sort((a, b) => parseQualityForSort(b.quality) - parseQualityForSort(a.quality));
|
||||||
|
|
||||||
|
injected.logger.log(`[MoviesMod] Successfully extracted and sorted ${streams.length} streams.`);
|
||||||
|
injected.logger.log('[MoviesMod] Final streams:', JSON.stringify(streams, null, 2));
|
||||||
|
return streams;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.error(`[MoviesMod] FATAL: ${error.message}\n${error.stack}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Register Plugin ---
|
||||||
|
if (typeof registerPlugin === 'function') {
|
||||||
|
registerPlugin(plugin);
|
||||||
|
} else {
|
||||||
|
console.error("Plugin system not found. Cannot register MoviesMod plugin.");
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
Loading…
Reference in a new issue