mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
feat: implement and debug moviesmod plugin
This commit is contained in:
parent
eec96b09d2
commit
6d2d50175a
11 changed files with 2211 additions and 168 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
|
||||
};
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -908,7 +908,7 @@ const LibraryScreen = () => {
|
|||
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
|
||||
: 'Trakt Collection'
|
||||
}
|
||||
</Text>
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
248
src/services/PluginManager.ts
Normal file
248
src/services/PluginManager.ts
Normal 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();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
0
src/services/plugins/TXT.txt
Normal file
0
src/services/plugins/TXT.txt
Normal file
741
src/services/plugins/moviesmod.plugin.js
Normal file
741
src/services/plugins/moviesmod.plugin.js
Normal 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.");
|
||||
}
|
||||
|
||||
})();
|
||||
Loading…
Reference in a new issue