feat: implement and debug moviesmod plugin

This commit is contained in:
tapframe 2025-07-07 23:38:43 +05:30
parent eec96b09d2
commit 6d2d50175a
11 changed files with 2211 additions and 168 deletions

View file

@ -85,3 +85,4 @@
]
}
}

971
moviesmod.js Normal file
View 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
};

View file

@ -11,6 +11,7 @@ import { usePersistentSeasons } from './usePersistentSeasons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Stream } from '../types/metadata';
import { storageService } from '../services/storageService';
import { pluginManager } from '../services/PluginManager';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
@ -183,6 +184,74 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
// 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,
});
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 () => {
logger.log('[loadCast] Starting cast fetch for:', id);
setLoadingCast(true);
@ -681,148 +750,63 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
};
const loadStreams = async () => {
const startTime = Date.now();
try {
console.log('🚀 [loadStreams] START - Loading streams for:', id);
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);
if (!metadata) {
logger.warn('[loadStreams] No metadata available, aborting stream load');
return;
}
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 startTime = Date.now();
try {
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
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);
if (!metadata || !metadata.videos) {
logger.warn('[loadEpisodeStreams] Metadata or videos not available, aborting');
return;
}
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) => {

View file

@ -70,7 +70,7 @@ const CalendarScreen = () => {
const [refreshing, setRefreshing] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]);
const onRefresh = useCallback(() => {
setRefreshing(true);
refresh(true);

View file

@ -908,7 +908,7 @@ const LibraryScreen = () => {
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
: 'Trakt Collection'
}
</Text>
</Text>
</>
) : (
<>

View file

@ -13,7 +13,9 @@ import {
Dimensions,
Image,
Button,
Linking
Linking,
TextInput,
ActivityIndicator
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
@ -29,6 +31,7 @@ import { useTheme } from '../contexts/ThemeContext';
import { catalogService } from '../services/catalogService';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Sentry from '@sentry/react-native';
import { pluginManager } from '../services/PluginManager';
const { width } = Dimensions.get('window');
@ -157,6 +160,31 @@ const SettingsScreen: React.FC = () => {
const [addonCount, setAddonCount] = useState<number>(0);
const [catalogCount, setCatalogCount] = useState<number>(0);
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
const [pluginUrl, setPluginUrl] = useState('');
const [loadedPlugins, setLoadedPlugins] = useState<string[]>([]);
const [isLoadingPlugin, setIsLoadingPlugin] = useState(false);
const refreshPluginsList = useCallback(() => {
const plugins = pluginManager.getScraperPlugins();
setLoadedPlugins(plugins.map(p => `${p.name} v${p.version}`));
}, []);
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 loadData = useCallback(async () => {
try {
@ -199,12 +227,14 @@ const SettingsScreen: React.FC = () => {
// Load data initially and when catalogs are updated
useEffect(() => {
loadData();
}, [loadData, lastUpdate]);
refreshPluginsList();
}, [loadData, refreshPluginsList]);
// Add focus listener to reload data when screen comes into focus
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
loadData();
refreshPluginsList();
});
return unsubscribe;
@ -496,6 +526,40 @@ const SettingsScreen: React.FC = () => {
</SettingsCard>
)}
<SettingsCard title="Plugins">
<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>
<SettingItem
icon="extension"
title="Loaded Scrapers"
description={loadedPlugins.length > 0 ? loadedPlugins.join(', ') : 'No custom plugins loaded'}
isLast={true}
/>
</SettingsCard>
<View style={styles.footer}>
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
Made with by the Nuvio team
@ -538,7 +602,7 @@ const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
width: '100%',
paddingBottom: 90,
paddingBottom: 100,
},
cardContainer: {
width: '100%',
@ -652,6 +716,31 @@ const styles = StyleSheet.create({
fontSize: 14,
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',
},
});
export default SettingsScreen;

View file

@ -360,11 +360,17 @@ export const StreamsScreen = () => {
}
}, [selectedProvider, availableProviders]);
// Update useEffect to check for sources
// This effect will run when metadata is available or when other dependencies change.
useEffect(() => {
const checkProviders = async () => {
// We need metadata to proceed to load streams.
if (!metadata) {
return;
}
const checkProvidersAndLoad = async () => {
// Check for Stremio addons
const hasStremioProviders = await stremioService.hasStreamProviders();
// NOTE: This will be expanded to check for plugins later
const hasProviders = hasStremioProviders;
if (!isMounted.current) return;
@ -378,33 +384,34 @@ export const StreamsScreen = () => {
}, 500);
return () => clearTimeout(timer);
} else {
if (type === 'series' && episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
setStreamsLoadStart(Date.now());
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setStreamsLoadStart(Date.now());
loadStreams();
}
// Reset autoplay state when content changes
setAutoplayTriggered(false);
if (settings.autoplayBestStream) {
setIsAutoplayWaiting(true);
logger.log('🔄 Autoplay enabled, waiting for best stream...');
} else {
setIsAutoplayWaiting(false);
}
if (type === 'series' && episodeId) {
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
setLoadingProviders({
'stremio': true
});
setSelectedEpisode(episodeId);
setStreamsLoadStart(Date.now());
loadEpisodeStreams(episodeId);
} else if (type === 'movie') {
logger.log(`🎬 Loading movie streams for: ${id}`);
setStreamsLoadStart(Date.now());
loadStreams();
}
// Reset autoplay state when content changes
setAutoplayTriggered(false);
if (settings.autoplayBestStream) {
setIsAutoplayWaiting(true);
logger.log('🔄 Autoplay enabled, waiting for best stream...');
} else {
setIsAutoplayWaiting(false);
}
}
};
checkProviders();
}, [type, id, episodeId, settings.autoplayBestStream]);
checkProvidersAndLoad();
// Adding metadata to the dependency array ensures this runs after metadata is fetched.
}, [metadata, type, id, episodeId, settings.autoplayBestStream]);
React.useEffect(() => {
// Trigger entrance animations

View file

@ -0,0 +1,248 @@
import { logger } from '../utils/logger';
import * as cheerio from 'cheerio';
// --- Type Definitions ---
interface Plugin {
name: string;
version: string;
author: string;
description: string;
type: 'scraper' | 'other';
getStreams: (options: GetStreamsOptions) => Promise<Stream[]>;
}
interface Stream {
name: string;
title: string;
url: string;
quality: string;
}
interface GetStreamsOptions {
tmdbId: string;
mediaType: 'movie' | 'tv';
seasonNum?: number;
episodeNum?: number;
tmdbApiKey: string;
// 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();
}
public static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
return PluginManager.instance;
}
public async loadPluginFromUrl(url: string): Promise<boolean> {
logger.log(`[PluginManager] Attempting to load plugin from URL: ${url}`);
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();
this.runPlugin(pluginCode);
// Assuming runPlugin is synchronous for registration purposes.
// A more robust system might have runPlugin return success/failure.
return true;
} catch (error) {
logger.error(`[PluginManager] Failed to load plugin from URL ${url}:`, error);
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);
}
}
private runPlugin(pluginCode: string) {
const pluginsBefore = this.plugins.length;
const sandbox = {
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.');
}
},
console: logger, // For security, plugins use our logger, not the raw console
};
// Use Function constructor for a slightly safer execution scope than direct eval
try {
const pluginExecutor = new Function('sandbox', `
with (sandbox) {
(function() {
// The plugin code is an IIFE, so it executes immediately
${pluginCode}
})();
}
`);
pluginExecutor(sandbox);
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);
}
}
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();

View file

@ -68,6 +68,7 @@ export interface StreamingContent {
[key: string]: any;
};
imdb_id?: string;
tmdbId?: number;
slug?: string;
releaseInfo?: string;
traktSource?: 'watchlist' | 'continue-watching' | 'watched';
@ -592,6 +593,7 @@ class CatalogService {
writer: (meta as any).writer || undefined,
country: (meta as any).country || undefined,
imdb_id: (meta as any).imdb_id || undefined,
tmdbId: (meta as any).tmdbId || undefined,
slug: (meta as any).slug || undefined,
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
trailerStreams: (meta as any).trailerStreams || undefined,

View file

View file

@ -0,0 +1,741 @@
/**
* 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) {
const { tmdbId, mediaType, seasonNum, episodeNum, tmdbApiKey, ...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}.`);
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();
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');
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.");
}
})();