mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
feat: Prepare for App Store submission
BREAKING CHANGE: Removes all internal providers, torrenting functionality, and default addons to comply with App Store guidelines. The app now starts with a clean slate, requiring users to manually install addons.
This commit is contained in:
parent
c41f5b881f
commit
ba94a515c8
18 changed files with 143 additions and 2945 deletions
516
hdrezkas.js
516
hdrezkas.js
|
|
@ -1,516 +0,0 @@
|
|||
// Simplified standalone script to test hdrezka scraper flow
|
||||
import fetch from 'node-fetch';
|
||||
import readline from 'readline';
|
||||
|
||||
// Constants
|
||||
const rezkaBase = 'https://hdrezka.ag/';
|
||||
const baseHeaders = {
|
||||
'X-Hdrezka-Android-App': '1',
|
||||
'X-Hdrezka-Android-App-Version': '2.2.0',
|
||||
};
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const argOptions = {
|
||||
title: null,
|
||||
type: null,
|
||||
year: null,
|
||||
season: null,
|
||||
episode: null
|
||||
};
|
||||
|
||||
// Process command line arguments
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--title' || args[i] === '-t') {
|
||||
argOptions.title = args[i + 1];
|
||||
i++;
|
||||
} else if (args[i] === '--type' || args[i] === '-m') {
|
||||
argOptions.type = args[i + 1].toLowerCase();
|
||||
i++;
|
||||
} else if (args[i] === '--year' || args[i] === '-y') {
|
||||
argOptions.year = parseInt(args[i + 1]);
|
||||
i++;
|
||||
} else if (args[i] === '--season' || args[i] === '-s') {
|
||||
argOptions.season = parseInt(args[i + 1]);
|
||||
i++;
|
||||
} else if (args[i] === '--episode' || args[i] === '-e') {
|
||||
argOptions.episode = parseInt(args[i + 1]);
|
||||
i++;
|
||||
} else if (args[i] === '--help' || args[i] === '-h') {
|
||||
console.log(`
|
||||
HDRezka Scraper Test Script
|
||||
|
||||
Usage:
|
||||
node hdrezka-test.js [options]
|
||||
|
||||
Options:
|
||||
--title, -t <title> Title to search for
|
||||
--type, -m <type> Media type (movie or show)
|
||||
--year, -y <year> Release year
|
||||
--season, -s <number> Season number (for shows)
|
||||
--episode, -e <number> Episode number (for shows)
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
node hdrezka-test.js --title "Breaking Bad" --type show --season 1 --episode 3
|
||||
node hdrezka-test.js --title "Inception" --type movie --year 2010
|
||||
node hdrezka-test.js (interactive mode)
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Create readline interface for user input
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// Function to prompt user for input
|
||||
function prompt(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function generateRandomFavs() {
|
||||
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
|
||||
const generateSegment = (length) => Array.from({ length }, randomHex).join('');
|
||||
|
||||
return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`;
|
||||
}
|
||||
|
||||
function extractTitleAndYear(input) {
|
||||
const regex = /^(.*?),.*?(\d{4})/;
|
||||
const match = input.match(regex);
|
||||
|
||||
if (match) {
|
||||
const title = match[1];
|
||||
const year = match[2];
|
||||
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseVideoLinks(inputString) {
|
||||
if (!inputString) {
|
||||
throw new Error('No video links found');
|
||||
}
|
||||
|
||||
console.log(`[PARSE] Parsing video links from stream URL data`);
|
||||
const linksArray = inputString.split(',');
|
||||
const result = {};
|
||||
|
||||
linksArray.forEach((link) => {
|
||||
// Handle different quality formats:
|
||||
// 1. Simple format: [360p]https://example.com/video.mp4
|
||||
// 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4
|
||||
|
||||
// Try simple format first (non-HTML)
|
||||
let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/);
|
||||
|
||||
// If not found, try HTML format with more flexible pattern
|
||||
if (!match) {
|
||||
// Extract quality text from HTML span
|
||||
const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/);
|
||||
// Extract URL separately
|
||||
const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/);
|
||||
|
||||
if (qualityMatch && urlMatch) {
|
||||
match = [null, qualityMatch[1].trim(), urlMatch[1]];
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const qualityText = match[1].trim();
|
||||
const mp4Url = match[2];
|
||||
|
||||
// Extract the quality value (e.g., "360p", "1080p Ultra")
|
||||
let quality = qualityText;
|
||||
|
||||
// Skip null URLs (premium content that requires login)
|
||||
if (mp4Url !== 'null') {
|
||||
result[quality] = { type: 'mp4', url: mp4Url };
|
||||
console.log(`[QUALITY] Found ${quality}: ${mp4Url}`);
|
||||
} else {
|
||||
console.log(`[QUALITY] Premium quality ${quality} requires login (null URL)`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[WARNING] Could not parse quality from: ${link}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSubtitles(inputString) {
|
||||
if (!inputString) {
|
||||
console.log('[SUBTITLES] No subtitles found');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[PARSE] Parsing subtitles data`);
|
||||
const linksArray = inputString.split(',');
|
||||
const captions = [];
|
||||
|
||||
linksArray.forEach((link) => {
|
||||
const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/);
|
||||
|
||||
if (match) {
|
||||
const language = match[1];
|
||||
const url = match[2];
|
||||
|
||||
captions.push({
|
||||
id: url,
|
||||
language,
|
||||
hasCorsRestrictions: false,
|
||||
type: 'vtt',
|
||||
url: url,
|
||||
});
|
||||
console.log(`[SUBTITLE] Found ${language}: ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[PARSE] Found ${captions.length} subtitles`);
|
||||
return captions;
|
||||
}
|
||||
|
||||
// Main scraper functions
|
||||
async function searchAndFindMediaId(media) {
|
||||
console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`);
|
||||
|
||||
const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g;
|
||||
const idRegexPattern = /\/(\d+)-[^/]+\.html$/;
|
||||
|
||||
const fullUrl = new URL('/engine/ajax/search.php', rezkaBase);
|
||||
fullUrl.searchParams.append('q', media.title);
|
||||
|
||||
console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`);
|
||||
const response = await fetch(fullUrl.toString(), {
|
||||
headers: baseHeaders
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const searchData = await response.text();
|
||||
console.log(`[RESPONSE] Search response length: ${searchData.length}`);
|
||||
|
||||
const movieData = [];
|
||||
let match;
|
||||
|
||||
while ((match = itemRegexPattern.exec(searchData)) !== null) {
|
||||
const url = match[1];
|
||||
const titleAndYear = match[3];
|
||||
|
||||
const result = extractTitleAndYear(titleAndYear);
|
||||
if (result !== null) {
|
||||
const id = url.match(idRegexPattern)?.[1] || null;
|
||||
const isMovie = url.includes('/films/');
|
||||
const isShow = url.includes('/series/');
|
||||
const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown';
|
||||
|
||||
movieData.push({
|
||||
id: id ?? '',
|
||||
year: result.year ?? 0,
|
||||
type,
|
||||
url,
|
||||
title: match[2]
|
||||
});
|
||||
console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If year is provided, filter by year
|
||||
let filteredItems = movieData;
|
||||
if (media.releaseYear) {
|
||||
filteredItems = movieData.filter(item => item.year === media.releaseYear);
|
||||
console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`);
|
||||
}
|
||||
|
||||
// If type is provided, filter by type
|
||||
if (media.type) {
|
||||
filteredItems = filteredItems.filter(item => item.type === media.type);
|
||||
console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`);
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0 && movieData.length > 0) {
|
||||
console.log(`[WARNING] No items match the exact criteria. Showing all results:`);
|
||||
movieData.forEach((item, index) => {
|
||||
console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`);
|
||||
});
|
||||
|
||||
// Let user select from results
|
||||
const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): ");
|
||||
const selectedIndex = parseInt(selection) - 1;
|
||||
|
||||
if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) {
|
||||
console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`);
|
||||
return movieData[selectedIndex];
|
||||
} else if (movieData.length > 0) {
|
||||
console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`);
|
||||
return movieData[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`);
|
||||
return filteredItems[0];
|
||||
} else {
|
||||
console.log(`[ERROR] No matching items found`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTranslatorId(url, id, media) {
|
||||
console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`);
|
||||
|
||||
// Make sure the URL is absolute
|
||||
const fullUrl = url.startsWith('http') ? url : `${rezkaBase}${url.startsWith('/') ? url.substring(1) : url}`;
|
||||
console.log(`[REQUEST] Making request to: ${fullUrl}`);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
headers: baseHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log(`[RESPONSE] Translator page response length: ${responseText.length}`);
|
||||
|
||||
// Translator ID 238 represents the Original + subtitles player.
|
||||
if (responseText.includes(`data-translator_id="238"`)) {
|
||||
console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`);
|
||||
return '238';
|
||||
}
|
||||
|
||||
const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents';
|
||||
const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i');
|
||||
const match = responseText.match(regexPattern);
|
||||
const translatorId = match ? match[1] : null;
|
||||
|
||||
console.log(`[RESULT] Extracted translator ID: ${translatorId}`);
|
||||
return translatorId;
|
||||
}
|
||||
|
||||
async function getStream(id, translatorId, media) {
|
||||
console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('id', id);
|
||||
searchParams.append('translator_id', translatorId);
|
||||
|
||||
if (media.type === 'show') {
|
||||
searchParams.append('season', media.season.number.toString());
|
||||
searchParams.append('episode', media.episode.number.toString());
|
||||
console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`);
|
||||
}
|
||||
|
||||
const randomFavs = generateRandomFavs();
|
||||
searchParams.append('favs', randomFavs);
|
||||
searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie');
|
||||
|
||||
const fullUrl = `${rezkaBase}ajax/get_cdn_series/`;
|
||||
console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`);
|
||||
|
||||
// Log the request details
|
||||
console.log('[HDRezka][FETCH DEBUG]', {
|
||||
url: fullUrl,
|
||||
method: 'POST',
|
||||
headers: baseHeaders,
|
||||
body: searchParams.toString()
|
||||
});
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
body: searchParams,
|
||||
headers: baseHeaders,
|
||||
});
|
||||
|
||||
// Log the response details
|
||||
let responseHeaders = {};
|
||||
if (response.headers && typeof response.headers.forEach === 'function') {
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders[key] = value;
|
||||
});
|
||||
} else if (response.headers && response.headers.entries) {
|
||||
for (const [key, value] of response.headers.entries()) {
|
||||
responseHeaders[key] = value;
|
||||
}
|
||||
}
|
||||
const responseText = await response.clone().text();
|
||||
console.log('[HDRezka][FETCH RESPONSE]', {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
text: responseText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const rawText = await response.text();
|
||||
console.log(`[RESPONSE] Stream response length: ${rawText.length}`);
|
||||
|
||||
// Response content-type is text/html, but it's actually JSON
|
||||
try {
|
||||
const parsedResponse = JSON.parse(rawText);
|
||||
console.log(`[RESULT] Parsed response successfully`);
|
||||
|
||||
// Process video qualities and subtitles
|
||||
const qualities = parseVideoLinks(parsedResponse.url);
|
||||
const captions = parseSubtitles(parsedResponse.subtitle);
|
||||
|
||||
// Add the parsed data to the response
|
||||
parsedResponse.formattedQualities = qualities;
|
||||
parsedResponse.formattedCaptions = captions;
|
||||
|
||||
return parsedResponse;
|
||||
} catch (e) {
|
||||
console.error(`[ERROR] Failed to parse JSON response: ${e.message}`);
|
||||
console.log(`[ERROR] Raw response: ${rawText.substring(0, 200)}...`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
try {
|
||||
console.log('=== HDREZKA SCRAPER TEST ===');
|
||||
|
||||
let media;
|
||||
|
||||
// Check if we have command line arguments
|
||||
if (argOptions.title) {
|
||||
// Use command line arguments
|
||||
media = {
|
||||
type: argOptions.type || 'show',
|
||||
title: argOptions.title,
|
||||
releaseYear: argOptions.year || null
|
||||
};
|
||||
|
||||
// If it's a show, add season and episode
|
||||
if (media.type === 'show') {
|
||||
media.season = { number: argOptions.season || 1 };
|
||||
media.episode = { number: argOptions.episode || 1 };
|
||||
|
||||
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`);
|
||||
} else {
|
||||
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`);
|
||||
}
|
||||
} else {
|
||||
// Get user input interactively
|
||||
const title = await prompt('Enter title to search: ');
|
||||
const mediaType = await prompt('Enter media type (movie/show): ').then(type =>
|
||||
type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show'
|
||||
);
|
||||
const releaseYear = await prompt('Enter release year (optional): ').then(year =>
|
||||
year ? parseInt(year) : null
|
||||
);
|
||||
|
||||
// Create media object
|
||||
media = {
|
||||
type: mediaType,
|
||||
title: title,
|
||||
releaseYear: releaseYear
|
||||
};
|
||||
|
||||
// If it's a show, get season and episode
|
||||
if (mediaType === 'show') {
|
||||
const seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1);
|
||||
const episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1);
|
||||
|
||||
media.season = { number: seasonNum };
|
||||
media.episode = { number: episodeNum };
|
||||
|
||||
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`);
|
||||
} else {
|
||||
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Search and find media ID
|
||||
const result = await searchAndFindMediaId(media);
|
||||
if (!result || !result.id) {
|
||||
console.log('No result found, exiting');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Get translator ID
|
||||
const translatorId = await getTranslatorId(result.url, result.id, media);
|
||||
if (!translatorId) {
|
||||
console.log('No translator ID found, exiting');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Get stream
|
||||
const streamData = await getStream(result.id, translatorId, media);
|
||||
if (!streamData) {
|
||||
console.log('No stream data found, exiting');
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Format output in clean JSON similar to CLI output
|
||||
const formattedOutput = {
|
||||
embeds: [],
|
||||
stream: [
|
||||
{
|
||||
id: 'primary',
|
||||
type: 'file',
|
||||
flags: ['cors-allowed', 'ip-locked'],
|
||||
captions: streamData.formattedCaptions.map(caption => ({
|
||||
id: caption.url,
|
||||
language: caption.language === 'Русский' ? 'ru' :
|
||||
caption.language === 'Українська' ? 'uk' :
|
||||
caption.language === 'English' ? 'en' : caption.language.toLowerCase(),
|
||||
hasCorsRestrictions: false,
|
||||
type: 'vtt',
|
||||
url: caption.url
|
||||
})),
|
||||
qualities: Object.entries(streamData.formattedQualities).reduce((acc, [quality, data]) => {
|
||||
// Convert quality format to match CLI output
|
||||
// "360p" -> "360", "1080p Ultra" -> "1080" (or keep as is if needed)
|
||||
let qualityKey = quality;
|
||||
const numericMatch = quality.match(/^(\d+)p/);
|
||||
if (numericMatch) {
|
||||
qualityKey = numericMatch[1];
|
||||
}
|
||||
|
||||
acc[qualityKey] = {
|
||||
type: data.type,
|
||||
url: data.url
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Display the formatted output
|
||||
console.log('✓ Done!');
|
||||
console.log(JSON.stringify(formattedOutput, null, 2).replace(/"([^"]+)":/g, '$1:'));
|
||||
|
||||
console.log('=== SCRAPING COMPLETE ===');
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
if (error.cause) {
|
||||
console.error(`Cause: ${error.cause.message}`);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
d// Test script for HDRezka service
|
||||
// Run with: node scripts/test-hdrezka.js
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const readline = require('readline');
|
||||
|
||||
// Constants
|
||||
const REZKA_BASE = 'https://hdrezka.ag/';
|
||||
const BASE_HEADERS = {
|
||||
'X-Hdrezka-Android-App': '1',
|
||||
'X-Hdrezka-Android-App-Version': '2.2.0',
|
||||
};
|
||||
|
||||
// Create readline interface for user input
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// Function to prompt user for input
|
||||
function prompt(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function generateRandomFavs() {
|
||||
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
|
||||
const generateSegment = (length) => Array.from({ length }, randomHex).join('');
|
||||
|
||||
return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`;
|
||||
}
|
||||
|
||||
function extractTitleAndYear(input) {
|
||||
const regex = /^(.*?),.*?(\d{4})/;
|
||||
const match = input.match(regex);
|
||||
|
||||
if (match) {
|
||||
const title = match[1];
|
||||
const year = match[2];
|
||||
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseVideoLinks(inputString) {
|
||||
if (!inputString) {
|
||||
console.warn('No video links found');
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log(`[PARSE] Parsing video links from stream URL data`);
|
||||
const linksArray = inputString.split(',');
|
||||
const result = {};
|
||||
|
||||
linksArray.forEach((link) => {
|
||||
// Handle different quality formats
|
||||
let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/);
|
||||
|
||||
// If not found, try HTML format with more flexible pattern
|
||||
if (!match) {
|
||||
const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/);
|
||||
const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/);
|
||||
|
||||
if (qualityMatch && urlMatch) {
|
||||
match = [null, qualityMatch[1].trim(), urlMatch[1]];
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const qualityText = match[1].trim();
|
||||
const mp4Url = match[2];
|
||||
|
||||
// Skip null URLs (premium content that requires login)
|
||||
if (mp4Url !== 'null') {
|
||||
result[qualityText] = { type: 'mp4', url: mp4Url };
|
||||
console.log(`[QUALITY] Found ${qualityText}: ${mp4Url}`);
|
||||
} else {
|
||||
console.log(`[QUALITY] Premium quality ${qualityText} requires login (null URL)`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[WARNING] Could not parse quality from: ${link}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSubtitles(inputString) {
|
||||
if (!inputString) {
|
||||
console.log('[SUBTITLES] No subtitles found');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[PARSE] Parsing subtitles data`);
|
||||
const linksArray = inputString.split(',');
|
||||
const captions = [];
|
||||
|
||||
linksArray.forEach((link) => {
|
||||
const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/);
|
||||
|
||||
if (match) {
|
||||
const language = match[1];
|
||||
const url = match[2];
|
||||
|
||||
captions.push({
|
||||
id: url,
|
||||
language,
|
||||
hasCorsRestrictions: false,
|
||||
type: 'vtt',
|
||||
url: url,
|
||||
});
|
||||
console.log(`[SUBTITLE] Found ${language}: ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[PARSE] Found ${captions.length} subtitles`);
|
||||
return captions;
|
||||
}
|
||||
|
||||
// Main scraper functions
|
||||
async function searchAndFindMediaId(media) {
|
||||
console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`);
|
||||
|
||||
const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g;
|
||||
const idRegexPattern = /\/(\d+)-[^/]+\.html$/;
|
||||
|
||||
const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE);
|
||||
fullUrl.searchParams.append('q', media.title);
|
||||
|
||||
console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`);
|
||||
const response = await fetch(fullUrl.toString(), {
|
||||
headers: BASE_HEADERS
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const searchData = await response.text();
|
||||
console.log(`[RESPONSE] Search response length: ${searchData.length}`);
|
||||
|
||||
const movieData = [];
|
||||
let match;
|
||||
|
||||
while ((match = itemRegexPattern.exec(searchData)) !== null) {
|
||||
const url = match[1];
|
||||
const titleAndYear = match[3];
|
||||
|
||||
const result = extractTitleAndYear(titleAndYear);
|
||||
if (result !== null) {
|
||||
const id = url.match(idRegexPattern)?.[1] || null;
|
||||
const isMovie = url.includes('/films/');
|
||||
const isShow = url.includes('/series/');
|
||||
const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown';
|
||||
|
||||
movieData.push({
|
||||
id: id ?? '',
|
||||
year: result.year ?? 0,
|
||||
type,
|
||||
url,
|
||||
title: match[2]
|
||||
});
|
||||
console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If year is provided, filter by year
|
||||
let filteredItems = movieData;
|
||||
if (media.releaseYear) {
|
||||
filteredItems = movieData.filter(item => item.year === media.releaseYear);
|
||||
console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`);
|
||||
}
|
||||
|
||||
// If type is provided, filter by type
|
||||
if (media.type) {
|
||||
filteredItems = filteredItems.filter(item => item.type === media.type);
|
||||
console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`);
|
||||
}
|
||||
|
||||
if (filteredItems.length === 0 && movieData.length > 0) {
|
||||
console.log(`[WARNING] No items match the exact criteria. Showing all results:`);
|
||||
movieData.forEach((item, index) => {
|
||||
console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`);
|
||||
});
|
||||
|
||||
// Let user select from results
|
||||
const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): ");
|
||||
const selectedIndex = parseInt(selection) - 1;
|
||||
|
||||
if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) {
|
||||
console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`);
|
||||
return movieData[selectedIndex];
|
||||
} else if (movieData.length > 0) {
|
||||
console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`);
|
||||
return movieData[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`);
|
||||
return filteredItems[0];
|
||||
} else {
|
||||
console.log(`[ERROR] No matching items found`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTranslatorId(url, id, media) {
|
||||
console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`);
|
||||
|
||||
// Make sure the URL is absolute
|
||||
const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`;
|
||||
console.log(`[REQUEST] Making request to: ${fullUrl}`);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
headers: BASE_HEADERS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log(`[RESPONSE] Translator page response length: ${responseText.length}`);
|
||||
|
||||
// Translator ID 238 represents the Original + subtitles player.
|
||||
if (responseText.includes(`data-translator_id="238"`)) {
|
||||
console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`);
|
||||
return '238';
|
||||
}
|
||||
|
||||
const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents';
|
||||
const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i');
|
||||
const match = responseText.match(regexPattern);
|
||||
const translatorId = match ? match[1] : null;
|
||||
|
||||
console.log(`[RESULT] Extracted translator ID: ${translatorId}`);
|
||||
return translatorId;
|
||||
}
|
||||
|
||||
async function getStream(id, translatorId, media) {
|
||||
console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('id', id);
|
||||
searchParams.append('translator_id', translatorId);
|
||||
|
||||
if (media.type === 'show') {
|
||||
searchParams.append('season', media.season.number.toString());
|
||||
searchParams.append('episode', media.episode.number.toString());
|
||||
console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`);
|
||||
}
|
||||
|
||||
const randomFavs = generateRandomFavs();
|
||||
searchParams.append('favs', randomFavs);
|
||||
searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie');
|
||||
|
||||
const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`;
|
||||
console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`);
|
||||
|
||||
const response = await fetch(fullUrl, {
|
||||
method: 'POST',
|
||||
body: searchParams,
|
||||
headers: BASE_HEADERS,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log(`[RESPONSE] Stream response length: ${responseText.length}`);
|
||||
|
||||
// Response content-type is text/html, but it's actually JSON
|
||||
try {
|
||||
const parsedResponse = JSON.parse(responseText);
|
||||
console.log(`[RESULT] Parsed response successfully`);
|
||||
|
||||
// Process video qualities and subtitles
|
||||
const qualities = parseVideoLinks(parsedResponse.url);
|
||||
const captions = parseSubtitles(parsedResponse.subtitle);
|
||||
|
||||
return {
|
||||
qualities,
|
||||
captions
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`[ERROR] Failed to parse JSON response: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreams(mediaId, mediaType, season, episode) {
|
||||
try {
|
||||
console.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`);
|
||||
|
||||
// Check if the mediaId appears to be an ID rather than a title
|
||||
let title = mediaId;
|
||||
let year;
|
||||
|
||||
// If it's an ID format (starts with 'tt' for IMDB or contains ':' like TMDB IDs)
|
||||
// For testing, we'll replace it with an example title instead of implementing full TMDB API calls
|
||||
if (mediaId.startsWith('tt') || mediaId.includes(':')) {
|
||||
console.log(`[HDRezka] ID format detected for "${mediaId}". Using title search instead.`);
|
||||
|
||||
// For demo purposes only - you would actually get this from TMDB API in real implementation
|
||||
if (mediaType === 'movie') {
|
||||
title = "Inception"; // Example movie
|
||||
year = 2010;
|
||||
} else {
|
||||
title = "Breaking Bad"; // Example show
|
||||
year = 2008;
|
||||
}
|
||||
|
||||
console.log(`[HDRezka] Using title "${title}" (${year}) for search instead of ID`);
|
||||
}
|
||||
|
||||
const media = {
|
||||
title,
|
||||
type: mediaType === 'movie' ? 'movie' : 'show',
|
||||
releaseYear: year
|
||||
};
|
||||
|
||||
// Step 1: Search and find media ID
|
||||
const searchResult = await searchAndFindMediaId(media);
|
||||
if (!searchResult || !searchResult.id) {
|
||||
console.log('[HDRezka] No search results found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Get translator ID
|
||||
const translatorId = await getTranslatorId(
|
||||
searchResult.url,
|
||||
searchResult.id,
|
||||
media
|
||||
);
|
||||
|
||||
if (!translatorId) {
|
||||
console.log('[HDRezka] No translator ID found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 3: Get stream
|
||||
const streamParams = {
|
||||
type: media.type,
|
||||
season: season ? { number: season } : undefined,
|
||||
episode: episode ? { number: episode } : undefined
|
||||
};
|
||||
|
||||
const streamData = await getStream(searchResult.id, translatorId, streamParams);
|
||||
if (!streamData) {
|
||||
console.log('[HDRezka] No stream data found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to Stream format
|
||||
const streams = [];
|
||||
|
||||
Object.entries(streamData.qualities).forEach(([quality, data]) => {
|
||||
streams.push({
|
||||
name: 'HDRezka',
|
||||
title: quality,
|
||||
url: data.url,
|
||||
behaviorHints: {
|
||||
notWebReady: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[HDRezka] Found ${streams.length} streams`);
|
||||
return streams;
|
||||
} catch (error) {
|
||||
console.error(`[HDRezka] Error getting streams: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
try {
|
||||
console.log('=== HDREZKA SCRAPER TEST ===');
|
||||
|
||||
// Get user input interactively
|
||||
const title = await prompt('Enter title to search: ');
|
||||
const mediaType = await prompt('Enter media type (movie/show): ').then(type =>
|
||||
type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show'
|
||||
);
|
||||
const releaseYear = await prompt('Enter release year (optional): ').then(year =>
|
||||
year ? parseInt(year) : null
|
||||
);
|
||||
|
||||
// Create media object
|
||||
let media = {
|
||||
title,
|
||||
type: mediaType,
|
||||
releaseYear
|
||||
};
|
||||
|
||||
let seasonNum, episodeNum;
|
||||
|
||||
// If it's a show, get season and episode
|
||||
if (mediaType === 'show') {
|
||||
seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1);
|
||||
episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1);
|
||||
|
||||
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${seasonNum}E${episodeNum}`);
|
||||
} else {
|
||||
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`);
|
||||
}
|
||||
|
||||
const streams = await getStreams(title, mediaType, seasonNum, episodeNum);
|
||||
|
||||
if (streams && streams.length > 0) {
|
||||
console.log('✓ Found streams:');
|
||||
console.log(JSON.stringify(streams, null, 2));
|
||||
} else {
|
||||
console.log('✗ No streams found');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -44,6 +44,80 @@ const imageCache: Record<string, boolean> = {};
|
|||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
const NoFeaturedContent = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
noContentContainer: {
|
||||
height: height * 0.55,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
noContentTitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
noContentText: {
|
||||
fontSize: 16,
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
noContentButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
width: '100%',
|
||||
},
|
||||
noContentButton: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 30,
|
||||
backgroundColor: currentTheme.colors.elevation3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
noContentButtonText: {
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontWeight: '600',
|
||||
fontSize: 14,
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.noContentContainer}>
|
||||
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={styles.noContentTitle}>No Featured Content</Text>
|
||||
<Text style={styles.noContentText}>
|
||||
Install addons with catalogs or change the content source in your settings.
|
||||
</Text>
|
||||
<View style={styles.noContentButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.noContentButton}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
>
|
||||
<Text style={styles.noContentButtonText}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
|
@ -408,7 +482,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
};
|
||||
|
||||
if (!featuredContent) {
|
||||
return <SkeletonFeatured />;
|
||||
return <NoFeaturedContent />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -155,11 +155,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
|
||||
if (!showSourcesModal) return null;
|
||||
|
||||
const sortedProviders = Object.entries(availableStreams).sort(([a], [b]) => {
|
||||
if (a === 'hdrezka') return -1;
|
||||
if (b === 'hdrezka') return 1;
|
||||
return 0;
|
||||
});
|
||||
const sortedProviders = Object.entries(availableStreams);
|
||||
|
||||
const handleStreamSelect = (stream: Stream) => {
|
||||
if (stream.url !== currentStreamUrl && !isChangingSource) {
|
||||
|
|
|
|||
|
|
@ -140,24 +140,29 @@ export function useFeaturedContent() {
|
|||
|
||||
if (signal.aborted) return;
|
||||
|
||||
// Filter catalogs based on user selection if any catalogs are selected
|
||||
const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0
|
||||
? catalogs.filter(catalog => {
|
||||
const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`;
|
||||
return selectedCatalogs.includes(catalogId);
|
||||
})
|
||||
: catalogs; // Use all catalogs if none specifically selected
|
||||
// If no catalogs are installed, stop loading and return.
|
||||
if (catalogs.length === 0) {
|
||||
formattedContent = [];
|
||||
} else {
|
||||
// Filter catalogs based on user selection if any catalogs are selected
|
||||
const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0
|
||||
? catalogs.filter(catalog => {
|
||||
const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`;
|
||||
return selectedCatalogs.includes(catalogId);
|
||||
})
|
||||
: catalogs; // Use all catalogs if none specifically selected
|
||||
|
||||
// Flatten all catalog items into a single array, filter out items without posters
|
||||
const allItems = filteredCatalogs.flatMap(catalog => catalog.items)
|
||||
.filter(item => item.poster)
|
||||
.filter((item, index, self) =>
|
||||
// Remove duplicates based on ID
|
||||
index === self.findIndex(t => t.id === item.id)
|
||||
);
|
||||
// Flatten all catalog items into a single array, filter out items without posters
|
||||
const allItems = filteredCatalogs.flatMap(catalog => catalog.items)
|
||||
.filter(item => item.poster)
|
||||
.filter((item, index, self) =>
|
||||
// Remove duplicates based on ID
|
||||
index === self.findIndex(t => t.id === item.id)
|
||||
);
|
||||
|
||||
// Sort by popular, newest, etc. (possibly enhanced later)
|
||||
formattedContent = allItems.sort(() => Math.random() - 0.5).slice(0, 10);
|
||||
// Sort by popular, newest, etc. (possibly enhanced later)
|
||||
formattedContent = allItems.sort(() => Math.random() - 0.5).slice(0, 10);
|
||||
}
|
||||
}
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { StreamingContent } from '../services/catalogService';
|
|||
import { catalogService } from '../services/catalogService';
|
||||
import { stremioService } from '../services/stremioService';
|
||||
import { tmdbService } from '../services/tmdbService';
|
||||
import { hdrezkaService } from '../services/hdrezkaService';
|
||||
import { cacheService } from '../services/cacheService';
|
||||
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
||||
import { TMDBService } from '../services/tmdbService';
|
||||
|
|
@ -184,85 +183,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Loading indicators should probably be managed based on callbacks completing.
|
||||
};
|
||||
|
||||
const processHDRezkaSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => {
|
||||
const sourceStartTime = Date.now();
|
||||
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
||||
const sourceName = 'hdrezka';
|
||||
|
||||
logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
|
||||
|
||||
try {
|
||||
const streams = await hdrezkaService.getStreams(
|
||||
id,
|
||||
type,
|
||||
season,
|
||||
episode
|
||||
);
|
||||
|
||||
const processTime = Date.now() - sourceStartTime;
|
||||
|
||||
if (streams && streams.length > 0) {
|
||||
logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`);
|
||||
|
||||
// Format response similar to Stremio format for the UI
|
||||
return {
|
||||
'hdrezka': {
|
||||
addonName: 'HDRezka',
|
||||
streams
|
||||
}
|
||||
};
|
||||
} else {
|
||||
logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`);
|
||||
return {};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await promise;
|
||||
const processingTime = Date.now() - startTime;
|
||||
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
// Update the appropriate state based on whether this is for an episode or not
|
||||
const updateState = (prevState: GroupedStreams) => {
|
||||
const newState = { ...prevState };
|
||||
|
||||
// Merge in the new streams
|
||||
Object.entries(result).forEach(([provider, data]: [string, any]) => {
|
||||
newState[provider] = data;
|
||||
});
|
||||
|
||||
return newState;
|
||||
};
|
||||
|
||||
if (isEpisode) {
|
||||
setEpisodeStreams(updateState);
|
||||
} else {
|
||||
setGroupedStreams(updateState);
|
||||
}
|
||||
|
||||
console.log(`✅ [processExternalSource:${sourceType}] Processed in ${processingTime}ms, found streams:`,
|
||||
Object.values(result).reduce((acc: number, curr: any) => acc + (curr.streams?.length || 0), 0)
|
||||
);
|
||||
|
||||
// Return the result for the promise chain
|
||||
return result;
|
||||
} else {
|
||||
console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`);
|
||||
return {};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [processExternalSource:${sourceType}] Error:`, error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const loadCast = async () => {
|
||||
if (!metadata || !metadata.id) return;
|
||||
|
||||
setLoadingCast(true);
|
||||
try {
|
||||
// Handle TMDB IDs
|
||||
|
|
@ -794,44 +717,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
|
||||
processStremioSource(type, stremioId, false);
|
||||
|
||||
// Add HDRezka source
|
||||
const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false);
|
||||
|
||||
// Include HDRezka in fetchPromises array
|
||||
const fetchPromises: Promise<any>[] = [hdrezkaPromise];
|
||||
|
||||
// Wait only for external promises now
|
||||
const results = await Promise.allSettled(fetchPromises);
|
||||
const totalTime = Date.now() - startTime;
|
||||
console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
|
||||
|
||||
const sourceTypes: string[] = ['hdrezka'];
|
||||
results.forEach((result, index) => {
|
||||
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
||||
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
|
||||
if (result.status === 'rejected') {
|
||||
console.error(`❌ [loadStreams:${source}] Error:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🧮 [loadStreams] Summary:');
|
||||
console.log(' Total time for external sources:', totalTime + 'ms');
|
||||
|
||||
// Log the final states - this might not include all Stremio addons yet
|
||||
console.log('📦 [loadStreams] Current combined streams count:',
|
||||
Object.keys(groupedStreams).length > 0 ?
|
||||
Object.values(groupedStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
|
||||
0
|
||||
);
|
||||
|
||||
// Cache the final streams state - Note: This might be incomplete if Stremio addons are slow
|
||||
setGroupedStreams(prev => {
|
||||
// We might want to reconsider when exactly to cache or mark loading as fully complete
|
||||
// cacheService.setStreams(id, type, prev); // Maybe cache incrementally in callback?
|
||||
setPreloadedStreams(prev);
|
||||
return prev;
|
||||
});
|
||||
|
||||
// Add a delay before marking loading as complete to give Stremio addons more time
|
||||
setTimeout(() => {
|
||||
setLoadingStreams(false);
|
||||
|
|
@ -906,40 +791,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
|
||||
processStremioSource('series', stremioEpisodeId, true);
|
||||
|
||||
// Add HDRezka source for episodes
|
||||
const hdrezkaEpisodePromise = processExternalSource('hdrezka',
|
||||
processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true),
|
||||
true
|
||||
);
|
||||
|
||||
const fetchPromises: Promise<any>[] = [hdrezkaEpisodePromise];
|
||||
|
||||
// Wait only for external promises now
|
||||
const results = await Promise.allSettled(fetchPromises);
|
||||
const totalTime = Date.now() - startTime;
|
||||
console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
|
||||
|
||||
const sourceTypes: string[] = ['hdrezka'];
|
||||
results.forEach((result, index) => {
|
||||
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
||||
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
|
||||
if (result.status === 'rejected') {
|
||||
console.error(`❌ [loadEpisodeStreams:${source}] Error:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🧮 [loadEpisodeStreams] Summary:');
|
||||
console.log(' Total time for external sources:', totalTime + 'ms');
|
||||
|
||||
// Update preloaded episode streams for future use
|
||||
if (Object.keys(episodeStreams).length > 0) {
|
||||
setPreloadedEpisodeStreams(prev => ({
|
||||
...prev,
|
||||
[episodeId]: { ...episodeStreams }
|
||||
}));
|
||||
}
|
||||
|
||||
// Add a delay before marking loading as complete to give addons more time
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ export interface AppSettings {
|
|||
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
||||
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
|
||||
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
|
||||
enableInternalProviders: boolean; // Toggle for internal providers like HDRezka
|
||||
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
|
||||
autoplayBestStream: boolean; // Automatically play the best available stream
|
||||
}
|
||||
|
|
@ -49,11 +48,10 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
useExternalPlayer: false,
|
||||
preferredPlayer: 'internal',
|
||||
showHeroSection: true,
|
||||
featuredContentSource: 'tmdb',
|
||||
featuredContentSource: 'catalogs',
|
||||
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
||||
logoSourcePreference: 'metahub', // Default to Metahub as first source
|
||||
tmdbLanguagePreference: 'en', // Default to English
|
||||
enableInternalProviders: true, // Enable internal providers by default
|
||||
episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout
|
||||
autoplayBestStream: false, // Disabled by default for user choice
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -39,7 +39,6 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
|
|||
import LogoSourceSettings from '../screens/LogoSourceSettings';
|
||||
import ThemeScreen from '../screens/ThemeScreen';
|
||||
import ProfilesScreen from '../screens/ProfilesScreen';
|
||||
import InternalProvidersSettings from '../screens/InternalProvidersSettings';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -102,7 +101,6 @@ export type RootStackParamList = {
|
|||
LogoSourceSettings: undefined;
|
||||
ThemeSettings: undefined;
|
||||
ProfilesSettings: undefined;
|
||||
InternalProvidersSettings: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -1039,21 +1037,6 @@ const AppNavigator = () => {
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="InternalProvidersSettings"
|
||||
component={InternalProvidersSettings}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
|
|
|
|||
|
|
@ -1,491 +0,0 @@
|
|||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
Switch,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
interface SettingItemProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
isLast?: boolean;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const SettingItem: React.FC<SettingItemProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
value,
|
||||
onValueChange,
|
||||
isLast,
|
||||
badge,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.settingItem,
|
||||
!isLast && styles.settingItemBorder,
|
||||
{ borderBottomColor: 'rgba(255,255,255,0.08)' },
|
||||
]}
|
||||
>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={[
|
||||
styles.settingIconContainer,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.1)' }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name={icon}
|
||||
size={20}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingText}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{badge && (
|
||||
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.badgeText}>{badge}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{description && (
|
||||
<Text
|
||||
style={[
|
||||
styles.settingDescription,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||
thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const InternalProvidersSettings: React.FC = () => {
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { currentTheme } = useTheme();
|
||||
const navigation = useNavigation();
|
||||
|
||||
// Individual provider states
|
||||
const [hdrezkaEnabled, setHdrezkaEnabled] = useState(true);
|
||||
|
||||
// Load individual provider settings
|
||||
useEffect(() => {
|
||||
const loadProviderSettings = async () => {
|
||||
try {
|
||||
const hdrezkaSettings = await AsyncStorage.getItem('hdrezka_settings');
|
||||
|
||||
if (hdrezkaSettings) {
|
||||
const parsed = JSON.parse(hdrezkaSettings);
|
||||
setHdrezkaEnabled(parsed.enabled !== false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading provider settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadProviderSettings();
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
const handleMasterToggle = useCallback((enabled: boolean) => {
|
||||
if (!enabled) {
|
||||
Alert.alert(
|
||||
'Disable Internal Providers',
|
||||
'This will disable all built-in streaming providers (HDRezka). You can still use external Stremio addons.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Disable',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
updateSetting('enableInternalProviders', false);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
} else {
|
||||
updateSetting('enableInternalProviders', true);
|
||||
}
|
||||
}, [updateSetting]);
|
||||
|
||||
const handleHdrezkaToggle = useCallback(async (enabled: boolean) => {
|
||||
setHdrezkaEnabled(enabled);
|
||||
try {
|
||||
await AsyncStorage.setItem('hdrezka_settings', JSON.stringify({ enabled }));
|
||||
} catch (error) {
|
||||
console.error('Error saving HDRezka settings:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={[
|
||||
styles.container,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground },
|
||||
]}
|
||||
>
|
||||
<StatusBar
|
||||
translucent
|
||||
backgroundColor="transparent"
|
||||
barStyle="light-content"
|
||||
/>
|
||||
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
onPress={handleBack}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text
|
||||
style={[
|
||||
styles.headerTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
]}
|
||||
>
|
||||
Internal Providers
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
{/* Master Toggle Section */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
MASTER CONTROL
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{ backgroundColor: currentTheme.colors.elevation2 },
|
||||
]}
|
||||
>
|
||||
<SettingItem
|
||||
title="Enable Internal Providers"
|
||||
description="Toggle all built-in streaming providers on/off"
|
||||
icon="toggle-on"
|
||||
value={settings.enableInternalProviders}
|
||||
onValueChange={handleMasterToggle}
|
||||
isLast={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Individual Providers Section */}
|
||||
{settings.enableInternalProviders && (
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
INDIVIDUAL PROVIDERS
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.card,
|
||||
{ backgroundColor: currentTheme.colors.elevation2 },
|
||||
]}
|
||||
>
|
||||
<SettingItem
|
||||
title="HDRezka"
|
||||
description="Popular streaming service with multiple quality options"
|
||||
icon="hd"
|
||||
value={hdrezkaEnabled}
|
||||
onValueChange={handleHdrezkaToggle}
|
||||
isLast={true}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Information Section */}
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
INFORMATION
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.infoCard,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
borderColor: `${currentTheme.colors.primary}30`
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="info-outline"
|
||||
size={24}
|
||||
color={currentTheme.colors.primary}
|
||||
style={styles.infoIcon}
|
||||
/>
|
||||
<View style={styles.infoContent}>
|
||||
<Text
|
||||
style={[
|
||||
styles.infoTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
]}
|
||||
>
|
||||
About Internal Providers
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.infoDescription,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
Internal providers are built directly into the app and don't require separate addon installation. They complement your Stremio addons by providing additional streaming sources.
|
||||
</Text>
|
||||
<View style={styles.featureList}>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
No addon installation required
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
Multiple quality options
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={16}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.featureText,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
Fast and reliable streaming
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginRight: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 8,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
settingItem: {
|
||||
padding: 16,
|
||||
borderBottomWidth: 0.5,
|
||||
},
|
||||
settingItemBorder: {
|
||||
borderBottomWidth: 0.5,
|
||||
},
|
||||
settingContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingIconContainer: {
|
||||
marginRight: 16,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingText: {
|
||||
flex: 1,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
opacity: 0.8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
badge: {
|
||||
height: 18,
|
||||
minWidth: 18,
|
||||
borderRadius: 9,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
badgeText: {
|
||||
color: 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoCard: {
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
flexDirection: 'row',
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
infoIcon: {
|
||||
marginRight: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
infoContent: {
|
||||
flex: 1,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
infoDescription: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 12,
|
||||
},
|
||||
featureList: {
|
||||
gap: 6,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
featureText: {
|
||||
fontSize: 14,
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default InternalProvidersSettings;
|
||||
|
|
@ -12,7 +12,8 @@ import {
|
|||
Platform,
|
||||
Dimensions,
|
||||
Image,
|
||||
Button
|
||||
Button,
|
||||
Linking
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
|
@ -477,11 +478,11 @@ const SettingsScreen: React.FC = () => {
|
|||
badge={catalogCount}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Internal Providers"
|
||||
description="Enable or disable built-in providers like HDRezka"
|
||||
icon="source"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('InternalProvidersSettings')}
|
||||
title="Trakt"
|
||||
icon="sync"
|
||||
onPress={() => navigation.navigate('TraktSettings')}
|
||||
renderControl={() => <ChevronRight />}
|
||||
badge={isAuthenticated ? `Logged in as ${userProfile?.username}`: "Not Logged In"}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Home Screen"
|
||||
|
|
@ -602,6 +603,17 @@ const SettingsScreen: React.FC = () => {
|
|||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<SettingsCard title="About">
|
||||
<SettingItem
|
||||
title="Privacy Policy"
|
||||
description="Information about data collection and usage"
|
||||
icon="policy"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => Linking.openURL('https://github.com/Stremio/stremio-expo/blob/main/PRIVACY_POLICY.md').catch(err => console.error("Couldn't load page", err))}
|
||||
isLast={true}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<View style={styles.versionContainer}>
|
||||
<Text style={[styles.versionText, {color: currentTheme.colors.mediumEmphasis}]}>
|
||||
Version 1.0.0
|
||||
|
|
|
|||
|
|
@ -72,13 +72,6 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }:
|
|||
const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
||||
const isDebrid = stream.behaviorHints?.cached;
|
||||
|
||||
// Determine if this is a HDRezka stream
|
||||
const isHDRezka = stream.name === 'HDRezka';
|
||||
|
||||
// For HDRezka streams, the title contains the quality information
|
||||
const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream');
|
||||
const displayAddonName = isHDRezka ? '' : (stream.title || '');
|
||||
|
||||
// Animation delay based on index - stagger effect
|
||||
const enterDelay = 100 + (index * 30);
|
||||
|
||||
|
|
@ -100,11 +93,11 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }:
|
|||
<View style={styles.streamNameRow}>
|
||||
<View style={styles.streamTitleContainer}>
|
||||
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
|
||||
{displayTitle}
|
||||
{stream.name || stream.title || 'Unnamed Stream'}
|
||||
</Text>
|
||||
{displayAddonName && displayAddonName !== displayTitle && (
|
||||
{stream.title && stream.title !== stream.name && (
|
||||
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
|
||||
{displayAddonName}
|
||||
{stream.title}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -140,13 +133,6 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }:
|
|||
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Special badge for HDRezka streams */}
|
||||
{isHDRezka && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.accent }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
|
@ -332,7 +318,7 @@ export const StreamsScreen = () => {
|
|||
}
|
||||
|
||||
// Update loading states for individual providers
|
||||
const expectedProviders = ['stremio', 'hdrezka'];
|
||||
const expectedProviders = ['stremio'];
|
||||
const now = Date.now();
|
||||
|
||||
setLoadingProviders(prevLoading => {
|
||||
|
|
@ -359,9 +345,9 @@ export const StreamsScreen = () => {
|
|||
// Update useEffect to check for sources
|
||||
useEffect(() => {
|
||||
const checkProviders = async () => {
|
||||
// Check for both Stremio addons and if the internal provider is enabled
|
||||
// Check for Stremio addons
|
||||
const hasStremioProviders = await stremioService.hasStreamProviders();
|
||||
const hasProviders = hasStremioProviders || settings.enableInternalProviders;
|
||||
const hasProviders = hasStremioProviders;
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
|
|
@ -377,8 +363,7 @@ export const StreamsScreen = () => {
|
|||
if (type === 'series' && episodeId) {
|
||||
logger.log(`🎬 Loading episode streams for: ${episodeId}`);
|
||||
setLoadingProviders({
|
||||
'stremio': true,
|
||||
'hdrezka': true
|
||||
'stremio': true
|
||||
});
|
||||
setSelectedEpisode(episodeId);
|
||||
loadEpisodeStreams(episodeId);
|
||||
|
|
@ -399,7 +384,7 @@ export const StreamsScreen = () => {
|
|||
};
|
||||
|
||||
checkProviders();
|
||||
}, [type, id, episodeId, settings.autoplayBestStream, settings.enableInternalProviders]);
|
||||
}, [type, id, episodeId, settings.autoplayBestStream]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Trigger entrance animations
|
||||
|
|
@ -474,8 +459,6 @@ export const StreamsScreen = () => {
|
|||
|
||||
// Provider priority (higher number = higher priority)
|
||||
const getProviderPriority = (addonId: string): number => {
|
||||
if (addonId === 'hdrezka') return 100; // HDRezka highest priority
|
||||
|
||||
// Get Stremio addon installation order (earlier = higher priority)
|
||||
const installedAddons = stremioService.getInstalledAddons();
|
||||
const addonIndex = installedAddons.findIndex(addon => addon.id === addonId);
|
||||
|
|
@ -564,8 +547,7 @@ export const StreamsScreen = () => {
|
|||
const streamsToPass = type === 'series' ? episodeStreams : groupedStreams;
|
||||
|
||||
// Determine the stream name using the same logic as StreamCard
|
||||
const isHDRezka = stream.name === 'HDRezka';
|
||||
const streamName = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream');
|
||||
const streamName = stream.name || stream.title || 'Unnamed Stream';
|
||||
|
||||
navigation.navigate('Player', {
|
||||
uri: stream.url,
|
||||
|
|
@ -805,11 +787,7 @@ export const StreamsScreen = () => {
|
|||
{ id: 'all', name: 'All Providers' },
|
||||
...Array.from(allProviders)
|
||||
.sort((a, b) => {
|
||||
// Put HDRezka first
|
||||
if (a === 'hdrezka') return -1;
|
||||
if (b === 'hdrezka') return 1;
|
||||
|
||||
// Then sort by Stremio addon installation order
|
||||
// Sort by Stremio addon installation order
|
||||
const indexA = installedAddons.findIndex(addon => addon.id === a);
|
||||
const indexB = installedAddons.findIndex(addon => addon.id === b);
|
||||
|
||||
|
|
@ -821,11 +799,6 @@ export const StreamsScreen = () => {
|
|||
.map(provider => {
|
||||
const addonInfo = streams[provider];
|
||||
|
||||
// Special handling for HDRezka
|
||||
if (provider === 'hdrezka') {
|
||||
return { id: provider, name: 'HDRezka' };
|
||||
}
|
||||
|
||||
// Standard handling for Stremio addons
|
||||
const installedAddon = installedAddons.find(addon => addon.id === provider);
|
||||
|
||||
|
|
@ -895,11 +868,7 @@ export const StreamsScreen = () => {
|
|||
return addonId === selectedProvider;
|
||||
})
|
||||
.sort(([addonIdA], [addonIdB]) => {
|
||||
// Put HDRezka first
|
||||
if (addonIdA === 'hdrezka') return -1;
|
||||
if (addonIdB === 'hdrezka') return 1;
|
||||
|
||||
// Then sort by Stremio addon installation order
|
||||
// Sort by Stremio addon installation order
|
||||
const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
|
||||
const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
|
||||
|
||||
|
|
@ -909,21 +878,13 @@ export const StreamsScreen = () => {
|
|||
return 0;
|
||||
})
|
||||
.map(([addonId, { addonName, streams: providerStreams }]) => {
|
||||
let sortedProviderStreams = providerStreams;
|
||||
if (addonId === 'hdrezka') {
|
||||
sortedProviderStreams = [...providerStreams].sort((a, b) => {
|
||||
const qualityA = getQualityNumeric(a.title);
|
||||
const qualityB = getQualityNumeric(b.title);
|
||||
return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p)
|
||||
});
|
||||
} else {
|
||||
// Sort other streams by quality if possible
|
||||
sortedProviderStreams = [...providerStreams].sort((a, b) => {
|
||||
const qualityA = getQualityNumeric(a.name || a.title);
|
||||
const qualityB = getQualityNumeric(b.name || b.title);
|
||||
return qualityB - qualityA; // Sort descending
|
||||
});
|
||||
}
|
||||
// Sort streams by quality if possible
|
||||
const sortedProviderStreams = [...providerStreams].sort((a, b) => {
|
||||
const qualityA = getQualityNumeric(a.name || a.title);
|
||||
const qualityB = getQualityNumeric(b.name || b.title);
|
||||
return qualityB - qualityA; // Sort descending
|
||||
});
|
||||
|
||||
return {
|
||||
title: addonName,
|
||||
addonId,
|
||||
|
|
|
|||
|
|
@ -248,6 +248,7 @@ const TraktSettingsScreen: React.FC = () => {
|
|||
</Text>
|
||||
{userProfile.vip && (
|
||||
<View style={styles.vipBadge}>
|
||||
<MaterialIcons name="star" size={14} color="#FFF" />
|
||||
<Text style={styles.vipText}>VIP</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -267,13 +268,11 @@ const TraktSettingsScreen: React.FC = () => {
|
|||
style={[
|
||||
styles.button,
|
||||
styles.signOutButton,
|
||||
{ backgroundColor: isDarkMode ? 'rgba(255,59,48,0.1)' : 'rgba(255,59,48,0.08)' }
|
||||
{ backgroundColor: currentTheme.colors.error }
|
||||
]}
|
||||
onPress={handleSignOut}
|
||||
>
|
||||
<Text style={[styles.buttonText, { color: '#FF3B30' }]}>
|
||||
Sign Out
|
||||
</Text>
|
||||
<Text style={styles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,543 +0,0 @@
|
|||
import { logger } from '../utils/logger';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { tmdbService } from './tmdbService';
|
||||
import axios from 'axios';
|
||||
import { settingsEmitter } from '../hooks/useSettings';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// Use node-fetch if available, otherwise fallback to global fetch
|
||||
let fetchImpl: typeof fetch;
|
||||
try {
|
||||
// @ts-ignore
|
||||
fetchImpl = require('node-fetch');
|
||||
} catch {
|
||||
fetchImpl = fetch;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const REZKA_BASE = 'https://hdrezka.ag/';
|
||||
const BASE_HEADERS = {
|
||||
'X-Hdrezka-Android-App': '1',
|
||||
'X-Hdrezka-Android-App-Version': '2.2.0',
|
||||
};
|
||||
|
||||
class HDRezkaService {
|
||||
private MAX_RETRIES = 3;
|
||||
private RETRY_DELAY = 1000; // 1 second
|
||||
|
||||
// No cookies/session logic needed for Android app API
|
||||
private getHeaders() {
|
||||
return {
|
||||
...BASE_HEADERS,
|
||||
'User-Agent': 'okhttp/4.9.0',
|
||||
};
|
||||
}
|
||||
|
||||
private generateRandomFavs(): string {
|
||||
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
|
||||
const generateSegment = (length: number) => Array.from({ length }, () => randomHex()).join('');
|
||||
|
||||
return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`;
|
||||
}
|
||||
|
||||
private extractTitleAndYear(input: string): { title: string; year: number | null } | null {
|
||||
// Handle multiple formats
|
||||
|
||||
// Format 1: "Title, YEAR, Additional info"
|
||||
const regex1 = /^(.*?),.*?(\d{4})/;
|
||||
const match1 = input.match(regex1);
|
||||
if (match1) {
|
||||
const title = match1[1];
|
||||
const year = match1[2];
|
||||
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
|
||||
}
|
||||
|
||||
// Format 2: "Title (YEAR)"
|
||||
const regex2 = /^(.*?)\s*\((\d{4})\)/;
|
||||
const match2 = input.match(regex2);
|
||||
if (match2) {
|
||||
const title = match2[1];
|
||||
const year = match2[2];
|
||||
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
|
||||
}
|
||||
|
||||
// Format 3: Look for any 4-digit year in the string
|
||||
const yearMatch = input.match(/(\d{4})/);
|
||||
if (yearMatch) {
|
||||
const year = yearMatch[1];
|
||||
// Remove the year and any surrounding brackets/parentheses from the title
|
||||
let title = input.replace(/\s*\(\d{4}\)|\s*\[\d{4}\]|\s*\d{4}/, '');
|
||||
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
|
||||
}
|
||||
|
||||
// If no year found but we have a title
|
||||
if (input.trim()) {
|
||||
return { title: input.trim(), year: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseVideoLinks(inputString: string | undefined): Record<string, { type: string; url: string }> {
|
||||
if (!inputString) {
|
||||
logger.log('[HDRezka] No video links found');
|
||||
return {};
|
||||
}
|
||||
|
||||
logger.log(`[HDRezka] Parsing video links from stream URL data`);
|
||||
const linksArray = inputString.split(',');
|
||||
const result: Record<string, { type: string; url: string }> = {};
|
||||
|
||||
linksArray.forEach((link) => {
|
||||
// Handle different quality formats:
|
||||
// 1. Simple format: [360p]https://example.com/video.mp4
|
||||
// 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4
|
||||
|
||||
// Try simple format first (non-HTML)
|
||||
let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/);
|
||||
|
||||
// If not found, try HTML format with more flexible pattern
|
||||
if (!match) {
|
||||
// Extract quality text from HTML span
|
||||
const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/);
|
||||
// Extract URL separately
|
||||
const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/);
|
||||
|
||||
if (qualityMatch && urlMatch) {
|
||||
match = [link, qualityMatch[1].trim(), urlMatch[1]] as RegExpMatchArray;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
const qualityText = match[1].trim();
|
||||
const mp4Url = match[2];
|
||||
|
||||
// Skip null URLs (premium content that requires login)
|
||||
if (mp4Url !== 'null') {
|
||||
result[qualityText] = { type: 'mp4', url: mp4Url };
|
||||
logger.log(`[HDRezka] Found ${qualityText}: ${mp4Url}`);
|
||||
} else {
|
||||
logger.log(`[HDRezka] Premium quality ${qualityText} requires login (null URL)`);
|
||||
}
|
||||
} else {
|
||||
logger.log(`[HDRezka] Could not parse quality from: ${link}`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.log(`[HDRezka] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseSubtitles(inputString: string | undefined): Array<{
|
||||
id: string;
|
||||
language: string;
|
||||
hasCorsRestrictions: boolean;
|
||||
type: string;
|
||||
url: string;
|
||||
}> {
|
||||
if (!inputString) {
|
||||
logger.log('[HDRezka] No subtitles found');
|
||||
return [];
|
||||
}
|
||||
|
||||
logger.log(`[HDRezka] Parsing subtitles data`);
|
||||
const linksArray = inputString.split(',');
|
||||
const captions: Array<{
|
||||
id: string;
|
||||
language: string;
|
||||
hasCorsRestrictions: boolean;
|
||||
type: string;
|
||||
url: string;
|
||||
}> = [];
|
||||
|
||||
linksArray.forEach((link) => {
|
||||
const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/);
|
||||
|
||||
if (match) {
|
||||
const language = match[1];
|
||||
const url = match[2];
|
||||
|
||||
captions.push({
|
||||
id: url,
|
||||
language,
|
||||
hasCorsRestrictions: false,
|
||||
type: 'vtt',
|
||||
url: url,
|
||||
});
|
||||
logger.log(`[HDRezka] Found subtitle ${language}: ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.log(`[HDRezka] Found ${captions.length} subtitles`);
|
||||
return captions;
|
||||
}
|
||||
|
||||
async searchAndFindMediaId(media: { title: string; type: string; releaseYear?: number }): Promise<{
|
||||
id: string;
|
||||
year: number;
|
||||
type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
} | null> {
|
||||
logger.log(`[HDRezka] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`);
|
||||
|
||||
const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g;
|
||||
const idRegexPattern = /\/(\d+)-[^/]+\.html$/;
|
||||
|
||||
const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE);
|
||||
fullUrl.searchParams.append('q', media.title);
|
||||
|
||||
logger.log(`[HDRezka] Making search request to: ${fullUrl.toString()}`);
|
||||
try {
|
||||
const response = await fetchImpl(fullUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const searchData = await response.text();
|
||||
logger.log(`[HDRezka] Search response length: ${searchData.length}`);
|
||||
|
||||
const movieData: Array<{
|
||||
id: string;
|
||||
year: number;
|
||||
type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
}> = [];
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = itemRegexPattern.exec(searchData)) !== null) {
|
||||
const url = match[1];
|
||||
const titleAndYear = match[3];
|
||||
|
||||
const result = this.extractTitleAndYear(titleAndYear);
|
||||
if (result !== null) {
|
||||
const id = url.match(idRegexPattern)?.[1] || null;
|
||||
const isMovie = url.includes('/films/');
|
||||
const isShow = url.includes('/series/');
|
||||
const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown';
|
||||
|
||||
movieData.push({
|
||||
id: id ?? '',
|
||||
year: result.year ?? 0,
|
||||
type,
|
||||
url,
|
||||
title: match[2]
|
||||
});
|
||||
logger.log(`[HDRezka] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If year is provided, filter by year
|
||||
let filteredItems = movieData;
|
||||
if (media.releaseYear) {
|
||||
filteredItems = movieData.filter(item => item.year === media.releaseYear);
|
||||
logger.log(`[HDRezka] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`);
|
||||
}
|
||||
|
||||
// If type is provided, filter by type
|
||||
if (media.type) {
|
||||
filteredItems = filteredItems.filter(item => item.type === media.type);
|
||||
logger.log(`[HDRezka] Items filtered by type ${media.type}: ${filteredItems.length}`);
|
||||
}
|
||||
|
||||
if (filteredItems.length > 0) {
|
||||
logger.log(`[HDRezka] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`);
|
||||
return filteredItems[0];
|
||||
} else if (movieData.length > 0) {
|
||||
logger.log(`[HDRezka] No exact match, using first result: id=${movieData[0].id}, title=${movieData[0].title}`);
|
||||
return movieData[0];
|
||||
} else {
|
||||
logger.log(`[HDRezka] No matching items found`);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[HDRezka] Search request failed: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getTranslatorId(url: string, id: string, mediaType: string): Promise<string | null> {
|
||||
logger.log(`[HDRezka] Getting translator ID for url=${url}, id=${id}`);
|
||||
|
||||
// Make sure the URL is absolute
|
||||
const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`;
|
||||
logger.log(`[HDRezka] Making request to: ${fullUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(fullUrl, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
logger.log(`[HDRezka] Translator page response length: ${responseText.length}`);
|
||||
|
||||
// 1. Check for "Original + Subtitles" specific ID (often ID 238)
|
||||
if (responseText.includes(`data-translator_id="238"`)) {
|
||||
logger.log(`[HDRezka] Found specific translator ID 238 (Original + subtitles)`);
|
||||
return '238';
|
||||
}
|
||||
|
||||
// 2. Try to extract from the main CDN init function (e.g., initCDNMoviesEvents, initCDNSeriesEvents)
|
||||
const functionName = mediaType === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents';
|
||||
const cdnEventsRegex = new RegExp(`sof\.tv\.${functionName}\(${id}, ([^,]+)`, 'i');
|
||||
const cdnEventsMatch = responseText.match(cdnEventsRegex);
|
||||
|
||||
if (cdnEventsMatch && cdnEventsMatch[1]) {
|
||||
const translatorIdFromCdn = cdnEventsMatch[1].trim().replace(/['"]/g, ''); // Remove potential quotes
|
||||
if (translatorIdFromCdn && translatorIdFromCdn !== 'false' && translatorIdFromCdn !== 'null') {
|
||||
logger.log(`[HDRezka] Extracted translator ID from CDN init: ${translatorIdFromCdn}`);
|
||||
return translatorIdFromCdn;
|
||||
}
|
||||
}
|
||||
logger.log(`[HDRezka] CDN init function did not yield a valid translator ID.`);
|
||||
|
||||
// 3. Fallback: Try to find any other data-translator_id attribute in the HTML
|
||||
// This regex looks for data-translator_id="<digits>"
|
||||
const anyTranslatorRegex = /data-translator_id="(\d+)"/;
|
||||
const anyTranslatorMatch = responseText.match(anyTranslatorRegex);
|
||||
|
||||
if (anyTranslatorMatch && anyTranslatorMatch[1]) {
|
||||
const fallbackTranslatorId = anyTranslatorMatch[1].trim();
|
||||
logger.log(`[HDRezka] Found fallback translator ID from data attribute: ${fallbackTranslatorId}`);
|
||||
return fallbackTranslatorId;
|
||||
}
|
||||
logger.log(`[HDRezka] No fallback data-translator_id found.`);
|
||||
|
||||
// If all attempts fail
|
||||
logger.log(`[HDRezka] Could not find any translator ID for id ${id} on page ${fullUrl}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`[HDRezka] Failed to get translator ID: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getStream(id: string, translatorId: string, media: {
|
||||
type: string;
|
||||
season?: { number: number };
|
||||
episode?: { number: number };
|
||||
}): Promise<any> {
|
||||
logger.log(`[HDRezka] Getting stream for id=${id}, translatorId=${translatorId}`);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.append('id', id);
|
||||
searchParams.append('translator_id', translatorId);
|
||||
|
||||
if (media.type === 'show' && media.season && media.episode) {
|
||||
searchParams.append('season', media.season.number.toString());
|
||||
searchParams.append('episode', media.episode.number.toString());
|
||||
logger.log(`[HDRezka] Show params: season=${media.season.number}, episode=${media.episode.number}`);
|
||||
}
|
||||
|
||||
const randomFavs = this.generateRandomFavs();
|
||||
searchParams.append('favs', randomFavs);
|
||||
searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie');
|
||||
|
||||
const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`;
|
||||
logger.log(`[HDRezka] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`);
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
attempts++;
|
||||
try {
|
||||
// Log the request details
|
||||
logger.log('[HDRezka][AXIOS DEBUG]', {
|
||||
url: fullUrl,
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
data: searchParams.toString()
|
||||
});
|
||||
const axiosResponse = await axios.post(fullUrl, searchParams.toString(), {
|
||||
headers: {
|
||||
...this.getHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
validateStatus: () => true,
|
||||
});
|
||||
logger.log('[HDRezka][AXIOS RESPONSE]', {
|
||||
status: axiosResponse.status,
|
||||
headers: axiosResponse.headers,
|
||||
data: axiosResponse.data
|
||||
});
|
||||
if (axiosResponse.status !== 200) {
|
||||
throw new Error(`HTTP error! status: ${axiosResponse.status}`);
|
||||
}
|
||||
const responseText = typeof axiosResponse.data === 'string' ? axiosResponse.data : JSON.stringify(axiosResponse.data);
|
||||
logger.log(`[HDRezka] Stream response length: ${responseText.length}`);
|
||||
try {
|
||||
const parsedResponse = typeof axiosResponse.data === 'object' ? axiosResponse.data : JSON.parse(responseText);
|
||||
logger.log(`[HDRezka] Parsed response successfully: ${JSON.stringify(parsedResponse)}`);
|
||||
if (!parsedResponse.success && parsedResponse.message) {
|
||||
logger.error(`[HDRezka] Server returned error: ${parsedResponse.message}`);
|
||||
if (attempts < maxAttempts) {
|
||||
logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const qualities = this.parseVideoLinks(parsedResponse.url);
|
||||
const captions = this.parseSubtitles(parsedResponse.subtitle);
|
||||
return {
|
||||
qualities,
|
||||
captions
|
||||
};
|
||||
} catch (e: unknown) {
|
||||
const error = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`[HDRezka] Failed to parse JSON response: ${error}`);
|
||||
if (attempts < maxAttempts) {
|
||||
logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[HDRezka] Stream request failed: ${error}`);
|
||||
if (attempts < maxAttempts) {
|
||||
logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
logger.error(`[HDRezka] All stream request attempts failed`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> {
|
||||
try {
|
||||
logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`);
|
||||
|
||||
// Check if internal providers are enabled globally
|
||||
const appSettingsJson = await AsyncStorage.getItem('app_settings');
|
||||
if (appSettingsJson) {
|
||||
const appSettings = JSON.parse(appSettingsJson);
|
||||
if (appSettings.enableInternalProviders === false) {
|
||||
logger.log('[HDRezka] Internal providers are disabled in settings, skipping HDRezka');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Check if HDRezka specifically is enabled
|
||||
const hdrezkaSettingsJson = await AsyncStorage.getItem('hdrezka_settings');
|
||||
if (hdrezkaSettingsJson) {
|
||||
const hdrezkaSettings = JSON.parse(hdrezkaSettingsJson);
|
||||
if (hdrezkaSettings.enabled === false) {
|
||||
logger.log('[HDRezka] HDRezka provider is disabled in settings, skipping HDRezka');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// First, extract the actual title from TMDB if this is an ID
|
||||
let title = mediaId;
|
||||
let year: number | undefined = undefined;
|
||||
|
||||
if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) {
|
||||
let tmdbId: number | null = null;
|
||||
|
||||
// Handle IMDB IDs
|
||||
if (mediaId.startsWith('tt')) {
|
||||
logger.log(`[HDRezka] Converting IMDB ID to TMDB ID: ${mediaId}`);
|
||||
tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId);
|
||||
}
|
||||
// Handle TMDB IDs
|
||||
else if (mediaId.startsWith('tmdb:')) {
|
||||
tmdbId = parseInt(mediaId.split(':')[1], 10);
|
||||
}
|
||||
|
||||
if (tmdbId) {
|
||||
// Fetch metadata from TMDB API
|
||||
if (mediaType === 'movie') {
|
||||
logger.log(`[HDRezka] Fetching movie details from TMDB for ID: ${tmdbId}`);
|
||||
const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString());
|
||||
if (movieDetails) {
|
||||
title = movieDetails.title;
|
||||
year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined;
|
||||
logger.log(`[HDRezka] Using movie title "${title}" (${year}) for search`);
|
||||
}
|
||||
} else {
|
||||
logger.log(`[HDRezka] Fetching TV show details from TMDB for ID: ${tmdbId}`);
|
||||
const showDetails = await tmdbService.getTVShowDetails(tmdbId);
|
||||
if (showDetails) {
|
||||
title = showDetails.name;
|
||||
year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined;
|
||||
logger.log(`[HDRezka] Using TV show title "${title}" (${year}) for search`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const media = {
|
||||
title,
|
||||
type: mediaType === 'movie' ? 'movie' : 'show',
|
||||
releaseYear: year
|
||||
};
|
||||
|
||||
// Step 1: Search and find media ID
|
||||
const searchResult = await this.searchAndFindMediaId(media);
|
||||
if (!searchResult || !searchResult.id) {
|
||||
logger.log('[HDRezka] No search results found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Get translator ID
|
||||
const translatorId = await this.getTranslatorId(
|
||||
searchResult.url,
|
||||
searchResult.id,
|
||||
media.type
|
||||
);
|
||||
|
||||
if (!translatorId) {
|
||||
logger.log('[HDRezka] No translator ID found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 3: Get stream
|
||||
const streamParams = {
|
||||
type: media.type,
|
||||
season: season ? { number: season } : undefined,
|
||||
episode: episode ? { number: episode } : undefined
|
||||
};
|
||||
|
||||
const streamData = await this.getStream(searchResult.id, translatorId, streamParams);
|
||||
if (!streamData) {
|
||||
logger.log('[HDRezka] No stream data found');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to Stream format
|
||||
const streams: Stream[] = [];
|
||||
|
||||
Object.entries(streamData.qualities).forEach(([quality, data]: [string, any]) => {
|
||||
streams.push({
|
||||
name: 'HDRezka',
|
||||
title: quality,
|
||||
url: data.url,
|
||||
behaviorHints: {
|
||||
notWebReady: false
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logger.log(`[HDRezka] Found ${streams.length} streams`);
|
||||
return streams;
|
||||
} catch (error) {
|
||||
logger.error(`[HDRezka] Error getting streams: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hdrezkaService = new HDRezkaService();
|
||||
|
|
@ -175,10 +175,6 @@ class StremioService {
|
|||
private addonOrder: string[] = [];
|
||||
private readonly STORAGE_KEY = 'stremio-addons';
|
||||
private readonly ADDON_ORDER_KEY = 'stremio-addon-order';
|
||||
private readonly DEFAULT_ADDONS = [
|
||||
'https://v3-cinemeta.strem.io/manifest.json',
|
||||
'https://opensubtitles-v3.strem.io/manifest.json'
|
||||
];
|
||||
private readonly MAX_CONCURRENT_REQUESTS = 3;
|
||||
private readonly DEFAULT_PAGE_SIZE = 50;
|
||||
private initialized: boolean = false;
|
||||
|
|
@ -227,19 +223,15 @@ class StremioService {
|
|||
const missingIds = installedIds.filter(id => !this.addonOrder.includes(id));
|
||||
this.addonOrder = [...this.addonOrder, ...missingIds];
|
||||
|
||||
// If no addons, install defaults
|
||||
if (this.installedAddons.size === 0) {
|
||||
await this.installDefaultAddons();
|
||||
}
|
||||
|
||||
// Ensure order is saved
|
||||
await this.saveAddonOrder();
|
||||
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize addons:', error);
|
||||
// Install defaults as fallback
|
||||
await this.installDefaultAddons();
|
||||
// Initialize with empty state on error
|
||||
this.installedAddons = new Map();
|
||||
this.addonOrder = [];
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -275,20 +267,6 @@ class StremioService {
|
|||
throw lastError;
|
||||
}
|
||||
|
||||
private async installDefaultAddons(): Promise<void> {
|
||||
try {
|
||||
for (const url of this.DEFAULT_ADDONS) {
|
||||
const manifest = await this.getManifest(url);
|
||||
if (manifest) {
|
||||
this.installedAddons.set(manifest.id, manifest);
|
||||
}
|
||||
}
|
||||
await this.saveInstalledAddons();
|
||||
} catch (error) {
|
||||
logger.error('Failed to install default addons:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async saveInstalledAddons(): Promise<void> {
|
||||
try {
|
||||
const addonsArray = Array.from(this.installedAddons.values());
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
import { NativeModules, NativeEventEmitter, EmitterSubscription, Platform } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Mock implementation for Expo environment
|
||||
const MockTorrentStreamModule = {
|
||||
TORRENT_PROGRESS_EVENT: 'torrentProgress',
|
||||
startStream: async (magnetUri: string): Promise<string> => {
|
||||
logger.log('[MockTorrentService] Starting mock stream for:', magnetUri);
|
||||
// Return a fake URL that would look like a file path
|
||||
return `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`;
|
||||
},
|
||||
stopStream: () => {
|
||||
logger.log('[MockTorrentService] Stopping mock stream');
|
||||
},
|
||||
fileExists: async (path: string): Promise<boolean> => {
|
||||
logger.log('[MockTorrentService] Checking if file exists:', path);
|
||||
return false;
|
||||
},
|
||||
// Add these methods to satisfy NativeModule interface
|
||||
addListener: () => {},
|
||||
removeListeners: () => {}
|
||||
};
|
||||
|
||||
// Create an EventEmitter that doesn't rely on native modules
|
||||
class MockEventEmitter {
|
||||
private listeners: Map<string, Function[]> = new Map();
|
||||
|
||||
addListener(eventName: string, callback: Function): { remove: () => void } {
|
||||
if (!this.listeners.has(eventName)) {
|
||||
this.listeners.set(eventName, []);
|
||||
}
|
||||
this.listeners.get(eventName)?.push(callback);
|
||||
|
||||
return {
|
||||
remove: () => {
|
||||
const eventListeners = this.listeners.get(eventName);
|
||||
if (eventListeners) {
|
||||
const index = eventListeners.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
eventListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
emit(eventName: string, ...args: any[]) {
|
||||
const eventListeners = this.listeners.get(eventName);
|
||||
if (eventListeners) {
|
||||
eventListeners.forEach(listener => listener(...args));
|
||||
}
|
||||
}
|
||||
|
||||
removeAllListeners(eventName: string) {
|
||||
this.listeners.delete(eventName);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the mock module and event emitter since we're in Expo
|
||||
const TorrentStreamModule = Platform.OS === 'web' ? null : MockTorrentStreamModule;
|
||||
const mockEmitter = new MockEventEmitter();
|
||||
|
||||
const CACHE_KEY = '@torrent_cache_mapping';
|
||||
|
||||
export interface TorrentProgress {
|
||||
bufferProgress: number;
|
||||
downloadSpeed: number;
|
||||
progress: number;
|
||||
seeds: number;
|
||||
}
|
||||
|
||||
export interface TorrentStreamEvents {
|
||||
onProgress?: (progress: TorrentProgress) => void;
|
||||
}
|
||||
|
||||
class TorrentService {
|
||||
private eventEmitter: NativeEventEmitter | MockEventEmitter;
|
||||
private progressListener: EmitterSubscription | { remove: () => void } | null = null;
|
||||
private static TORRENT_PROGRESS_EVENT = TorrentStreamModule?.TORRENT_PROGRESS_EVENT || 'torrentProgress';
|
||||
private cachedTorrents: Map<string, string> = new Map(); // Map of magnet URI to cached file path
|
||||
private initialized: boolean = false;
|
||||
private mockProgressInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
// Use mock event emitter since we're in Expo
|
||||
this.eventEmitter = mockEmitter;
|
||||
this.loadCache();
|
||||
}
|
||||
|
||||
private async loadCache() {
|
||||
try {
|
||||
const cacheData = await AsyncStorage.getItem(CACHE_KEY);
|
||||
if (cacheData) {
|
||||
const cacheMap = JSON.parse(cacheData);
|
||||
this.cachedTorrents = new Map(Object.entries(cacheMap));
|
||||
logger.log('[TorrentService] Loaded cache mapping:', this.cachedTorrents);
|
||||
}
|
||||
this.initialized = true;
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error loading cache:', error);
|
||||
this.initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveCache() {
|
||||
try {
|
||||
const cacheData = Object.fromEntries(this.cachedTorrents);
|
||||
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
||||
logger.log('[TorrentService] Saved cache mapping');
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error saving cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async startStream(magnetUri: string, events?: TorrentStreamEvents): Promise<string> {
|
||||
// Wait for cache to be loaded
|
||||
while (!this.initialized) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
try {
|
||||
// First check if we have this torrent cached
|
||||
const cachedPath = this.cachedTorrents.get(magnetUri);
|
||||
if (cachedPath) {
|
||||
logger.log('[TorrentService] Found cached torrent file:', cachedPath);
|
||||
|
||||
// In mock mode, we'll always use the cached path if available
|
||||
if (!TorrentStreamModule) {
|
||||
// Still set up progress listeners for cached content
|
||||
this.setupProgressListener(events);
|
||||
|
||||
// Simulate progress for cached content too
|
||||
if (events?.onProgress) {
|
||||
this.startMockProgressUpdates(events.onProgress);
|
||||
}
|
||||
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// For native implementations, verify the file still exists
|
||||
try {
|
||||
const exists = await TorrentStreamModule.fileExists(cachedPath);
|
||||
if (exists) {
|
||||
logger.log('[TorrentService] Using cached torrent file');
|
||||
|
||||
// Setup progress listener if callback provided
|
||||
this.setupProgressListener(events);
|
||||
|
||||
// Start the stream in cached mode
|
||||
await TorrentStreamModule.startStream(magnetUri);
|
||||
return cachedPath;
|
||||
} else {
|
||||
logger.log('[TorrentService] Cached file not found, removing from cache');
|
||||
this.cachedTorrents.delete(magnetUri);
|
||||
await this.saveCache();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error checking cached file:', error);
|
||||
// Continue to download again if there's an error
|
||||
}
|
||||
}
|
||||
|
||||
// First stop any existing stream
|
||||
await this.stopStreamAndWait();
|
||||
|
||||
// Setup progress listener if callback provided
|
||||
this.setupProgressListener(events);
|
||||
|
||||
// If we're in mock mode (Expo), simulate progress
|
||||
if (!TorrentStreamModule) {
|
||||
logger.log('[TorrentService] Using mock implementation');
|
||||
const mockUrl = `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`;
|
||||
|
||||
// Save to cache
|
||||
this.cachedTorrents.set(magnetUri, mockUrl);
|
||||
await this.saveCache();
|
||||
|
||||
// Start mock progress updates if events callback provided
|
||||
if (events?.onProgress) {
|
||||
this.startMockProgressUpdates(events.onProgress);
|
||||
}
|
||||
|
||||
// Return immediately with mock URL
|
||||
return mockUrl;
|
||||
}
|
||||
|
||||
// Start the actual stream if native module is available
|
||||
logger.log('[TorrentService] Starting torrent stream');
|
||||
const filePath = await TorrentStreamModule.startStream(magnetUri);
|
||||
|
||||
// Save to cache
|
||||
if (filePath) {
|
||||
logger.log('[TorrentService] Adding path to cache:', filePath);
|
||||
this.cachedTorrents.set(magnetUri, filePath);
|
||||
await this.saveCache();
|
||||
}
|
||||
|
||||
return filePath;
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error starting torrent stream:', error);
|
||||
this.cleanup(); // Clean up on error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setupProgressListener(events?: TorrentStreamEvents) {
|
||||
if (events?.onProgress) {
|
||||
logger.log('[TorrentService] Setting up progress listener');
|
||||
this.progressListener = this.eventEmitter.addListener(
|
||||
TorrentService.TORRENT_PROGRESS_EVENT,
|
||||
(progress) => {
|
||||
logger.log('[TorrentService] Progress event received:', progress);
|
||||
if (events.onProgress) {
|
||||
events.onProgress(progress);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.log('[TorrentService] No progress callback provided');
|
||||
}
|
||||
}
|
||||
|
||||
private startMockProgressUpdates(onProgress: (progress: TorrentProgress) => void) {
|
||||
// Clear any existing interval
|
||||
if (this.mockProgressInterval) {
|
||||
clearInterval(this.mockProgressInterval);
|
||||
}
|
||||
|
||||
// Start at 0% progress
|
||||
let mockProgress = 0;
|
||||
|
||||
// Update every second
|
||||
this.mockProgressInterval = setInterval(() => {
|
||||
// Increase by 10% each time
|
||||
mockProgress += 10;
|
||||
|
||||
// Create mock progress object
|
||||
const progress: TorrentProgress = {
|
||||
bufferProgress: mockProgress,
|
||||
downloadSpeed: 1024 * 1024 * (1 + Math.random()), // Random speed around 1MB/s
|
||||
progress: mockProgress,
|
||||
seeds: Math.floor(5 + Math.random() * 20), // Random seed count between 5-25
|
||||
};
|
||||
|
||||
// Emit the event instead of directly calling callback
|
||||
if (this.eventEmitter instanceof MockEventEmitter) {
|
||||
(this.eventEmitter as MockEventEmitter).emit(TorrentService.TORRENT_PROGRESS_EVENT, progress);
|
||||
} else {
|
||||
// Fallback to direct callback if needed
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
// If we reach 100%, clear the interval
|
||||
if (mockProgress >= 100) {
|
||||
if (this.mockProgressInterval) {
|
||||
clearInterval(this.mockProgressInterval);
|
||||
this.mockProgressInterval = null;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
public async stopStreamAndWait(): Promise<void> {
|
||||
logger.log('[TorrentService] Stopping stream and waiting for cleanup');
|
||||
this.cleanup();
|
||||
|
||||
if (TorrentStreamModule) {
|
||||
try {
|
||||
TorrentStreamModule.stopStream();
|
||||
// Wait a moment to ensure native side has cleaned up
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error stopping torrent stream:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public stopStream(): void {
|
||||
try {
|
||||
logger.log('[TorrentService] Stopping stream and cleaning up');
|
||||
this.cleanup();
|
||||
|
||||
if (TorrentStreamModule) {
|
||||
TorrentStreamModule.stopStream();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error stopping torrent stream:', error);
|
||||
// Still attempt cleanup even if stop fails
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
logger.log('[TorrentService] Cleaning up event listeners and intervals');
|
||||
|
||||
// Clean up progress listener
|
||||
if (this.progressListener) {
|
||||
try {
|
||||
this.progressListener.remove();
|
||||
} catch (error) {
|
||||
logger.error('[TorrentService] Error removing progress listener:', error);
|
||||
} finally {
|
||||
this.progressListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up mock progress interval
|
||||
if (this.mockProgressInterval) {
|
||||
clearInterval(this.mockProgressInterval);
|
||||
this.mockProgressInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const torrentService = new TorrentService();
|
||||
|
|
@ -1,298 +0,0 @@
|
|||
import { logger } from '../utils/logger';
|
||||
import { Stream } from '../types/metadata';
|
||||
import { tmdbService } from './tmdbService';
|
||||
import axios from 'axios';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
|
||||
// Use node-fetch if available, otherwise fallback to global fetch
|
||||
let fetchImpl: typeof fetch;
|
||||
try {
|
||||
// @ts-ignore
|
||||
fetchImpl = require('node-fetch');
|
||||
} catch {
|
||||
fetchImpl = fetch;
|
||||
}
|
||||
|
||||
// Constants
|
||||
const MAX_RETRIES_XPRIME = 3;
|
||||
const RETRY_DELAY_MS_XPRIME = 1000;
|
||||
|
||||
// Use app's cache directory for React Native
|
||||
const CACHE_DIR = `${FileSystem.cacheDirectory}xprime/`;
|
||||
|
||||
const BROWSER_HEADERS_XPRIME = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
|
||||
'Accept': '*/*',
|
||||
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache',
|
||||
'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
|
||||
'Sec-Ch-Ua-Mobile': '?0',
|
||||
'Sec-Ch-Ua-Platform': '"Windows"',
|
||||
'Connection': 'keep-alive'
|
||||
};
|
||||
|
||||
interface XprimeStream {
|
||||
url: string;
|
||||
quality: string;
|
||||
title: string;
|
||||
provider: string;
|
||||
codecs: string[];
|
||||
size: string;
|
||||
}
|
||||
|
||||
class XprimeService {
|
||||
private MAX_RETRIES = 3;
|
||||
private RETRY_DELAY = 1000; // 1 second
|
||||
|
||||
// Ensure cache directories exist
|
||||
private async ensureCacheDir(dirPath: string) {
|
||||
try {
|
||||
const dirInfo = await FileSystem.getInfoAsync(dirPath);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(dirPath, { intermediates: true });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[XPRIME] Warning: Could not create cache directory ${dirPath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache helpers
|
||||
private async getFromCache(cacheKey: string, subDir: string = ''): Promise<any> {
|
||||
try {
|
||||
const fullPath = `${CACHE_DIR}${subDir}/${cacheKey}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(fullPath);
|
||||
|
||||
if (fileInfo.exists) {
|
||||
const data = await FileSystem.readAsStringAsync(fullPath);
|
||||
logger.log(`[XPRIME] CACHE HIT for: ${subDir}/${cacheKey}`);
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`[XPRIME] CACHE READ ERROR for ${cacheKey}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveToCache(cacheKey: string, content: any, subDir: string = '') {
|
||||
try {
|
||||
const fullSubDir = `${CACHE_DIR}${subDir}/`;
|
||||
await this.ensureCacheDir(fullSubDir);
|
||||
|
||||
const fullPath = `${fullSubDir}${cacheKey}`;
|
||||
const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
||||
|
||||
await FileSystem.writeAsStringAsync(fullPath, dataToSave);
|
||||
logger.log(`[XPRIME] SAVED TO CACHE: ${subDir}/${cacheKey}`);
|
||||
} catch (error) {
|
||||
logger.error(`[XPRIME] CACHE WRITE ERROR for ${cacheKey}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to fetch stream size using a HEAD request
|
||||
private async fetchStreamSize(url: string): Promise<string> {
|
||||
const cacheSubDir = 'xprime_stream_sizes';
|
||||
|
||||
// Create a hash of the URL to use as the cache key
|
||||
const urlHash = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.MD5,
|
||||
url,
|
||||
{ encoding: Crypto.CryptoEncoding.HEX }
|
||||
);
|
||||
const urlCacheKey = `${urlHash}.txt`;
|
||||
|
||||
const cachedSize = await this.getFromCache(urlCacheKey, cacheSubDir);
|
||||
if (cachedSize !== null) {
|
||||
return cachedSize;
|
||||
}
|
||||
|
||||
try {
|
||||
// For m3u8, Content-Length is for the playlist file, not the stream segments
|
||||
if (url.toLowerCase().includes('.m3u8')) {
|
||||
await this.saveToCache(urlCacheKey, 'Playlist (size N/A)', cacheSubDir);
|
||||
return 'Playlist (size N/A)';
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5-second timeout
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
const sizeInBytes = parseInt(contentLength, 10);
|
||||
if (!isNaN(sizeInBytes)) {
|
||||
let formattedSize;
|
||||
if (sizeInBytes < 1024) formattedSize = `${sizeInBytes} B`;
|
||||
else if (sizeInBytes < 1024 * 1024) formattedSize = `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||
else if (sizeInBytes < 1024 * 1024 * 1024) formattedSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
else formattedSize = `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
|
||||
await this.saveToCache(urlCacheKey, formattedSize, cacheSubDir);
|
||||
return formattedSize;
|
||||
}
|
||||
}
|
||||
await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir);
|
||||
return 'Unknown size';
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[XPRIME] Could not fetch size for ${url.substring(0, 50)}...`, error);
|
||||
await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir);
|
||||
return 'Unknown size';
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithRetry(url: string, options: any, maxRetries: number = MAX_RETRIES_XPRIME) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetchImpl(url, options);
|
||||
if (!response.ok) {
|
||||
let errorBody = '';
|
||||
try {
|
||||
errorBody = await response.text();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const httpError = new Error(`HTTP error! Status: ${response.status} ${response.statusText}. Body: ${errorBody.substring(0, 200)}`);
|
||||
(httpError as any).status = response.status;
|
||||
throw httpError;
|
||||
}
|
||||
return response;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
logger.error(`[XPRIME] Fetch attempt ${attempt}/${maxRetries} failed for ${url}:`, error);
|
||||
|
||||
// If it's a 403 error, stop retrying immediately
|
||||
if (error.status === 403) {
|
||||
logger.log(`[XPRIME] Encountered 403 Forbidden for ${url}. Halting retries.`);
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`[XPRIME] All fetch attempts failed for ${url}. Last error:`, lastError);
|
||||
if (lastError) throw lastError;
|
||||
else throw new Error(`[XPRIME] All fetch attempts failed for ${url} without a specific error captured.`);
|
||||
}
|
||||
|
||||
async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> {
|
||||
// XPRIME service has been removed from internal providers
|
||||
logger.log('[XPRIME] Service has been removed from internal providers');
|
||||
return [];
|
||||
}
|
||||
|
||||
private async getXprimeStreams(title: string, year: number, type: string, seasonNum?: number, episodeNum?: number): Promise<XprimeStream[]> {
|
||||
let rawXprimeStreams: XprimeStream[] = [];
|
||||
|
||||
try {
|
||||
logger.log(`[XPRIME] Fetch attempt for '${title}' (${year}). Type: ${type}, S: ${seasonNum}, E: ${episodeNum}`);
|
||||
|
||||
const xprimeName = encodeURIComponent(title);
|
||||
let xprimeApiUrl: string;
|
||||
|
||||
// type here is tmdbTypeFromId which is 'movie' or 'tv'/'series'
|
||||
if (type === 'movie') {
|
||||
xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}`;
|
||||
} else if (type === 'tv' || type === 'series') { // Accept both 'tv' and 'series' for compatibility
|
||||
if (seasonNum !== null && seasonNum !== undefined && episodeNum !== null && episodeNum !== undefined) {
|
||||
xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`;
|
||||
} else {
|
||||
logger.log('[XPRIME] Skipping series request: missing season/episode numbers.');
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
logger.log(`[XPRIME] Skipping request: unknown type '${type}'.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let xprimeResult: any;
|
||||
|
||||
// Direct fetch only
|
||||
logger.log(`[XPRIME] Fetching directly: ${xprimeApiUrl}`);
|
||||
const xprimeResponse = await this.fetchWithRetry(xprimeApiUrl, {
|
||||
headers: {
|
||||
...BROWSER_HEADERS_XPRIME,
|
||||
'Origin': 'https://pstream.org',
|
||||
'Referer': 'https://pstream.org/',
|
||||
'Sec-Fetch-Mode': 'cors',
|
||||
'Sec-Fetch-Site': 'cross-site',
|
||||
'Sec-Fetch-Dest': 'empty'
|
||||
}
|
||||
});
|
||||
xprimeResult = await xprimeResponse.json();
|
||||
|
||||
// Process the result
|
||||
this.processXprimeResult(xprimeResult, rawXprimeStreams, title, type, seasonNum, episodeNum);
|
||||
|
||||
// Fetch stream sizes concurrently for all Xprime streams
|
||||
if (rawXprimeStreams.length > 0) {
|
||||
logger.log('[XPRIME] Fetching stream sizes...');
|
||||
const sizePromises = rawXprimeStreams.map(async (stream) => {
|
||||
stream.size = await this.fetchStreamSize(stream.url);
|
||||
return stream;
|
||||
});
|
||||
await Promise.all(sizePromises);
|
||||
logger.log(`[XPRIME] Found ${rawXprimeStreams.length} streams with sizes.`);
|
||||
}
|
||||
|
||||
return rawXprimeStreams;
|
||||
|
||||
} catch (xprimeError) {
|
||||
logger.error('[XPRIME] Error fetching or processing streams:', xprimeError);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to process Xprime API response
|
||||
private processXprimeResult(xprimeResult: any, rawXprimeStreams: XprimeStream[], title: string, type: string, seasonNum?: number, episodeNum?: number) {
|
||||
const processXprimeItem = (item: any) => {
|
||||
if (item && typeof item === 'object' && !item.error && item.streams && typeof item.streams === 'object') {
|
||||
Object.entries(item.streams).forEach(([quality, fileUrl]) => {
|
||||
if (fileUrl && typeof fileUrl === 'string') {
|
||||
rawXprimeStreams.push({
|
||||
url: fileUrl,
|
||||
quality: quality || 'Unknown',
|
||||
title: `${title} - ${(type === 'tv' || type === 'series') ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`,
|
||||
provider: 'XPRIME',
|
||||
codecs: [],
|
||||
size: 'Unknown size'
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.log('[XPRIME] Skipping item due to missing/invalid streams or an error was reported by Xprime API:', item && item.error);
|
||||
}
|
||||
};
|
||||
|
||||
if (Array.isArray(xprimeResult)) {
|
||||
xprimeResult.forEach(processXprimeItem);
|
||||
} else if (xprimeResult) {
|
||||
processXprimeItem(xprimeResult);
|
||||
} else {
|
||||
logger.log('[XPRIME] No result from Xprime API to process.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const xprimeService = new XprimeService();
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// Test script for HDRezka service
|
||||
const { hdrezkaService } = require('./services/hdrezkaService');
|
||||
|
||||
// Enable more detailed console logging
|
||||
const originalConsoleLog = console.log;
|
||||
console.log = function(...args) {
|
||||
const timestamp = new Date().toISOString();
|
||||
originalConsoleLog(`[${timestamp}]`, ...args);
|
||||
};
|
||||
|
||||
// Test function to get streams from HDRezka
|
||||
async function testHDRezka() {
|
||||
console.log('Testing HDRezka service...');
|
||||
|
||||
// Test a popular movie - "Deadpool & Wolverine" (2024)
|
||||
const movieId = 'tt6263850';
|
||||
console.log(`Testing movie ID: ${movieId}`);
|
||||
|
||||
try {
|
||||
const streams = await hdrezkaService.getStreams(movieId, 'movie');
|
||||
console.log('Streams found:', streams.length);
|
||||
if (streams.length > 0) {
|
||||
console.log('First stream:', {
|
||||
name: streams[0].name,
|
||||
title: streams[0].title,
|
||||
url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL
|
||||
});
|
||||
} else {
|
||||
console.log('No streams found.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing HDRezka:', error);
|
||||
}
|
||||
|
||||
// Test a TV show - "House of the Dragon" with a specific episode
|
||||
const showId = 'tt11198330';
|
||||
console.log(`\nTesting TV show ID: ${showId}, Season 2 Episode 1`);
|
||||
|
||||
try {
|
||||
const streams = await hdrezkaService.getStreams(showId, 'series', 2, 1);
|
||||
console.log('Streams found:', streams.length);
|
||||
if (streams.length > 0) {
|
||||
console.log('First stream:', {
|
||||
name: streams[0].name,
|
||||
title: streams[0].title,
|
||||
url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL
|
||||
});
|
||||
} else {
|
||||
console.log('No streams found.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error testing HDRezka TV show:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testHDRezka().then(() => {
|
||||
console.log('Test completed.');
|
||||
}).catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
});
|
||||
Loading…
Reference in a new issue