refactor for tf

This commit is contained in:
tapframe 2025-12-12 14:29:02 +05:30
parent 8a34bf6678
commit 9cc8b2ea67
6 changed files with 431 additions and 602 deletions

View file

@ -155,7 +155,7 @@ export type RootStackParamList = {
TraktSettings: undefined; TraktSettings: undefined;
PlayerSettings: undefined; PlayerSettings: undefined;
ThemeSettings: undefined; ThemeSettings: undefined;
ScraperSettings: undefined; PluginSettings: undefined;
CastMovies: { CastMovies: {
castMember: { castMember: {
id: number; id: number;
@ -1463,7 +1463,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
}} }}
/> />
<Stack.Screen <Stack.Screen
name="ScraperSettings" name="PluginSettings"
component={PluginsScreen} component={PluginsScreen}
options={{ options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',

View file

@ -46,7 +46,7 @@ if (Platform.OS === 'ios') {
} }
} }
// Removed community blur and expo-constants for Android overlay // Removed community blur and expo-constants for Android overlay
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
// Extend Manifest type to include logo only (remove disabled status) // Extend Manifest type to include logo only (remove disabled status)
@ -60,11 +60,7 @@ interface ExtendedManifest extends Manifest {
}; };
} }
// Interface for Community Addon structure from the JSON URL
interface CommunityAddon {
transportUrl: string;
manifest: ExtendedManifest;
}
const { width } = Dimensions.get('window'); const { width } = Dimensions.get('window');
@ -623,10 +619,7 @@ const AddonsScreen = () => {
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
// State for community addons
const [communityAddons, setCommunityAddons] = useState<CommunityAddon[]>([]);
const [communityLoading, setCommunityLoading] = useState(true);
const [communityError, setCommunityError] = useState<string | null>(null);
// Promotional addon: Nuvio Streams // Promotional addon: Nuvio Streams
const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json'; const PROMO_ADDON_URL = 'https://nuviostreams.hayd.uk/manifest.json';
@ -652,7 +645,6 @@ const AddonsScreen = () => {
useEffect(() => { useEffect(() => {
loadAddons(); loadAddons();
loadCommunityAddons();
}, []); }, []);
const loadAddons = async () => { const loadAddons = async () => {
@ -702,27 +694,7 @@ const AddonsScreen = () => {
} }
}; };
// Function to load community addons
const loadCommunityAddons = async () => {
setCommunityLoading(true);
setCommunityError(null);
try {
const response = await axios.get<CommunityAddon[]>('https://stremio-addons.com/catalog.json');
// Filter out addons without a manifest or transportUrl (basic validation)
let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl);
// Filter out Cinemeta since it's now pre-installed
validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta');
setCommunityAddons(validAddons);
} catch (error) {
logger.error('Failed to load community addons:', error);
setCommunityError('Failed to load community addons. Please try again later.');
setCommunityAddons([]);
} finally {
setCommunityLoading(false);
}
};
const handleAddAddon = async (url?: string) => { const handleAddAddon = async (url?: string) => {
let urlToInstall = url || addonUrl; let urlToInstall = url || addonUrl;
@ -783,7 +755,6 @@ const AddonsScreen = () => {
const refreshAddons = async () => { const refreshAddons = async () => {
loadAddons(); loadAddons();
loadCommunityAddons();
}; };
const moveAddonUp = (addon: ExtendedManifest) => { const moveAddonUp = (addon: ExtendedManifest) => {
@ -835,7 +806,7 @@ const AddonsScreen = () => {
configUrl = addon.behaviorHints.configurationURL; configUrl = addon.behaviorHints.configurationURL;
logger.info(`Using configurationURL from behaviorHints: ${configUrl}`); logger.info(`Using configurationURL from behaviorHints: ${configUrl}`);
} }
// If a transport URL was provided directly (for community addons) // If a transport URL was provided directly
else if (transportUrl) { else if (transportUrl) {
// Remove any trailing filename like manifest.json // Remove any trailing filename like manifest.json
const baseUrl = transportUrl.replace(/\/[^\/]+\.json$/, '/'); const baseUrl = transportUrl.replace(/\/[^\/]+\.json$/, '/');
@ -1057,65 +1028,7 @@ const AddonsScreen = () => {
); );
}; };
// Function to render community addon items
const renderCommunityAddonItem = ({ item }: { item: CommunityAddon }) => {
const { manifest, transportUrl } = item;
const types = manifest.types || [];
const description = manifest.description || 'No description provided.';
// @ts-ignore - logo might exist
const logo = manifest.logo || null;
const categoryText = types.length > 0
? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General';
// Check if addon is configurable
const isConfigurable = manifest.behaviorHints?.configurable === true;
return (
<View style={styles.communityAddonItem}>
{logo ? (
<FastImage
source={{ uri: logo }}
style={styles.communityAddonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.communityAddonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.darkGray} />
</View>
)}
<View style={styles.communityAddonDetails}>
<Text style={styles.communityAddonName}>{manifest.name}</Text>
<Text style={styles.communityAddonDesc} numberOfLines={2}>{description}</Text>
<View style={styles.communityAddonMetaContainer}>
<Text style={styles.communityAddonVersion}>v{manifest.version || 'N/A'}</Text>
<Text style={styles.communityAddonDot}></Text>
<Text style={styles.communityAddonCategory}>{categoryText}</Text>
</View>
</View>
<View style={styles.addonActionButtons}>
{isConfigurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(manifest, transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
);
};
const StatsCard = ({ value, label }: { value: number; label: string }) => ( const StatsCard = ({ value, label }: { value: number; label: string }) => (
<View style={styles.statsCard}> <View style={styles.statsCard}>
@ -1316,91 +1229,7 @@ const AddonsScreen = () => {
</View> </View>
)} )}
{/* Community Addons Section */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>COMMUNITY ADDONS</Text>
<View style={styles.addonList}>
{communityLoading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : communityError ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="error-outline" size={32} color={colors.error} />
<Text style={styles.emptyText}>{communityError}</Text>
</View>
) : communityAddons.length === 0 ? (
<View style={styles.emptyContainer}>
<MaterialIcons name="extension-off" size={32} color={colors.mediumGray} />
<Text style={styles.emptyText}>No community addons available</Text>
</View>
) : (
communityAddons.map((item, index) => (
<View
key={item.transportUrl}
style={{ marginBottom: index === communityAddons.length - 1 ? 32 : 16 }}
>
<View style={styles.addonItem}>
<View style={styles.addonHeader}>
{item.manifest.logo ? (
<FastImage
source={{ uri: item.manifest.logo }}
style={styles.addonIcon}
resizeMode={FastImage.resizeMode.contain}
/>
) : (
<View style={styles.addonIconPlaceholder}>
<MaterialIcons name="extension" size={22} color={colors.mediumGray} />
</View>
)}
<View style={styles.addonTitleContainer}>
<Text style={styles.addonName}>{item.manifest.name}</Text>
<View style={styles.addonMetaContainer}>
<Text style={styles.addonVersion}>v{item.manifest.version || 'N/A'}</Text>
<Text style={styles.addonDot}></Text>
<Text style={styles.addonCategory}>
{item.manifest.types && item.manifest.types.length > 0
? item.manifest.types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ')
: 'General'}
</Text>
</View>
</View>
<View style={styles.addonActions}>
{item.manifest.behaviorHints?.configurable && (
<TouchableOpacity
style={styles.configButton}
onPress={() => handleConfigureAddon(item.manifest, item.transportUrl)}
>
<MaterialIcons name="settings" size={20} color={colors.primary} />
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.installButton, installing && { opacity: 0.6 }]}
onPress={() => handleAddAddon(item.transportUrl)}
disabled={installing}
>
{installing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<MaterialIcons name="add" size={20} color={colors.white} />
)}
</TouchableOpacity>
</View>
</View>
<Text style={styles.addonDescription}>
{item.manifest.description
? (item.manifest.description.length > 100
? item.manifest.description.substring(0, 100) + '...'
: item.manifest.description)
: 'No description provided.'}
</Text>
</View>
</View>
))
)}
</View>
</View>
</ScrollView> </ScrollView>
)} )}

View file

@ -78,17 +78,17 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: 16, padding: 16,
}, },
sectionTitle: { sectionTitle: {
fontSize: 20, fontSize: 20,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
marginBottom: 8, marginBottom: 8,
}, },
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}, },
sectionDescription: { sectionDescription: {
fontSize: 14, fontSize: 14,
color: colors.mediumGray, color: colors.mediumGray,
@ -283,59 +283,59 @@ const createStyles = (colors: any) => StyleSheet.create({
marginTop: 8, marginTop: 8,
}, },
infoText: { infoText: {
fontSize: 14, fontSize: 14,
color: colors.mediumEmphasis, color: colors.mediumEmphasis,
lineHeight: 20, lineHeight: 20,
}, },
content: { content: {
flex: 1, flex: 1,
}, },
emptyState: { emptyState: {
alignItems: 'center', alignItems: 'center',
paddingVertical: 32, paddingVertical: 32,
}, },
emptyStateTitle: { emptyStateTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
marginTop: 16, marginTop: 16,
marginBottom: 8, marginBottom: 8,
}, },
emptyStateDescription: { emptyStateDescription: {
fontSize: 14, fontSize: 14,
color: colors.mediumGray, color: colors.mediumGray,
textAlign: 'center', textAlign: 'center',
lineHeight: 20, lineHeight: 20,
}, },
scrapersList: { scrapersList: {
gap: 12, gap: 12,
}, },
scrapersContainer: { scrapersContainer: {
marginBottom: 24, marginBottom: 24,
}, },
inputContainer: { inputContainer: {
marginBottom: 16, marginBottom: 16,
}, },
lastSection: { lastSection: {
borderBottomWidth: 0, borderBottomWidth: 0,
}, },
disabledSection: { disabledSection: {
opacity: 0.5, opacity: 0.5,
}, },
disabledText: { disabledText: {
color: colors.elevation3, color: colors.elevation3,
}, },
disabledContainer: { disabledContainer: {
opacity: 0.5, opacity: 0.5,
}, },
disabledInput: { disabledInput: {
backgroundColor: colors.elevation1, backgroundColor: colors.elevation1,
opacity: 0.5, opacity: 0.5,
}, },
disabledButton: { disabledButton: {
opacity: 0.5, opacity: 0.5,
}, },
disabledImage: { disabledImage: {
opacity: 0.3, opacity: 0.3,
}, },
availableIndicator: { availableIndicator: {
@ -761,10 +761,10 @@ const CollapsibleSection: React.FC<{
<View style={styles.collapsibleSection}> <View style={styles.collapsibleSection}>
<TouchableOpacity style={styles.collapsibleHeader} onPress={onToggle}> <TouchableOpacity style={styles.collapsibleHeader} onPress={onToggle}>
<Text style={styles.collapsibleTitle}>{title}</Text> <Text style={styles.collapsibleTitle}>{title}</Text>
<Ionicons <Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"} name={isExpanded ? "chevron-up" : "chevron-down"}
size={20} size={20}
color={colors.mediumGray} color={colors.mediumGray}
/> />
</TouchableOpacity> </TouchableOpacity>
{isExpanded && <View style={styles.collapsibleContent}>{children}</View>} {isExpanded && <View style={styles.collapsibleContent}>{children}</View>}
@ -803,7 +803,7 @@ const StatusBadge: React.FC<{
}; };
const config = getStatusConfig(); const config = getStatusConfig();
return ( return (
<View style={{ <View style={{
flexDirection: 'row', flexDirection: 'row',
@ -828,7 +828,7 @@ const PluginsScreen: React.FC = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
// CustomAlert state // CustomAlert state
const [alertVisible, setAlertVisible] = useState(false); const [alertVisible, setAlertVisible] = useState(false);
const [alertTitle, setAlertTitle] = useState(''); const [alertTitle, setAlertTitle] = useState('');
@ -842,7 +842,7 @@ const PluginsScreen: React.FC = () => {
) => { ) => {
setAlertTitle(title); setAlertTitle(title);
setAlertMessage(message); setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true); setAlertVisible(true);
}; };
@ -856,14 +856,14 @@ const PluginsScreen: React.FC = () => {
const [showboxSavedToken, setShowboxSavedToken] = useState<string>(''); const [showboxSavedToken, setShowboxSavedToken] = useState<string>('');
const [showboxScraperId, setShowboxScraperId] = useState<string | null>(null); const [showboxScraperId, setShowboxScraperId] = useState<string | null>(null);
const [showboxTokenVisible, setShowboxTokenVisible] = useState<boolean>(false); const [showboxTokenVisible, setShowboxTokenVisible] = useState<boolean>(false);
// Multiple repositories state // Multiple repositories state
const [repositories, setRepositories] = useState<RepositoryInfo[]>([]); const [repositories, setRepositories] = useState<RepositoryInfo[]>([]);
const [currentRepositoryId, setCurrentRepositoryId] = useState<string>(''); const [currentRepositoryId, setCurrentRepositoryId] = useState<string>('');
const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false); const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false);
const [newRepositoryUrl, setNewRepositoryUrl] = useState(''); const [newRepositoryUrl, setNewRepositoryUrl] = useState('');
const [switchingRepository, setSwitchingRepository] = useState<string | null>(null); const [switchingRepository, setSwitchingRepository] = useState<string | null>(null);
// New UX state // New UX state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all'); const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
@ -897,7 +897,7 @@ const PluginsScreen: React.FC = () => {
// Filter by search query // Filter by search query
if (searchQuery.trim()) { if (searchQuery.trim()) {
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
filtered = filtered.filter(scraper => filtered = filtered.filter(scraper =>
scraper.name.toLowerCase().includes(query) || scraper.name.toLowerCase().includes(query) ||
scraper.description.toLowerCase().includes(query) || scraper.description.toLowerCase().includes(query) ||
scraper.id.toLowerCase().includes(query) scraper.id.toLowerCase().includes(query)
@ -906,7 +906,7 @@ const PluginsScreen: React.FC = () => {
// Filter by type // Filter by type
if (selectedFilter !== 'all') { if (selectedFilter !== 'all') {
filtered = filtered.filter(scraper => filtered = filtered.filter(scraper =>
scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv') scraper.supportedTypes?.includes(selectedFilter as 'movie' | 'tv')
); );
} }
@ -933,14 +933,14 @@ const PluginsScreen: React.FC = () => {
const handleBulkToggle = async (enabled: boolean) => { const handleBulkToggle = async (enabled: boolean) => {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
const promises = filteredScrapers.map(scraper => const promises = filteredScrapers.map(scraper =>
pluginService.setScraperEnabled(scraper.id, enabled) pluginService.setScraperEnabled(scraper.id, enabled)
); );
await Promise.all(promises); await Promise.all(promises);
await loadScrapers(); await loadScrapers();
openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`); openAlert('Success', `${enabled ? 'Enabled' : 'Disabled'} ${filteredScrapers.length} scrapers`);
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to bulk toggle:', error); logger.error('[PluginsScreen] Failed to bulk toggle:', error);
openAlert('Error', 'Failed to update scrapers'); openAlert('Error', 'Failed to update scrapers');
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
@ -994,14 +994,14 @@ const PluginsScreen: React.FC = () => {
description: '', description: '',
enabled: true enabled: true
}); });
await loadRepositories(); await loadRepositories();
// Switch to the new repository and refresh it // Switch to the new repository and refresh it
await pluginService.setCurrentRepository(repoId); await pluginService.setCurrentRepository(repoId);
await loadRepositories(); await loadRepositories();
await loadScrapers(); await loadScrapers();
setNewRepositoryUrl(''); setNewRepositoryUrl('');
setShowAddRepositoryModal(false); setShowAddRepositoryModal(false);
openAlert('Success', 'Repository added and refreshed successfully'); openAlert('Success', 'Repository added and refreshed successfully');
@ -1021,7 +1021,7 @@ const PluginsScreen: React.FC = () => {
await loadScrapers(); await loadScrapers();
openAlert('Success', 'Repository switched successfully'); openAlert('Success', 'Repository switched successfully');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to switch repository:', error); logger.error('[PluginsScreen] Failed to switch repository:', error);
openAlert('Error', 'Failed to switch repository'); openAlert('Error', 'Failed to switch repository');
} finally { } finally {
setSwitchingRepository(null); setSwitchingRepository(null);
@ -1034,9 +1034,9 @@ const PluginsScreen: React.FC = () => {
// Special handling for the last repository // Special handling for the last repository
const isLastRepository = repositories.length === 1; const isLastRepository = repositories.length === 1;
const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository'; const alertTitle = isLastRepository ? 'Remove Last Repository' : 'Remove Repository';
const alertMessage = isLastRepository const alertMessage = isLastRepository
? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.` ? `Are you sure you want to remove "${repo.name}"? This is your only repository, so you'll have no scrapers available until you add a new repository.`
: `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`; : `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`;
@ -1044,7 +1044,7 @@ const PluginsScreen: React.FC = () => {
alertTitle, alertTitle,
alertMessage, alertMessage,
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Remove', label: 'Remove',
onPress: async () => { onPress: async () => {
@ -1052,12 +1052,12 @@ const PluginsScreen: React.FC = () => {
await pluginService.removeRepository(repoId); await pluginService.removeRepository(repoId);
await loadRepositories(); await loadRepositories();
await loadScrapers(); await loadScrapers();
const successMessage = isLastRepository const successMessage = isLastRepository
? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.' ? 'Repository removed successfully. You can add a new repository using the "Add Repository" button.'
: 'Repository removed successfully'; : 'Repository removed successfully';
openAlert('Success', successMessage); openAlert('Success', successMessage);
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to remove repository:', error); logger.error('[PluginsScreen] Failed to remove repository:', error);
openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository'); openAlert('Error', error instanceof Error ? error.message : 'Failed to remove repository');
} }
}, },
@ -1097,7 +1097,7 @@ const PluginsScreen: React.FC = () => {
setShowboxTokenVisible(false); setShowboxTokenVisible(false);
} }
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to load scrapers:', error); logger.error('[PluginsScreen] Failed to load scrapers:', error);
} }
}; };
@ -1105,20 +1105,20 @@ const PluginsScreen: React.FC = () => {
try { try {
// First refresh repository names from manifests for existing repositories // First refresh repository names from manifests for existing repositories
await pluginService.refreshRepositoryNamesFromManifests(); await pluginService.refreshRepositoryNamesFromManifests();
const repos = await pluginService.getRepositories(); const repos = await pluginService.getRepositories();
setRepositories(repos); setRepositories(repos);
setHasRepository(repos.length > 0); setHasRepository(repos.length > 0);
const currentRepoId = pluginService.getCurrentRepositoryId(); const currentRepoId = pluginService.getCurrentRepositoryId();
setCurrentRepositoryId(currentRepoId); setCurrentRepositoryId(currentRepoId);
const currentRepo = repos.find(r => r.id === currentRepoId); const currentRepo = repos.find(r => r.id === currentRepoId);
if (currentRepo) { if (currentRepo) {
setRepositoryUrl(currentRepo.url); setRepositoryUrl(currentRepo.url);
} }
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to load repositories:', error); logger.error('[PluginsScreen] Failed to load repositories:', error);
} }
}; };
@ -1130,7 +1130,7 @@ const PluginsScreen: React.FC = () => {
setRepositoryUrl(repoUrl); setRepositoryUrl(repoUrl);
} }
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to check repository:', error); logger.error('[PluginsScreen] Failed to check repository:', error);
} }
}; };
@ -1144,7 +1144,7 @@ const PluginsScreen: React.FC = () => {
const url = repositoryUrl.trim(); const url = repositoryUrl.trim();
if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) {
openAlert( openAlert(
'Invalid URL Format', 'Invalid URL Format',
'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master'
); );
return; return;
@ -1157,7 +1157,7 @@ const PluginsScreen: React.FC = () => {
setHasRepository(true); setHasRepository(true);
openAlert('Success', 'Repository URL saved successfully'); openAlert('Success', 'Repository URL saved successfully');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to save repository:', error); logger.error('[PluginsScreen] Failed to save repository:', error);
openAlert('Error', 'Failed to save repository URL'); openAlert('Error', 'Failed to save repository URL');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@ -1199,7 +1199,7 @@ const PluginsScreen: React.FC = () => {
// If enabling a scraper, ensure it's installed first // If enabling a scraper, ensure it's installed first
const installedScrapers = await pluginService.getInstalledScrapers(); const installedScrapers = await pluginService.getInstalledScrapers();
const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId); const isInstalled = installedScrapers.some(scraper => scraper.id === scraperId);
if (!isInstalled) { if (!isInstalled) {
// Need to install the scraper first // Need to install the scraper first
setIsRefreshing(true); setIsRefreshing(true);
@ -1207,32 +1207,32 @@ const PluginsScreen: React.FC = () => {
setIsRefreshing(false); setIsRefreshing(false);
} }
} }
await pluginService.setScraperEnabled(scraperId, enabled); await pluginService.setScraperEnabled(scraperId, enabled);
await loadScrapers(); await loadScrapers();
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to toggle scraper:', error); logger.error('[PluginsScreen] Failed to toggle plugin:', error);
openAlert('Error', 'Failed to update scraper status'); openAlert('Error', 'Failed to update plugin status');
setIsRefreshing(false); setIsRefreshing(false);
} }
}; };
const handleClearScrapers = () => { const handleClearScrapers = () => {
openAlert( openAlert(
'Clear All Scrapers', 'Clear All Plugins',
'Are you sure you want to remove all installed scrapers? This action cannot be undone.', 'Are you sure you want to remove all installed plugins? This action cannot be undone.',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Clear', label: 'Clear',
onPress: async () => { onPress: async () => {
try { try {
await pluginService.clearScrapers(); await pluginService.clearScrapers();
await loadScrapers(); await loadScrapers();
openAlert('Success', 'All scrapers have been removed'); openAlert('Success', 'All plugins have been removed');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to clear scrapers:', error); logger.error('[PluginsScreen] Failed to clear plugins:', error);
openAlert('Error', 'Failed to clear scrapers'); openAlert('Error', 'Failed to clear plugins');
} }
}, },
}, },
@ -1243,9 +1243,9 @@ const PluginsScreen: React.FC = () => {
const handleClearCache = () => { const handleClearCache = () => {
openAlert( openAlert(
'Clear Repository Cache', 'Clear Repository Cache',
'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.', 'This will remove the saved repository URL and clear all cached plugin data. You will need to re-enter your repository URL.',
[ [
{ label: 'Cancel', onPress: () => {} }, { label: 'Cancel', onPress: () => { } },
{ {
label: 'Clear Cache', label: 'Clear Cache',
onPress: async () => { onPress: async () => {
@ -1258,7 +1258,7 @@ const PluginsScreen: React.FC = () => {
await loadScrapers(); await loadScrapers();
openAlert('Success', 'Repository cache cleared successfully'); openAlert('Success', 'Repository cache cleared successfully');
} catch (error) { } catch (error) {
logger.error('[ScraperSettings] Failed to clear cache:', error); logger.error('[PluginsScreen] Failed to clear cache:', error);
openAlert('Error', 'Failed to clear repository cache'); openAlert('Error', 'Failed to clear repository cache');
} }
}, },
@ -1274,19 +1274,19 @@ const PluginsScreen: React.FC = () => {
const handleToggleLocalScrapers = async (enabled: boolean) => { const handleToggleLocalScrapers = async (enabled: boolean) => {
await updateSetting('enableLocalScrapers', enabled); await updateSetting('enableLocalScrapers', enabled);
// If enabling plugins, refresh repository and reload plugins // If enabling plugins, refresh repository and reload plugins
if (enabled) { if (enabled) {
try { try {
setIsRefreshing(true); setIsRefreshing(true);
logger.log('[PluginsScreen] Enabling plugins - refreshing repository...'); logger.log('[PluginsScreen] Enabling plugins - refreshing repository...');
// Refresh repository to ensure plugins are available // Refresh repository to ensure plugins are available
await pluginService.refreshRepository(); await pluginService.refreshRepository();
// Reload plugins to get the latest state // Reload plugins to get the latest state
await loadScrapers(); await loadScrapers();
logger.log('[PluginsScreen] Plugins enabled and repository refreshed'); logger.log('[PluginsScreen] Plugins enabled and repository refreshed');
} catch (error) { } catch (error) {
logger.error('[PluginsScreen] Failed to refresh repository when enabling plugins:', error); logger.error('[PluginsScreen] Failed to refresh repository when enabling plugins:', error);
@ -1304,7 +1304,7 @@ const PluginsScreen: React.FC = () => {
const handleToggleQualityExclusion = async (quality: string) => { const handleToggleQualityExclusion = async (quality: string) => {
const currentExcluded = settings.excludedQualities || []; const currentExcluded = settings.excludedQualities || [];
const isExcluded = currentExcluded.includes(quality); const isExcluded = currentExcluded.includes(quality);
let newExcluded: string[]; let newExcluded: string[];
if (isExcluded) { if (isExcluded) {
// Remove from excluded list // Remove from excluded list
@ -1313,14 +1313,14 @@ const PluginsScreen: React.FC = () => {
// Add to excluded list // Add to excluded list
newExcluded = [...currentExcluded, quality]; newExcluded = [...currentExcluded, quality];
} }
await updateSetting('excludedQualities', newExcluded); await updateSetting('excludedQualities', newExcluded);
}; };
const handleToggleLanguageExclusion = async (language: string) => { const handleToggleLanguageExclusion = async (language: string) => {
const currentExcluded = settings.excludedLanguages || []; const currentExcluded = settings.excludedLanguages || [];
const isExcluded = currentExcluded.includes(language); const isExcluded = currentExcluded.includes(language);
let newExcluded: string[]; let newExcluded: string[];
if (isExcluded) { if (isExcluded) {
// Remove from excluded list // Remove from excluded list
@ -1329,13 +1329,13 @@ const PluginsScreen: React.FC = () => {
// Add to excluded list // Add to excluded list
newExcluded = [...currentExcluded, language]; newExcluded = [...currentExcluded, language];
} }
await updateSetting('excludedLanguages', newExcluded); await updateSetting('excludedLanguages', newExcluded);
}; };
// Define available quality options // Define available quality options
const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS']; const qualityOptions = ['Auto', 'Adaptive', '2160p', '4K', '1080p', '720p', '360p', 'DV', 'HDR', 'REMUX', '480p', 'CAM', 'TS'];
// Define available language options // Define available language options
const languageOptions = ['Original', 'English', 'Spanish', 'Latin', 'French', 'German', 'Italian', 'Portuguese', 'Russian', 'Japanese', 'Korean', 'Chinese', 'Arabic', 'Hindi', 'Turkish', 'Dutch', 'Polish']; const languageOptions = ['Original', 'English', 'Spanish', 'Latin', 'French', 'German', 'Italian', 'Portuguese', 'Russian', 'Japanese', 'Korean', 'Chinese', 'Arabic', 'Hindi', 'Turkish', 'Dutch', 'Polish'];
@ -1344,7 +1344,7 @@ const PluginsScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
@ -1354,7 +1354,7 @@ const PluginsScreen: React.FC = () => {
<Ionicons name="arrow-back" size={24} color={colors.primary} /> <Ionicons name="arrow-back" size={24} color={colors.primary} />
<Text style={styles.backText}>Settings</Text> <Text style={styles.backText}>Settings</Text>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.headerActions}> <View style={styles.headerActions}>
{/* Help Button */} {/* Help Button */}
<TouchableOpacity <TouchableOpacity
@ -1365,7 +1365,7 @@ const PluginsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
<Text style={styles.headerTitle}>Plugins</Text> <Text style={styles.headerTitle}>Plugins</Text>
<ScrollView <ScrollView
@ -1427,9 +1427,9 @@ const PluginsScreen: React.FC = () => {
styles={styles} styles={styles}
> >
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers. Manage multiple plugin repositories. Switch between repositories to access different sets of plugins.
</Text> </Text>
{/* Current Repository */} {/* Current Repository */}
{currentRepositoryId && ( {currentRepositoryId && (
<View style={styles.currentRepoContainer}> <View style={styles.currentRepoContainer}>
@ -1438,7 +1438,7 @@ const PluginsScreen: React.FC = () => {
<Text style={[styles.currentRepoUrl, { fontSize: 12, opacity: 0.7, marginTop: 4 }]}>{repositoryUrl}</Text> <Text style={[styles.currentRepoUrl, { fontSize: 12, opacity: 0.7, marginTop: 4 }]}>{repositoryUrl}</Text>
</View> </View>
)} )}
{/* Repository List */} {/* Repository List */}
{repositories.length > 0 && ( {repositories.length > 0 && (
<View style={styles.repositoriesList}> <View style={styles.repositoriesList}>
@ -1466,9 +1466,9 @@ const PluginsScreen: React.FC = () => {
)} )}
<Text style={styles.repositoryUrl}>{repo.url}</Text> <Text style={styles.repositoryUrl}>{repo.url}</Text>
<Text style={styles.repositoryMeta}> <Text style={styles.repositoryMeta}>
{repo.scraperCount || 0} scrapers Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} {repo.scraperCount || 0} plugins Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'}
</Text> </Text>
</View> </View>
<View style={styles.repositoryActions}> <View style={styles.repositoryActions}>
{repo.id !== currentRepositoryId && ( {repo.id !== currentRepositoryId && (
<TouchableOpacity <TouchableOpacity
@ -1502,7 +1502,7 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.repositoryActionButtonText}>Remove</Text> <Text style={styles.repositoryActionButtonText}>Remove</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
))} ))}
</View> </View>
)} )}
@ -1535,15 +1535,15 @@ const PluginsScreen: React.FC = () => {
style={styles.searchInput} style={styles.searchInput}
value={searchQuery} value={searchQuery}
onChangeText={setSearchQuery} onChangeText={setSearchQuery}
placeholder="Search scrapers..." placeholder="Search plugins..."
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
/> />
{searchQuery.length > 0 && ( {searchQuery.length > 0 && (
<TouchableOpacity onPress={() => setSearchQuery('')}> <TouchableOpacity onPress={() => setSearchQuery('')}>
<Ionicons name="close-circle" size={20} color={colors.mediumGray} /> <Ionicons name="close-circle" size={20} color={colors.mediumGray} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
{/* Filter Chips */} {/* Filter Chips */}
<View style={styles.filterContainer}> <View style={styles.filterContainer}>
@ -1561,7 +1561,7 @@ const PluginsScreen: React.FC = () => {
selectedFilter === filter && styles.filterChipTextSelected selectedFilter === filter && styles.filterChipTextSelected
]}> ]}>
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'} {filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
))} ))}
</View> </View>
@ -1590,19 +1590,19 @@ const PluginsScreen: React.FC = () => {
{filteredScrapers.length === 0 ? ( {filteredScrapers.length === 0 ? (
<View style={styles.emptyStateContainer}> <View style={styles.emptyStateContainer}>
<Ionicons <Ionicons
name={searchQuery ? "search" : "download-outline"} name={searchQuery ? "search" : "download-outline"}
size={48} size={48}
color={colors.mediumGray} color={colors.mediumGray}
style={styles.emptyStateIcon} style={styles.emptyStateIcon}
/> />
<Text style={styles.emptyStateTitle}> <Text style={styles.emptyStateTitle}>
{searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'} {searchQuery ? 'No Plugins Found' : 'No Plugins Available'}
</Text> </Text>
<Text style={styles.emptyStateDescription}> <Text style={styles.emptyStateDescription}>
{searchQuery {searchQuery
? `No scrapers match "${searchQuery}". Try a different search term.` ? `No plugins match "${searchQuery}". Try a different search term.`
: 'Configure a repository above to view available scrapers.' : 'Configure a repository above to view available plugins.'
} }
</Text> </Text>
{searchQuery && ( {searchQuery && (
@ -1613,45 +1613,45 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.secondaryButtonText}>Clear Search</Text> <Text style={styles.secondaryButtonText}>Clear Search</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
) : ( ) : (
<View style={styles.scrapersContainer}> <View style={styles.scrapersContainer}>
{filteredScrapers.map((scraper) => ( {filteredScrapers.map((scraper) => (
<View key={scraper.id} style={styles.scraperCard}> <View key={scraper.id} style={styles.scraperCard}>
<View style={styles.scraperCardHeader}> <View style={styles.scraperCardHeader}>
{scraper.logo ? ( {scraper.logo ? (
(scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? ( (scraper.logo.toLowerCase().endsWith('.svg') || scraper.logo.toLowerCase().includes('.svg?')) ? (
<Image <Image
source={{ uri: scraper.logo }} source={{ uri: scraper.logo }}
style={styles.scraperLogo} style={styles.scraperLogo}
resizeMode="contain" resizeMode="contain"
/> />
) : ( ) : (
<FastImage <FastImage
source={{ uri: scraper.logo }} source={{ uri: scraper.logo }}
style={styles.scraperLogo} style={styles.scraperLogo}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
) )
) : ( ) : (
<View style={styles.scraperLogo} /> <View style={styles.scraperLogo} />
)} )}
<View style={styles.scraperCardInfo}> <View style={styles.scraperCardInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4, gap: 8 }}>
<Text style={styles.scraperName}>{scraper.name}</Text> <Text style={styles.scraperName}>{scraper.name}</Text>
<StatusBadge status={getScraperStatus(scraper)} colors={colors} /> <StatusBadge status={getScraperStatus(scraper)} colors={colors} />
</View>
<Text style={styles.scraperDescription}>{scraper.description}</Text>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
/>
</View> </View>
<Text style={styles.scraperDescription}>{scraper.description}</Text>
</View>
<Switch
value={scraper.enabled && settings.enableLocalScrapers}
onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || scraper.manifestEnabled === false || (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(Platform.OS as 'ios' | 'android'))}
/>
</View>
<View style={styles.scraperCardMeta}> <View style={styles.scraperCardMeta}>
<View style={styles.scraperCardMetaItem}> <View style={styles.scraperCardMetaItem}>
<Ionicons name="information-circle" size={12} color={colors.mediumGray} /> <Ionicons name="information-circle" size={12} color={colors.mediumGray} />
@ -1682,62 +1682,62 @@ const PluginsScreen: React.FC = () => {
</View> </View>
{/* ShowBox Settings - only visible when ShowBox scraper is available */} {/* ShowBox Settings - only visible when ShowBox scraper is available */}
{showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && ( {showboxScraperId && scraper.id === showboxScraperId && settings.enableLocalScrapers && (
<View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}> <View style={{ marginTop: 16, paddingTop: 16, borderTopWidth: 1, borderTopColor: colors.elevation3 }}>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text> <Text style={[styles.settingTitle, { marginBottom: 8 }]}>ShowBox UI Token</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}> <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 12 }}>
<TextInput <TextInput
style={[styles.textInput, { flex: 1, marginBottom: 0 }]} style={[styles.textInput, { flex: 1, marginBottom: 0 }]}
value={showboxUiToken} value={showboxUiToken}
onChangeText={setShowboxUiToken} onChangeText={setShowboxUiToken}
placeholder="Paste your ShowBox UI token" placeholder="Paste your ShowBox UI token"
placeholderTextColor={colors.mediumGray} placeholderTextColor={colors.mediumGray}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible} secureTextEntry={showboxSavedToken.length > 0 && !showboxTokenVisible}
multiline={false} multiline={false}
numberOfLines={1} numberOfLines={1}
/> />
{showboxSavedToken.length > 0 && ( {showboxSavedToken.length > 0 && (
<TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}> <TouchableOpacity onPress={() => setShowboxTokenVisible(v => !v)} accessibilityRole="button" accessibilityLabel={showboxTokenVisible ? 'Hide token' : 'Show token'} style={{ marginLeft: 10 }}>
<Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} /> <Ionicons name={showboxTokenVisible ? 'eye-off' : 'eye'} size={18} color={colors.primary} />
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
{showboxUiToken !== showboxSavedToken && ( {showboxUiToken !== showboxSavedToken && (
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.primaryButton]} style={[styles.button, styles.primaryButton]}
onPress={async () => { onPress={async () => {
if (showboxScraperId) { if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken }); await pluginService.setScraperSettings(showboxScraperId, { uiToken: showboxUiToken });
} }
setShowboxSavedToken(showboxUiToken); setShowboxSavedToken(showboxUiToken);
openAlert('Saved', 'ShowBox settings updated'); openAlert('Saved', 'ShowBox settings updated');
}} }}
> >
<Text style={styles.buttonText}>Save</Text> <Text style={styles.buttonText}>Save</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.secondaryButton]} style={[styles.button, styles.secondaryButton]}
onPress={async () => { onPress={async () => {
setShowboxUiToken(''); setShowboxUiToken('');
setShowboxSavedToken(''); setShowboxSavedToken('');
if (showboxScraperId) { if (showboxScraperId) {
await pluginService.setScraperSettings(showboxScraperId, {}); await pluginService.setScraperSettings(showboxScraperId, {});
} }
}} }}
> >
<Text style={styles.secondaryButtonText}>Clear</Text> <Text style={styles.secondaryButtonText}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)} )}
</View> </View>
))} ))}
</View> </View>
)} )}
</CollapsibleSection> </CollapsibleSection>
{/* Additional Settings */} {/* Additional Settings */}
@ -1763,7 +1763,7 @@ const PluginsScreen: React.FC = () => {
disabled={!settings.enableLocalScrapers} disabled={!settings.enableLocalScrapers}
/> />
</View> </View>
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Group Plugin Streams</Text> <Text style={styles.settingTitle}>Group Plugin Streams</Text>
@ -1772,50 +1772,50 @@ const PluginsScreen: React.FC = () => {
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings.streamDisplayMode === 'grouped'} value={settings.streamDisplayMode === 'grouped'}
onValueChange={(value) => { onValueChange={(value) => {
updateSetting('streamDisplayMode', value ? 'grouped' : 'separate'); updateSetting('streamDisplayMode', value ? 'grouped' : 'separate');
// Auto-disable quality sorting when grouping is disabled // Auto-disable quality sorting when grouping is disabled
if (!value && settings.streamSortMode === 'quality-then-scraper') { if (!value && settings.streamSortMode === 'quality-then-scraper') {
updateSetting('streamSortMode', 'scraper-then-quality'); updateSetting('streamSortMode', 'scraper-then-quality');
} }
}} }}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'} thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers} disabled={!settings.enableLocalScrapers}
/> />
</View> </View>
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Sort by Quality First</Text> <Text style={styles.settingTitle}>Sort by Quality First</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
When enabled, streams are sorted by quality first, then by scraper. When disabled, streams are sorted by scraper first, then by quality. Only available when grouping is enabled. When enabled, streams are sorted by quality first, then by plugin. When disabled, streams are sorted by plugin first, then by quality. Only available when grouping is enabled.
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings.streamSortMode === 'quality-then-scraper'} value={settings.streamSortMode === 'quality-then-scraper'}
onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')} onValueChange={(value) => updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'} thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'} disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'}
/> />
</View> </View>
<View style={styles.settingRow}> <View style={styles.settingRow}>
<View style={styles.settingInfo}> <View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Show Scraper Logos</Text> <Text style={styles.settingTitle}>Show Plugin Logos</Text>
<Text style={styles.settingDescription}> <Text style={styles.settingDescription}>
Display scraper logos next to streaming links on the streams screen. Display plugin logos next to streaming links on the streams screen.
</Text> </Text>
</View> </View>
<Switch <Switch
value={settings.showScraperLogos && settings.enableLocalScrapers} value={settings.showScraperLogos && settings.enableLocalScrapers}
onValueChange={(value) => updateSetting('showScraperLogos', value)} onValueChange={(value) => updateSetting('showScraperLogos', value)}
trackColor={{ false: colors.elevation3, true: colors.primary }} trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} thumbColor={settings.showScraperLogos && settings.enableLocalScrapers ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers} disabled={!settings.enableLocalScrapers}
/> />
</View> </View>
</CollapsibleSection> </CollapsibleSection>
@ -1830,7 +1830,7 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results. Exclude specific video qualities from search results. Tap on a quality to exclude it from plugin results.
</Text> </Text>
<View style={styles.qualityChipsContainer}> <View style={styles.qualityChipsContainer}>
{qualityOptions.map((quality) => { {qualityOptions.map((quality) => {
const isExcluded = (settings.excludedQualities || []).includes(quality); const isExcluded = (settings.excludedQualities || []).includes(quality);
@ -1856,7 +1856,7 @@ const PluginsScreen: React.FC = () => {
); );
})} })}
</View> </View>
{(settings.excludedQualities || []).length > 0 && ( {(settings.excludedQualities || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}> <Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded qualities: {(settings.excludedQualities || []).join(', ')} Excluded qualities: {(settings.excludedQualities || []).join(', ')}
@ -1875,11 +1875,11 @@ const PluginsScreen: React.FC = () => {
<Text style={styles.sectionDescription}> <Text style={styles.sectionDescription}>
Exclude specific languages from search results. Tap on a language to exclude it from plugin results. Exclude specific languages from search results. Tap on a language to exclude it from plugin results.
</Text> </Text>
<Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}> <Text style={[styles.infoText, { marginTop: 8, fontSize: 13, color: colors.mediumEmphasis }]}>
<Text style={{ fontWeight: '600' }}>Note:</Text> This filter only applies to providers that include language information in their stream names. It does not affect other providers. <Text style={{ fontWeight: '600' }}>Note:</Text> This filter only applies to providers that include language information in their stream names. It does not affect other providers.
</Text> </Text>
<View style={styles.qualityChipsContainer}> <View style={styles.qualityChipsContainer}>
{languageOptions.map((language) => { {languageOptions.map((language) => {
const isExcluded = (settings.excludedLanguages || []).includes(language); const isExcluded = (settings.excludedLanguages || []).includes(language);
@ -1905,7 +1905,7 @@ const PluginsScreen: React.FC = () => {
); );
})} })}
</View> </View>
{(settings.excludedLanguages || []).length > 0 && ( {(settings.excludedLanguages || []).length > 0 && (
<Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}> <Text style={[styles.infoText, { marginTop: 12 }, !settings.enableLocalScrapers && styles.disabledText]}>
Excluded languages: {(settings.excludedLanguages || []).join(', ')} Excluded languages: {(settings.excludedLanguages || []).join(', ')}
@ -1944,10 +1944,10 @@ const PluginsScreen: React.FC = () => {
2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository 2. <Text style={{ fontWeight: '600' }}>Add Repository</Text> - Add a GitHub raw URL or use the default repository
</Text> </Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available scrapers from the repository 3. <Text style={{ fontWeight: '600' }}>Refresh Repository</Text> - Download available plugins from the repository
</Text> </Text>
<Text style={styles.modalText}> <Text style={styles.modalText}>
4. <Text style={{ fontWeight: '600' }}>Enable Scrapers</Text> - Turn on the scrapers you want to use for streaming 4. <Text style={{ fontWeight: '600' }}>Enable Plugins</Text> - Turn on the plugins you want to use for streaming
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={styles.modalButton} style={styles.modalButton}
@ -1988,36 +1988,36 @@ const PluginsScreen: React.FC = () => {
/> />
{/* Format Hint */} {/* Format Hint */}
<Text style={styles.formatHint}> <Text style={styles.formatHint}>
Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch Format: https://raw.githubusercontent.com/username/repo/refs/heads/branch
</Text> </Text>
{/* Action Buttons */} {/* Action Buttons */}
<View style={styles.compactActions}> <View style={styles.compactActions}>
<TouchableOpacity <TouchableOpacity
style={[styles.compactButton, styles.cancelButton]} style={[styles.compactButton, styles.cancelButton]}
onPress={() => { onPress={() => {
setShowAddRepositoryModal(false); setShowAddRepositoryModal(false);
setNewRepositoryUrl(''); setNewRepositoryUrl('');
}} }}
> >
<Text style={styles.cancelButtonText}>Cancel</Text> <Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]} style={[styles.compactButton, styles.addButton, (!newRepositoryUrl.trim() || isLoading) && styles.disabledButton]}
onPress={handleAddRepository} onPress={handleAddRepository}
disabled={!newRepositoryUrl.trim() || isLoading} disabled={!newRepositoryUrl.trim() || isLoading}
> >
{isLoading ? ( {isLoading ? (
<ActivityIndicator size="small" color={colors.white} /> <ActivityIndicator size="small" color={colors.white} />
) : ( ) : (
<Text style={styles.addButtonText}>Add</Text> <Text style={styles.addButtonText}>Add</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</ScrollView> </ScrollView>
</View> </View>
</View> </View>
</Modal> </Modal>

View file

@ -561,7 +561,7 @@ const SettingsScreen: React.FC = () => {
description="Manage plugins and repositories" description="Manage plugins and repositories"
customIcon={<PluginIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />} customIcon={<PluginIcon size={isTablet ? 24 : 20} color={currentTheme.colors.primary} />}
renderControl={ChevronRight} renderControl={ChevronRight}
onPress={() => navigation.navigate('ScraperSettings')} onPress={() => navigation.navigate('PluginSettings')}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem

View file

@ -913,13 +913,13 @@ export const StreamsScreen = () => {
const handleStreamPress = useCallback(async (stream: Stream) => { const handleStreamPress = useCallback(async (stream: Stream) => {
try { try {
if (stream.url) { if (stream.url) {
// Block magnet links - not supported yet
// Block magnet links with sanitized message
if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) {
try { openAlert('Stream Not Supported', 'This stream format is not supported.');
openAlert('Not supported', 'Torrent streaming is not supported yet.');
} catch (_e) { }
return; return;
} }
// If stream is actually MKV format, force the in-app VLC-based player on iOS // If stream is actually MKV format, force the in-app VLC-based player on iOS
try { try {
if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') { if (Platform.OS === 'ios' && settings.preferredPlayer === 'internal') {
@ -1078,7 +1078,7 @@ export const StreamsScreen = () => {
const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:'); const isMagnet = typeof stream.url === 'string' && stream.url.startsWith('magnet:');
if (isMagnet) { if (isMagnet) {
// For magnet links, open directly which will trigger the torrent app chooser // For magnet links, open directly
if (__DEV__) console.log('Opening magnet link directly'); if (__DEV__) console.log('Opening magnet link directly');
Linking.openURL(stream.url) Linking.openURL(stream.url)
.then(() => { if (__DEV__) console.log('Successfully opened magnet link'); }) .then(() => { if (__DEV__) console.log('Successfully opened magnet link'); })

View file

@ -113,7 +113,7 @@ class LocalScraperService {
id: 'default', id: 'default',
name: this.extractRepositoryName(storedRepoUrl), name: this.extractRepositoryName(storedRepoUrl),
url: storedRepoUrl, url: storedRepoUrl,
description: 'Default repository', description: 'Default Plugins Repository',
isDefault: true, isDefault: true,
enabled: true, enabled: true,
lastUpdated: Date.now() lastUpdated: Date.now()
@ -146,21 +146,21 @@ class LocalScraperService {
if (storedScrapers) { if (storedScrapers) {
const scrapers: ScraperInfo[] = JSON.parse(storedScrapers); const scrapers: ScraperInfo[] = JSON.parse(storedScrapers);
const validScrapers: ScraperInfo[] = []; const validScrapers: ScraperInfo[] = [];
scrapers.forEach(scraper => { scrapers.forEach(scraper => {
// Skip scrapers with missing essential fields // Skip scrapers with missing essential fields
if (!scraper.id || !scraper.name || !scraper.version) { if (!scraper.id || !scraper.name || !scraper.version) {
logger.warn('[LocalScraperService] Skipping invalid scraper with missing essential fields:', scraper); logger.warn('[LocalScraperService] Skipping invalid scraper with missing essential fields:', scraper);
return; return;
} }
// Ensure contentLanguage is an array (migration for older scrapers) // Ensure contentLanguage is an array (migration for older scrapers)
if (!scraper.contentLanguage) { if (!scraper.contentLanguage) {
scraper.contentLanguage = ['en']; // Default to English scraper.contentLanguage = ['en']; // Default to English
} else if (typeof scraper.contentLanguage === 'string') { } else if (typeof scraper.contentLanguage === 'string') {
scraper.contentLanguage = [scraper.contentLanguage]; // Convert string to array scraper.contentLanguage = [scraper.contentLanguage]; // Convert string to array
} }
// Ensure supportedTypes is an array (migration for older scrapers) // Ensure supportedTypes is an array (migration for older scrapers)
if (!scraper.supportedTypes || !Array.isArray(scraper.supportedTypes)) { if (!scraper.supportedTypes || !Array.isArray(scraper.supportedTypes)) {
scraper.supportedTypes = ['movie', 'tv']; // Default to both types scraper.supportedTypes = ['movie', 'tv']; // Default to both types
@ -175,7 +175,7 @@ class LocalScraperService {
if (!scraper.supportedFormats && scraper.formats) { if (!scraper.supportedFormats && scraper.formats) {
scraper.supportedFormats = scraper.formats; scraper.supportedFormats = scraper.formats;
} }
// Ensure other required fields have defaults // Ensure other required fields have defaults
if (!scraper.description) { if (!scraper.description) {
scraper.description = 'No description available'; scraper.description = 'No description available';
@ -186,16 +186,16 @@ class LocalScraperService {
if (scraper.enabled === undefined) { if (scraper.enabled === undefined) {
scraper.enabled = true; scraper.enabled = true;
} }
this.installedScrapers.set(scraper.id, scraper); this.installedScrapers.set(scraper.id, scraper);
validScrapers.push(scraper); validScrapers.push(scraper);
}); });
// Save cleaned scrapers back to storage if any were filtered out // Save cleaned scrapers back to storage if any were filtered out
if (validScrapers.length !== scrapers.length) { if (validScrapers.length !== scrapers.length) {
logger.log('[LocalScraperService] Cleaned up invalid scrapers, saving valid ones'); logger.log('[LocalScraperService] Cleaned up invalid scrapers, saving valid ones');
await mmkvStorage.setItem(this.STORAGE_KEY, JSON.stringify(validScrapers)); await mmkvStorage.setItem(this.STORAGE_KEY, JSON.stringify(validScrapers));
// Clean up cached code for removed scrapers // Clean up cached code for removed scrapers
const validScraperIds = new Set(validScrapers.map(s => s.id)); const validScraperIds = new Set(validScrapers.map(s => s.id));
const removedScrapers = scrapers.filter(s => s.id && !validScraperIds.has(s.id)); const removedScrapers = scrapers.filter(s => s.id && !validScraperIds.has(s.id));
@ -212,7 +212,7 @@ class LocalScraperService {
// Load scraper code from cache // Load scraper code from cache
await this.loadScraperCode(); await this.loadScraperCode();
// Auto-refresh repository on app startup if URL is configured (only once) // Auto-refresh repository on app startup if URL is configured (only once)
if (this.repositoryUrl && !this.autoRefreshCompleted) { if (this.repositoryUrl && !this.autoRefreshCompleted) {
try { try {
@ -225,7 +225,7 @@ class LocalScraperService {
this.autoRefreshCompleted = true; // Mark as completed even on error to prevent retries this.autoRefreshCompleted = true; // Mark as completed even on error to prevent retries
} }
} }
this.initialized = true; this.initialized = true;
logger.log('[LocalScraperService] Initialized with', this.installedScrapers.size, 'scrapers'); logger.log('[LocalScraperService] Initialized with', this.installedScrapers.size, 'scrapers');
} catch (error) { } catch (error) {
@ -268,7 +268,7 @@ class LocalScraperService {
async addRepository(repo: Omit<RepositoryInfo, 'id' | 'lastUpdated' | 'scraperCount'>): Promise<string> { async addRepository(repo: Omit<RepositoryInfo, 'id' | 'lastUpdated' | 'scraperCount'>): Promise<string> {
await this.ensureInitialized(); await this.ensureInitialized();
const id = `repo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const id = `repo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Try to fetch the repository name from manifest if not provided // Try to fetch the repository name from manifest if not provided
let repositoryName = repo.name; let repositoryName = repo.name;
if (!repositoryName || repositoryName.trim() === '') { if (!repositoryName || repositoryName.trim() === '') {
@ -279,7 +279,7 @@ class LocalScraperService {
repositoryName = this.extractRepositoryName(repo.url); repositoryName = this.extractRepositoryName(repo.url);
} }
} }
const newRepo: RepositoryInfo = { const newRepo: RepositoryInfo = {
...repo, ...repo,
name: repositoryName, name: repositoryName,
@ -302,7 +302,7 @@ class LocalScraperService {
const updatedRepo = { ...repo, ...updates }; const updatedRepo = { ...repo, ...updates };
this.repositories.set(id, updatedRepo); this.repositories.set(id, updatedRepo);
await this.saveRepositories(); await this.saveRepositories();
// If this is the current repository, update current values // If this is the current repository, update current values
if (id === this.currentRepositoryId) { if (id === this.currentRepositoryId) {
this.repositoryUrl = updatedRepo.url; this.repositoryUrl = updatedRepo.url;
@ -316,10 +316,10 @@ class LocalScraperService {
if (!this.repositories.has(id)) { if (!this.repositories.has(id)) {
throw new Error(`Repository with id ${id} not found`); throw new Error(`Repository with id ${id} not found`);
} }
// Allow removing the last repository - users can add new ones // Allow removing the last repository - users can add new ones
// The app will work without repositories (no scrapers available) // The app will work without repositories (no scrapers available)
// If removing current repository, switch to another one or clear current // If removing current repository, switch to another one or clear current
if (id === this.currentRepositoryId) { if (id === this.currentRepositoryId) {
const remainingRepos = Array.from(this.repositories.values()).filter(r => r.id !== id); const remainingRepos = Array.from(this.repositories.values()).filter(r => r.id !== id);
@ -331,18 +331,18 @@ class LocalScraperService {
await mmkvStorage.removeItem('current-repository-id'); await mmkvStorage.removeItem('current-repository-id');
} }
} }
// Remove scrapers from this repository // Remove scrapers from this repository
const scrapersToRemove = Array.from(this.installedScrapers.values()) const scrapersToRemove = Array.from(this.installedScrapers.values())
.filter(s => s.repositoryId === id) .filter(s => s.repositoryId === id)
.map(s => s.id); .map(s => s.id);
for (const scraperId of scrapersToRemove) { for (const scraperId of scrapersToRemove) {
this.installedScrapers.delete(scraperId); this.installedScrapers.delete(scraperId);
this.scraperCode.delete(scraperId); this.scraperCode.delete(scraperId);
await mmkvStorage.removeItem(`scraper-code-${scraperId}`); await mmkvStorage.removeItem(`scraper-code-${scraperId}`);
} }
this.repositories.delete(id); this.repositories.delete(id);
await this.saveRepositories(); await this.saveRepositories();
await this.saveInstalledScrapers(); await this.saveInstalledScrapers();
@ -355,13 +355,13 @@ class LocalScraperService {
if (!repo) { if (!repo) {
throw new Error(`Repository with id ${id} not found`); throw new Error(`Repository with id ${id} not found`);
} }
this.currentRepositoryId = id; this.currentRepositoryId = id;
this.repositoryUrl = repo.url; this.repositoryUrl = repo.url;
this.repositoryName = repo.name; this.repositoryName = repo.name;
await mmkvStorage.setItem('current-repository-id', id); await mmkvStorage.setItem('current-repository-id', id);
// Refresh the repository to get its scrapers // Refresh the repository to get its scrapers
try { try {
logger.log('[LocalScraperService] Refreshing repository after switch:', repo.name); logger.log('[LocalScraperService] Refreshing repository after switch:', repo.name);
@ -370,7 +370,7 @@ class LocalScraperService {
logger.error('[LocalScraperService] Failed to refresh repository after switch:', error); logger.error('[LocalScraperService] Failed to refresh repository after switch:', error);
// Don't throw error, just log it - the switch should still succeed // Don't throw error, just log it - the switch should still succeed
} }
logger.log('[LocalScraperService] Switched to repository:', repo.name); logger.log('[LocalScraperService] Switched to repository:', repo.name);
} }
@ -396,21 +396,21 @@ class LocalScraperService {
async fetchRepositoryNameFromManifest(repositoryUrl: string): Promise<string> { async fetchRepositoryNameFromManifest(repositoryUrl: string): Promise<string> {
try { try {
logger.log('[LocalScraperService] Fetching repository name from manifest:', repositoryUrl); logger.log('[LocalScraperService] Fetching repository name from manifest:', repositoryUrl);
// Construct manifest URL // Construct manifest URL
const baseManifestUrl = repositoryUrl.endsWith('/') const baseManifestUrl = repositoryUrl.endsWith('/')
? `${repositoryUrl}manifest.json` ? `${repositoryUrl}manifest.json`
: `${repositoryUrl}/manifest.json`; : `${repositoryUrl}/manifest.json`;
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}`; const manifestUrl = `${baseManifestUrl}?t=${Date.now()}`;
const response = await axios.get(manifestUrl, { const response = await axios.get(manifestUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Pragma': 'no-cache' 'Pragma': 'no-cache'
} }
}); });
if (response.data && response.data.name) { if (response.data && response.data.name) {
logger.log('[LocalScraperService] Found repository name in manifest:', response.data.name); logger.log('[LocalScraperService] Found repository name in manifest:', response.data.name);
return response.data.name; return response.data.name;
@ -427,14 +427,14 @@ class LocalScraperService {
// Update repository name from manifest for existing repositories // Update repository name from manifest for existing repositories
async refreshRepositoryNamesFromManifests(): Promise<void> { async refreshRepositoryNamesFromManifests(): Promise<void> {
await this.ensureInitialized(); await this.ensureInitialized();
for (const [id, repo] of this.repositories) { for (const [id, repo] of this.repositories) {
try { try {
const manifestName = await this.fetchRepositoryNameFromManifest(repo.url); const manifestName = await this.fetchRepositoryNameFromManifest(repo.url);
if (manifestName !== repo.name) { if (manifestName !== repo.name) {
logger.log('[LocalScraperService] Updating repository name:', repo.name, '->', manifestName); logger.log('[LocalScraperService] Updating repository name:', repo.name, '->', manifestName);
repo.name = manifestName; repo.name = manifestName;
// If this is the current repository, update the current name // If this is the current repository, update the current name
if (id === this.currentRepositoryId) { if (id === this.currentRepositoryId) {
this.repositoryName = manifestName; this.repositoryName = manifestName;
@ -444,7 +444,7 @@ class LocalScraperService {
logger.warn('[LocalScraperService] Failed to refresh name for repository:', repo.name, error); logger.warn('[LocalScraperService] Failed to refresh name for repository:', repo.name, error);
} }
} }
await this.saveRepositories(); await this.saveRepositories();
} }
@ -457,19 +457,19 @@ class LocalScraperService {
// Check if a scraper is compatible with the current platform // Check if a scraper is compatible with the current platform
private isPlatformCompatible(scraper: ScraperInfo): boolean { private isPlatformCompatible(scraper: ScraperInfo): boolean {
const currentPlatform = Platform.OS as 'ios' | 'android'; const currentPlatform = Platform.OS as 'ios' | 'android';
// If disabledPlatforms is specified and includes current platform, scraper is not compatible // If disabledPlatforms is specified and includes current platform, scraper is not compatible
if (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(currentPlatform)) { if (scraper.disabledPlatforms && scraper.disabledPlatforms.includes(currentPlatform)) {
logger.log(`[LocalScraperService] Scraper ${scraper.name} is disabled on ${currentPlatform}`); logger.log(`[LocalScraperService] Scraper ${scraper.name} is disabled on ${currentPlatform}`);
return false; return false;
} }
// If supportedPlatforms is specified and doesn't include current platform, scraper is not compatible // If supportedPlatforms is specified and doesn't include current platform, scraper is not compatible
if (scraper.supportedPlatforms && !scraper.supportedPlatforms.includes(currentPlatform)) { if (scraper.supportedPlatforms && !scraper.supportedPlatforms.includes(currentPlatform)) {
logger.log(`[LocalScraperService] Scraper ${scraper.name} is not supported on ${currentPlatform}`); logger.log(`[LocalScraperService] Scraper ${scraper.name} is not supported on ${currentPlatform}`);
return false; return false;
} }
// If neither supportedPlatforms nor disabledPlatforms is specified, or current platform is supported // If neither supportedPlatforms nor disabledPlatforms is specified, or current platform is supported
return true; return true;
} }
@ -516,33 +516,33 @@ class LocalScraperService {
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`; const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`;
const response = await axios.get(manifestUrl, { const response = await axios.get(manifestUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0' 'Expires': '0'
} }
}); });
const manifest: ScraperManifest = response.data; const manifest: ScraperManifest = response.data;
// Store repository name from manifest // Store repository name from manifest
if (manifest.name) { if (manifest.name) {
this.repositoryName = manifest.name; this.repositoryName = manifest.name;
} }
logger.log('[LocalScraperService] getAvailableScrapers - Raw manifest data:', JSON.stringify(manifest, null, 2)); logger.log('[LocalScraperService] getAvailableScrapers - Raw manifest data:', JSON.stringify(manifest, null, 2));
logger.log('[LocalScraperService] getAvailableScrapers - Manifest scrapers count:', manifest.scrapers?.length || 0); logger.log('[LocalScraperService] getAvailableScrapers - Manifest scrapers count:', manifest.scrapers?.length || 0);
// Log each scraper's enabled status from manifest // Log each scraper's enabled status from manifest
manifest.scrapers?.forEach(scraper => { manifest.scrapers?.forEach(scraper => {
logger.log(`[LocalScraperService] getAvailableScrapers - Scraper ${scraper.name}: enabled=${scraper.enabled}`); logger.log(`[LocalScraperService] getAvailableScrapers - Scraper ${scraper.name}: enabled=${scraper.enabled}`);
}); });
logger.log('[LocalScraperService] Found', manifest.scrapers.length, 'scrapers in repository'); logger.log('[LocalScraperService] Found', manifest.scrapers.length, 'scrapers in repository');
// Get current manifest scraper IDs // Get current manifest scraper IDs
const manifestScraperIds = new Set(manifest.scrapers.map(s => s.id)); const manifestScraperIds = new Set(manifest.scrapers.map(s => s.id));
// Remove scrapers that are no longer in the manifest // Remove scrapers that are no longer in the manifest
const currentScraperIds = Array.from(this.installedScrapers.keys()); const currentScraperIds = Array.from(this.installedScrapers.keys());
for (const scraperId of currentScraperIds) { for (const scraperId of currentScraperIds) {
@ -554,11 +554,11 @@ class LocalScraperService {
await mmkvStorage.removeItem(`scraper-code-${scraperId}`); await mmkvStorage.removeItem(`scraper-code-${scraperId}`);
} }
} }
// Download and install each scraper from manifest // Download and install each scraper from manifest
for (const scraperInfo of manifest.scrapers) { for (const scraperInfo of manifest.scrapers) {
const isPlatformCompatible = this.isPlatformCompatible(scraperInfo); const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
if (isPlatformCompatible) { if (isPlatformCompatible) {
// Add repository ID to scraper info // Add repository ID to scraper info
const scraperWithRepo = { ...scraperInfo, repositoryId: this.currentRepositoryId }; const scraperWithRepo = { ...scraperInfo, repositoryId: this.currentRepositoryId };
@ -575,9 +575,9 @@ class LocalScraperService {
} }
} }
} }
await this.saveInstalledScrapers(); await this.saveInstalledScrapers();
// Update repository info // Update repository info
const currentRepo = this.repositories.get(this.currentRepositoryId); const currentRepo = this.repositories.get(this.currentRepositoryId);
if (currentRepo) { if (currentRepo) {
@ -588,9 +588,9 @@ class LocalScraperService {
scraperCount scraperCount
}); });
} }
logger.log('[LocalScraperService] Repository refresh completed'); logger.log('[LocalScraperService] Repository refresh completed');
} catch (error) { } catch (error) {
logger.error('[LocalScraperService] Failed to refresh repository:', error); logger.error('[LocalScraperService] Failed to refresh repository:', error);
throw error; throw error;
@ -602,12 +602,12 @@ class LocalScraperService {
// Download individual scraper // Download individual scraper
private async downloadScraper(scraperInfo: ScraperInfo): Promise<void> { private async downloadScraper(scraperInfo: ScraperInfo): Promise<void> {
try { try {
const scraperUrl = this.repositoryUrl.endsWith('/') const scraperUrl = this.repositoryUrl.endsWith('/')
? `${this.repositoryUrl}${scraperInfo.filename}` ? `${this.repositoryUrl}${scraperInfo.filename}`
: `${this.repositoryUrl}/${scraperInfo.filename}`; : `${this.repositoryUrl}/${scraperInfo.filename}`;
logger.log('[LocalScraperService] Downloading scraper:', scraperInfo.name); logger.log('[LocalScraperService] Downloading scraper:', scraperInfo.name);
// Add cache-busting parameters to force fresh download // Add cache-busting parameters to force fresh download
const scraperUrlWithCacheBust = `${scraperUrl}?t=${Date.now()}&v=${Math.random()}`; const scraperUrlWithCacheBust = `${scraperUrl}?t=${Date.now()}&v=${Math.random()}`;
@ -620,11 +620,11 @@ class LocalScraperService {
} }
}); });
const scraperCode = response.data; const scraperCode = response.data;
// Store scraper info and code // Store scraper info and code
const existingScraper = this.installedScrapers.get(scraperInfo.id); const existingScraper = this.installedScrapers.get(scraperInfo.id);
const isPlatformCompatible = this.isPlatformCompatible(scraperInfo); const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
const updatedScraperInfo = { const updatedScraperInfo = {
...scraperInfo, ...scraperInfo,
// Store the manifest's enabled state separately // Store the manifest's enabled state separately
@ -635,14 +635,14 @@ class LocalScraperService {
// Otherwise, preserve user's enabled state or default to true for new installations // Otherwise, preserve user's enabled state or default to true for new installations
enabled: scraperInfo.enabled && isPlatformCompatible ? (existingScraper?.enabled ?? true) : false enabled: scraperInfo.enabled && isPlatformCompatible ? (existingScraper?.enabled ?? true) : false
}; };
// Ensure contentLanguage is an array (migration for older scrapers) // Ensure contentLanguage is an array (migration for older scrapers)
if (!updatedScraperInfo.contentLanguage) { if (!updatedScraperInfo.contentLanguage) {
updatedScraperInfo.contentLanguage = ['en']; // Default to English updatedScraperInfo.contentLanguage = ['en']; // Default to English
} else if (typeof updatedScraperInfo.contentLanguage === 'string') { } else if (typeof updatedScraperInfo.contentLanguage === 'string') {
updatedScraperInfo.contentLanguage = [updatedScraperInfo.contentLanguage]; // Convert string to array updatedScraperInfo.contentLanguage = [updatedScraperInfo.contentLanguage]; // Convert string to array
} }
// Ensure supportedTypes is an array (migration for older scrapers) // Ensure supportedTypes is an array (migration for older scrapers)
if (!updatedScraperInfo.supportedTypes || !Array.isArray(updatedScraperInfo.supportedTypes)) { if (!updatedScraperInfo.supportedTypes || !Array.isArray(updatedScraperInfo.supportedTypes)) {
updatedScraperInfo.supportedTypes = ['movie', 'tv']; // Default to both types updatedScraperInfo.supportedTypes = ['movie', 'tv']; // Default to both types
@ -657,16 +657,16 @@ class LocalScraperService {
if (!updatedScraperInfo.supportedFormats && updatedScraperInfo.formats) { if (!updatedScraperInfo.supportedFormats && updatedScraperInfo.formats) {
updatedScraperInfo.supportedFormats = updatedScraperInfo.formats; updatedScraperInfo.supportedFormats = updatedScraperInfo.formats;
} }
this.installedScrapers.set(scraperInfo.id, updatedScraperInfo); this.installedScrapers.set(scraperInfo.id, updatedScraperInfo);
this.scraperCode.set(scraperInfo.id, scraperCode); this.scraperCode.set(scraperInfo.id, scraperCode);
// Cache the scraper code // Cache the scraper code
await this.cacheScraperCode(scraperInfo.id, scraperCode); await this.cacheScraperCode(scraperInfo.id, scraperCode);
logger.log('[LocalScraperService] Successfully downloaded:', scraperInfo.name); logger.log('[LocalScraperService] Successfully downloaded:', scraperInfo.name);
} catch (error) { } catch (error) {
logger.error('[LocalScraperService] Failed to download scraper', scraperInfo.name, ':', error); logger.error('[LocalScraperService] Failed to download scraper', scraperInfo.name, ':', error);
} }
@ -752,7 +752,7 @@ class LocalScraperService {
try { try {
logger.log('[LocalScraperService] Fetching available scrapers from manifest'); logger.log('[LocalScraperService] Fetching available scrapers from manifest');
// Fetch manifest with cache busting // Fetch manifest with cache busting
const baseManifestUrl = this.repositoryUrl.endsWith('/') const baseManifestUrl = this.repositoryUrl.endsWith('/')
? `${this.repositoryUrl}manifest.json` ? `${this.repositoryUrl}manifest.json`
@ -760,26 +760,26 @@ class LocalScraperService {
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`; const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`;
const response = await axios.get(manifestUrl, { const response = await axios.get(manifestUrl, {
timeout: 10000, timeout: 10000,
headers: { headers: {
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Expires': '0' 'Expires': '0'
} }
}); });
const manifest: ScraperManifest = response.data; const manifest: ScraperManifest = response.data;
// Store repository name from manifest // Store repository name from manifest
if (manifest.name) { if (manifest.name) {
this.repositoryName = manifest.name; this.repositoryName = manifest.name;
} }
// Return scrapers from manifest, respecting manifest's enabled field and platform compatibility // Return scrapers from manifest, respecting manifest's enabled field and platform compatibility
const availableScrapers = manifest.scrapers const availableScrapers = manifest.scrapers
.filter(scraperInfo => this.isPlatformCompatible(scraperInfo)) .filter(scraperInfo => this.isPlatformCompatible(scraperInfo))
.map(scraperInfo => { .map(scraperInfo => {
const installedScraper = this.installedScrapers.get(scraperInfo.id); const installedScraper = this.installedScrapers.get(scraperInfo.id);
// Create a copy with manifest data // Create a copy with manifest data
const scraperWithManifestData = { const scraperWithManifestData = {
...scraperInfo, ...scraperInfo,
@ -802,19 +802,19 @@ class LocalScraperService {
if (!anyScraper.supportedFormats && anyScraper.formats) { if (!anyScraper.supportedFormats && anyScraper.formats) {
anyScraper.supportedFormats = anyScraper.formats; anyScraper.supportedFormats = anyScraper.formats;
} }
return scraperWithManifestData; return scraperWithManifestData;
}); });
logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository'); logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository');
// Log final scraper states being returned to UI // Log final scraper states being returned to UI
availableScrapers.forEach(scraper => { availableScrapers.forEach(scraper => {
logger.log(`[LocalScraperService] Final scraper ${scraper.name}: manifestEnabled=${scraper.manifestEnabled}, enabled=${scraper.enabled}`); logger.log(`[LocalScraperService] Final scraper ${scraper.name}: manifestEnabled=${scraper.manifestEnabled}, enabled=${scraper.enabled}`);
}); });
return availableScrapers; return availableScrapers;
} catch (error) { } catch (error) {
logger.error('[LocalScraperService] Failed to fetch available scrapers from manifest:', error); logger.error('[LocalScraperService] Failed to fetch available scrapers from manifest:', error);
// Fallback to installed scrapers if manifest fetch fails // Fallback to installed scrapers if manifest fetch fails
@ -844,7 +844,7 @@ class LocalScraperService {
// Enable/disable scraper // Enable/disable scraper
async setScraperEnabled(scraperId: string, enabled: boolean): Promise<void> { async setScraperEnabled(scraperId: string, enabled: boolean): Promise<void> {
await this.ensureInitialized(); await this.ensureInitialized();
const scraper = this.installedScrapers.get(scraperId); const scraper = this.installedScrapers.get(scraperId);
if (scraper) { if (scraper) {
// Prevent enabling if manifest has disabled it or if platform-incompatible // Prevent enabling if manifest has disabled it or if platform-incompatible
@ -852,7 +852,7 @@ class LocalScraperService {
logger.log('[LocalScraperService] Cannot enable scraper', scraperId, '- disabled in manifest or platform-incompatible'); logger.log('[LocalScraperService] Cannot enable scraper', scraperId, '- disabled in manifest or platform-incompatible');
return; return;
} }
scraper.enabled = enabled; scraper.enabled = enabled;
this.installedScrapers.set(scraperId, scraper); this.installedScrapers.set(scraperId, scraper);
await this.saveInstalledScrapers(); await this.saveInstalledScrapers();
@ -920,7 +920,7 @@ class LocalScraperService {
if (enabledScrapers.length > 0) { if (enabledScrapers.length > 0) {
try { try {
logger.log('[LocalScraperService] Enabled scrapers:', enabledScrapers.map(s => s.name).join(', ')); logger.log('[LocalScraperService] Enabled scrapers:', enabledScrapers.map(s => s.name).join(', '));
} catch {} } catch { }
} }
if (enabledScrapers.length === 0) { if (enabledScrapers.length === 0) {
@ -983,7 +983,7 @@ class LocalScraperService {
promise.finally(() => { promise.finally(() => {
const current = this.inFlightByKey.get(flightKey); const current = this.inFlightByKey.get(flightKey);
if (current === promise) this.inFlightByKey.delete(flightKey); if (current === promise) this.inFlightByKey.delete(flightKey);
}).catch(() => {}); }).catch(() => { });
} }
const results = await promise; const results = await promise;
@ -1013,16 +1013,16 @@ class LocalScraperService {
const settingsData = await mmkvStorage.getItem('app_settings'); const settingsData = await mmkvStorage.getItem('app_settings');
const settings = settingsData ? JSON.parse(settingsData) : {}; const settings = settingsData ? JSON.parse(settingsData) : {};
const urlValidationEnabled = settings.enableScraperUrlValidation ?? true; const urlValidationEnabled = settings.enableScraperUrlValidation ?? true;
// Load per-scraper settings for this run // Load per-scraper settings for this run
const allScraperSettingsRaw = await mmkvStorage.getItem(this.SCRAPER_SETTINGS_KEY); const allScraperSettingsRaw = await mmkvStorage.getItem(this.SCRAPER_SETTINGS_KEY);
const allScraperSettings = allScraperSettingsRaw ? JSON.parse(allScraperSettingsRaw) : {}; const allScraperSettings = allScraperSettingsRaw ? JSON.parse(allScraperSettingsRaw) : {};
const perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId]) ? allScraperSettings[params.scraperId] : (params?.settings || {}); const perScraperSettings = (params && params.scraperId && allScraperSettings[params.scraperId]) ? allScraperSettings[params.scraperId] : (params?.settings || {});
// Create a limited global context // Create a limited global context
const moduleExports = {}; const moduleExports = {};
const moduleObj = { exports: moduleExports }; const moduleObj = { exports: moduleExports };
// Try to load cheerio-without-node-native // Try to load cheerio-without-node-native
let cheerio = null; let cheerio = null;
try { try {
@ -1034,14 +1034,14 @@ class LocalScraperService {
// Cheerio not available, scrapers will fall back to regex // Cheerio not available, scrapers will fall back to regex
} }
} }
// MovieBox constants - read from Expo public envs so they bundle in builds // MovieBox constants - read from Expo public envs so they bundle in builds
const MOVIEBOX_PRIMARY_KEY = process.env.EXPO_PUBLIC_MOVIEBOX_PRIMARY_KEY; const MOVIEBOX_PRIMARY_KEY = process.env.EXPO_PUBLIC_MOVIEBOX_PRIMARY_KEY;
const MOVIEBOX_TMDB_API_KEY = process.env.EXPO_PUBLIC_MOVIEBOX_TMDB_API_KEY || '439c478a771f35c05022f9feabcca01c'; const MOVIEBOX_TMDB_API_KEY = process.env.EXPO_PUBLIC_MOVIEBOX_TMDB_API_KEY || '439c478a771f35c05022f9feabcca01c';
if (!MOVIEBOX_PRIMARY_KEY) { if (!MOVIEBOX_PRIMARY_KEY) {
throw new Error('Missing EXPO_PUBLIC_MOVIEBOX_PRIMARY_KEY'); throw new Error('Missing EXPO_PUBLIC_MOVIEBOX_PRIMARY_KEY');
} }
const sandbox = { const sandbox = {
console: { console: {
log: (...args: any[]) => logger.log('[Scraper]', ...args), log: (...args: any[]) => logger.log('[Scraper]', ...args),
@ -1076,7 +1076,7 @@ class LocalScraperService {
// Add fetch for HTTP requests (using native fetch for MovieBox, axios for others) // Add fetch for HTTP requests (using native fetch for MovieBox, axios for others)
fetch: async (url: string, options: any = {}) => { fetch: async (url: string, options: any = {}) => {
const isMovieBoxRequest = url.includes('api.inmoviebox.com') || url.includes('themoviedb.org'); const isMovieBoxRequest = url.includes('api.inmoviebox.com') || url.includes('themoviedb.org');
if (isMovieBoxRequest) { if (isMovieBoxRequest) {
// Always use native fetch for MovieBox requests // Always use native fetch for MovieBox requests
try { try {
@ -1084,7 +1084,7 @@ class LocalScraperService {
method: options.method || 'GET', method: options.method || 'GET',
hasBody: !!options.body hasBody: !!options.body
}); });
const nativeResponse = await fetch(url, { const nativeResponse = await fetch(url, {
method: options.method || 'GET', method: options.method || 'GET',
headers: { headers: {
@ -1094,13 +1094,13 @@ class LocalScraperService {
}, },
body: options.body body: options.body
}); });
const responseData = await nativeResponse.text(); const responseData = await nativeResponse.text();
logger.log(`[Sandbox] Native fetch successful for MovieBox:`, { logger.log(`[Sandbox] Native fetch successful for MovieBox:`, {
status: nativeResponse.status, status: nativeResponse.status,
ok: nativeResponse.ok ok: nativeResponse.ok
}); });
return { return {
ok: nativeResponse.ok, ok: nativeResponse.ok,
status: nativeResponse.status, status: nativeResponse.status,
@ -1134,7 +1134,7 @@ class LocalScraperService {
timeout: 120000, // Increased to 2 minutes for complex scrapers timeout: 120000, // Increased to 2 minutes for complex scrapers
validateStatus: () => true // Don't throw on HTTP error status codes validateStatus: () => true // Don't throw on HTTP error status codes
}; };
try { try {
logger.log(`[Sandbox] Using axios for request: ${url}`, { logger.log(`[Sandbox] Using axios for request: ${url}`, {
method: axiosConfig.method, method: axiosConfig.method,
@ -1142,7 +1142,7 @@ class LocalScraperService {
hasBody: !!axiosConfig.data hasBody: !!axiosConfig.data
}); });
const response = await axios(axiosConfig); const response = await axios(axiosConfig);
return { return {
ok: response.status >= 200 && response.status < 300, ok: response.status >= 200 && response.status < 300,
status: response.status, status: response.status,
@ -1181,7 +1181,7 @@ class LocalScraperService {
SCRAPER_SETTINGS: perScraperSettings, SCRAPER_SETTINGS: perScraperSettings,
SCRAPER_ID: params?.scraperId SCRAPER_ID: params?.scraperId
}; };
// Execute the scraper code with 1 minute timeout // Execute the scraper code with 1 minute timeout
const SCRAPER_EXECUTION_TIMEOUT_MS = 60000; // 1 minute const SCRAPER_EXECUTION_TIMEOUT_MS = 60000; // 1 minute
@ -1237,7 +1237,7 @@ class LocalScraperService {
setTimeout(() => reject(new Error(`Scraper execution timed out after ${SCRAPER_EXECUTION_TIMEOUT_MS}ms`)), SCRAPER_EXECUTION_TIMEOUT_MS) setTimeout(() => reject(new Error(`Scraper execution timed out after ${SCRAPER_EXECUTION_TIMEOUT_MS}ms`)), SCRAPER_EXECUTION_TIMEOUT_MS)
) )
]); ]);
} catch (error) { } catch (error) {
logger.error('[LocalScraperService] Sandbox execution failed:', error); logger.error('[LocalScraperService] Sandbox execution failed:', error);
throw error; throw error;
@ -1250,22 +1250,22 @@ class LocalScraperService {
logger.warn('[LocalScraperService] Scraper returned non-array result'); logger.warn('[LocalScraperService] Scraper returned non-array result');
return []; return [];
} }
return results.map((result, index) => { return results.map((result, index) => {
// Build title with quality information for UI compatibility // Build title with quality information for UI compatibility
let title = result.title || result.name || `${scraper.name} Stream ${index + 1}`; let title = result.title || result.name || `${scraper.name} Stream ${index + 1}`;
// Add quality to title if available and not already present // Add quality to title if available and not already present
if (result.quality && !title.includes(result.quality)) { if (result.quality && !title.includes(result.quality)) {
title = `${title} ${result.quality}`; title = `${title} ${result.quality}`;
} }
// Build name with quality information // Build name with quality information
let streamName = result.name || `${scraper.name}`; let streamName = result.name || `${scraper.name}`;
if (result.quality && !streamName.includes(result.quality)) { if (result.quality && !streamName.includes(result.quality)) {
streamName = `${streamName} - ${result.quality}`; streamName = `${streamName} - ${result.quality}`;
} }
const stream: Stream = { const stream: Stream = {
// Include quality in name field for proper display // Include quality in name field for proper display
name: streamName, name: streamName,
@ -1280,22 +1280,22 @@ class LocalScraperService {
bingeGroup: `local-scraper-${scraper.id}` bingeGroup: `local-scraper-${scraper.id}`
} }
}; };
// Add additional properties if available // Add additional properties if available
if (result.infoHash) { if (result.infoHash) {
stream.infoHash = result.infoHash; stream.infoHash = result.infoHash;
} }
// Preserve any additional fields from the scraper result // Preserve any additional fields from the scraper result
if (result.quality && !stream.quality) { if (result.quality && !stream.quality) {
stream.quality = result.quality; stream.quality = result.quality;
} }
// Pass headers from scraper result if available // Pass headers from scraper result if available
if (result.headers) { if (result.headers) {
stream.headers = result.headers; stream.headers = result.headers;
} }
return stream; return stream;
}).filter(stream => stream.url); // Filter out streams without URLs }).filter(stream => stream.url); // Filter out streams without URLs
} }
@ -1303,13 +1303,13 @@ class LocalScraperService {
// Parse size string to bytes // Parse size string to bytes
private parseSize(sizeStr: string): number { private parseSize(sizeStr: string): number {
if (!sizeStr) return 0; if (!sizeStr) return 0;
const match = sizeStr.match(/([0-9.]+)\s*(GB|MB|KB|TB)/i); const match = sizeStr.match(/([0-9.]+)\s*(GB|MB|KB|TB)/i);
if (!match) return 0; if (!match) return 0;
const value = parseFloat(match[1]); const value = parseFloat(match[1]);
const unit = match[2].toUpperCase(); const unit = match[2].toUpperCase();
switch (unit) { switch (unit) {
case 'TB': return value * 1024 * 1024 * 1024 * 1024; case 'TB': return value * 1024 * 1024 * 1024 * 1024;
case 'GB': return value * 1024 * 1024 * 1024; case 'GB': return value * 1024 * 1024 * 1024;
@ -1323,22 +1323,22 @@ class LocalScraperService {
async clearScrapers(): Promise<void> { async clearScrapers(): Promise<void> {
this.installedScrapers.clear(); this.installedScrapers.clear();
this.scraperCode.clear(); this.scraperCode.clear();
// Clear from storage // Clear from storage
await mmkvStorage.removeItem(this.STORAGE_KEY); await mmkvStorage.removeItem(this.STORAGE_KEY);
// Clear cached code // Clear cached code
const keys = await mmkvStorage.getAllKeys(); const keys = await mmkvStorage.getAllKeys();
const scraperCodeKeys = keys.filter(key => key.startsWith('scraper-code-')); const scraperCodeKeys = keys.filter(key => key.startsWith('scraper-code-'));
await mmkvStorage.multiRemove(scraperCodeKeys); await mmkvStorage.multiRemove(scraperCodeKeys);
logger.log('[LocalScraperService] All scrapers cleared'); logger.log('[LocalScraperService] All scrapers cleared');
} }
// Check if local scrapers are available // Check if local scrapers are available
async hasScrapers(): Promise<boolean> { async hasScrapers(): Promise<boolean> {
await this.ensureInitialized(); await this.ensureInitialized();
// Get user settings to check if local scrapers are enabled // Get user settings to check if local scrapers are enabled
const userSettings = await this.getUserScraperSettings(); const userSettings = await this.getUserScraperSettings();
logger.log('[LocalScraperService.hasScrapers] enableLocalScrapers:', userSettings.enableLocalScrapers); logger.log('[LocalScraperService.hasScrapers] enableLocalScrapers:', userSettings.enableLocalScrapers);
@ -1346,13 +1346,13 @@ class LocalScraperService {
logger.log('[LocalScraperService.hasScrapers] Returning false: local scrapers disabled'); logger.log('[LocalScraperService.hasScrapers] Returning false: local scrapers disabled');
return false; return false;
} }
// If no repository is configured, return false // If no repository is configured, return false
if (!this.repositoryUrl) { if (!this.repositoryUrl) {
logger.log('[LocalScraperService.hasScrapers] Returning false: no repository URL configured'); logger.log('[LocalScraperService.hasScrapers] Returning false: no repository URL configured');
return false; return false;
} }
// If no scrapers are installed, try to refresh repository // If no scrapers are installed, try to refresh repository
if (this.installedScrapers.size === 0) { if (this.installedScrapers.size === 0) {
logger.log('[LocalScraperService.hasScrapers] No scrapers installed, attempting to refresh repository'); logger.log('[LocalScraperService.hasScrapers] No scrapers installed, attempting to refresh repository');
@ -1363,16 +1363,16 @@ class LocalScraperService {
return false; return false;
} }
} }
logger.log('[LocalScraperService.hasScrapers] installedScrapers.size:', this.installedScrapers.size); logger.log('[LocalScraperService.hasScrapers] installedScrapers.size:', this.installedScrapers.size);
logger.log('[LocalScraperService.hasScrapers] enabledScrapers set size:', userSettings.enabledScrapers?.size); logger.log('[LocalScraperService.hasScrapers] enabledScrapers set size:', userSettings.enabledScrapers?.size);
// Check if there are any enabled scrapers based on user settings // Check if there are any enabled scrapers based on user settings
if (userSettings.enabledScrapers && userSettings.enabledScrapers.size > 0) { if (userSettings.enabledScrapers && userSettings.enabledScrapers.size > 0) {
logger.log('[LocalScraperService.hasScrapers] Returning true: enabledScrapers set has items'); logger.log('[LocalScraperService.hasScrapers] Returning true: enabledScrapers set has items');
return true; return true;
} }
// Fallback: check if any scrapers are enabled in the internal state // Fallback: check if any scrapers are enabled in the internal state
const hasEnabledScrapers = Array.from(this.installedScrapers.values()).some(scraper => scraper.enabled); const hasEnabledScrapers = Array.from(this.installedScrapers.values()).some(scraper => scraper.enabled);
logger.log('[LocalScraperService.hasScrapers] Fallback check - hasEnabledScrapers:', hasEnabledScrapers); logger.log('[LocalScraperService.hasScrapers] Fallback check - hasEnabledScrapers:', hasEnabledScrapers);