diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index bb35e48..02eaadd 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -21,7 +21,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; import { useSettings } from '../hooks/useSettings'; -import { localScraperService, ScraperInfo } from '../services/localScraperService'; +import { localScraperService, ScraperInfo, RepositoryInfo } from '../services/localScraperService'; import { logger } from '../utils/logger'; import { useTheme } from '../contexts/ThemeContext'; @@ -189,28 +189,34 @@ const createStyles = (colors: any) => StyleSheet.create({ fontSize: 15, }, button: { - backgroundColor: colors.elevation2, + backgroundColor: 'transparent', paddingVertical: 12, - paddingHorizontal: 16, + paddingHorizontal: 20, borderRadius: 8, - marginRight: 8, + borderWidth: 1, + borderColor: colors.elevation3, + alignItems: 'center', + justifyContent: 'center', + minHeight: 44, }, primaryButton: { backgroundColor: colors.primary, + borderColor: colors.primary, }, secondaryButton: { - backgroundColor: colors.elevation2, + backgroundColor: 'transparent', + borderColor: colors.elevation3, }, buttonText: { - fontSize: 16, - fontWeight: '600', + fontSize: 15, + fontWeight: '500', color: colors.white, textAlign: 'center', }, secondaryButtonText: { - fontSize: 16, - fontWeight: '600', - color: colors.mediumGray, + fontSize: 15, + fontWeight: '500', + color: colors.white, textAlign: 'center', }, clearButton: { @@ -264,6 +270,7 @@ const createStyles = (colors: any) => StyleSheet.create({ buttonRow: { flexDirection: 'row', gap: 12, + marginTop: 8, }, infoText: { fontSize: 14, @@ -440,35 +447,40 @@ const createStyles = (colors: any) => StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 16, - gap: 8, + gap: 12, }, bulkActionButton: { flex: 1, - paddingVertical: 10, + paddingVertical: 12, paddingHorizontal: 16, borderRadius: 8, alignItems: 'center', + justifyContent: 'center', + minHeight: 44, + borderWidth: 1, }, bulkActionButtonEnabled: { - backgroundColor: '#34C759', + backgroundColor: 'transparent', + borderColor: '#34C759', }, bulkActionButtonDisabled: { - backgroundColor: colors.elevation2, - borderWidth: 1, + backgroundColor: 'transparent', borderColor: colors.elevation3, }, bulkActionButtonText: { color: colors.white, fontSize: 14, - fontWeight: '600', + fontWeight: '500', }, helpButton: { position: 'absolute', top: Platform.OS === 'ios' ? 44 : ANDROID_STATUSBAR_HEIGHT + 16, right: 16, - backgroundColor: colors.elevation2, + backgroundColor: 'transparent', borderRadius: 20, padding: 8, + borderWidth: 1, + borderColor: colors.elevation3, }, modalOverlay: { flex: 1, @@ -498,16 +510,18 @@ const createStyles = (colors: any) => StyleSheet.create({ }, modalButton: { backgroundColor: colors.primary, - paddingVertical: 12, + paddingVertical: 14, paddingHorizontal: 24, borderRadius: 8, alignItems: 'center', + justifyContent: 'center', marginTop: 16, + minHeight: 48, }, modalButtonText: { color: colors.white, fontSize: 16, - fontWeight: '600', + fontWeight: '500', }, quickSetupContainer: { backgroundColor: colors.elevation2, @@ -531,15 +545,17 @@ const createStyles = (colors: any) => StyleSheet.create({ }, quickSetupButton: { backgroundColor: colors.primary, - paddingVertical: 10, - paddingHorizontal: 16, + paddingVertical: 12, + paddingHorizontal: 20, borderRadius: 8, alignItems: 'center', + justifyContent: 'center', + minHeight: 44, }, quickSetupButtonText: { color: colors.white, - fontSize: 14, - fontWeight: '600', + fontSize: 15, + fontWeight: '500', }, scraperCard: { backgroundColor: colors.elevation2, @@ -581,6 +597,75 @@ const createStyles = (colors: any) => StyleSheet.create({ emptyStateIcon: { marginBottom: 16, }, + // Repository management styles + repositoriesList: { + marginBottom: 16, + }, + repositoryItem: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 16, + marginBottom: 12, + borderWidth: 1, + borderColor: colors.elevation3, + }, + repositoryInfo: { + flex: 1, + marginBottom: 12, + }, + repositoryName: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + marginRight: 8, + }, + repositoryDescription: { + fontSize: 14, + color: colors.mediumGray, + marginBottom: 4, + lineHeight: 18, + }, + repositoryUrl: { + fontSize: 12, + color: colors.mediumGray, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + marginBottom: 4, + }, + repositoryMeta: { + fontSize: 12, + color: colors.mediumGray, + }, + repositoryActions: { + flexDirection: 'row', + gap: 12, + marginTop: 8, + }, + repositoryActionButton: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 6, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + minHeight: 36, + }, + repositoryActionButtonPrimary: { + backgroundColor: 'transparent', + borderColor: colors.primary, + }, + repositoryActionButtonSecondary: { + backgroundColor: 'transparent', + borderColor: colors.elevation3, + }, + repositoryActionButtonDanger: { + backgroundColor: 'transparent', + borderColor: '#ff3b30', + }, + repositoryActionButtonText: { + fontSize: 13, + fontWeight: '500', + color: colors.white, + }, }); // Helper component for collapsible sections @@ -660,6 +745,16 @@ const PluginsScreen: React.FC = () => { const [showboxCookie, setShowboxCookie] = useState(''); const [showboxRegion, setShowboxRegion] = useState(''); + // Multiple repositories state + const [repositories, setRepositories] = useState([]); + const [currentRepositoryId, setCurrentRepositoryId] = useState(''); + const [showAddRepositoryModal, setShowAddRepositoryModal] = useState(false); + const [newRepositoryUrl, setNewRepositoryUrl] = useState(''); + const [newRepositoryName, setNewRepositoryName] = useState(''); + const [newRepositoryDescription, setNewRepositoryDescription] = useState(''); + const [switchingRepository, setSwitchingRepository] = useState(null); + const [fetchingRepoName, setFetchingRepoName] = useState(false); + // New UX state const [searchQuery, setSearchQuery] = useState(''); const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all'); @@ -742,9 +837,123 @@ const PluginsScreen: React.FC = () => { } }; + const handleUrlChange = async (url: string) => { + setNewRepositoryUrl(url); + // Auto-populate repository name if it's empty and URL is valid + if (!newRepositoryName.trim() && url.trim()) { + setFetchingRepoName(true); + try { + // Try to fetch name from manifest first + const manifestName = await localScraperService.fetchRepositoryNameFromManifest(url.trim()); + setNewRepositoryName(manifestName); + } catch (error) { + // Fallback to URL extraction if manifest fetch fails + try { + const extractedName = localScraperService.extractRepositoryName(url.trim()); + if (extractedName !== 'Unknown Repository') { + setNewRepositoryName(extractedName); + } + } catch (extractError) { + // Ignore errors, just don't auto-populate + } + } finally { + setFetchingRepoName(false); + } + } + }; + + const handleAddRepository = async () => { + if (!newRepositoryUrl.trim()) { + Alert.alert('Error', 'Please enter a valid repository URL'); + return; + } + + // Validate URL format + const url = newRepositoryUrl.trim(); + if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { + Alert.alert( + 'Invalid URL Format', + 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/branch/\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/main/' + ); + return; + } + + try { + setIsLoading(true); + const repoId = await localScraperService.addRepository({ + name: newRepositoryName.trim(), // Let the service fetch from manifest if empty + url, + description: newRepositoryDescription.trim(), + enabled: true + }); + + await loadRepositories(); + + // Switch to the new repository and refresh it + await localScraperService.setCurrentRepository(repoId); + await loadRepositories(); + await loadScrapers(); + + setNewRepositoryUrl(''); + setNewRepositoryName(''); + setNewRepositoryDescription(''); + setFetchingRepoName(false); + setShowAddRepositoryModal(false); + Alert.alert('Success', 'Repository added and refreshed successfully'); + } catch (error) { + logger.error('[ScraperSettings] Failed to add repository:', error); + Alert.alert('Error', 'Failed to add repository'); + } finally { + setIsLoading(false); + } + }; + + const handleSwitchRepository = async (repoId: string) => { + try { + setSwitchingRepository(repoId); + await localScraperService.setCurrentRepository(repoId); + await loadRepositories(); + await loadScrapers(); + Alert.alert('Success', 'Repository switched successfully'); + } catch (error) { + logger.error('[ScraperSettings] Failed to switch repository:', error); + Alert.alert('Error', 'Failed to switch repository'); + } finally { + setSwitchingRepository(null); + } + }; + + const handleRemoveRepository = async (repoId: string) => { + const repo = repositories.find(r => r.id === repoId); + if (!repo) return; + + Alert.alert( + 'Remove Repository', + `Are you sure you want to remove "${repo.name}"? This will also remove all scrapers from this repository.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: async () => { + try { + await localScraperService.removeRepository(repoId); + await loadRepositories(); + await loadScrapers(); + Alert.alert('Success', 'Repository removed successfully'); + } catch (error) { + logger.error('[ScraperSettings] Failed to remove repository:', error); + Alert.alert('Error', error instanceof Error ? error.message : 'Failed to remove repository'); + } + }, + }, + ] + ); + }; + useEffect(() => { loadScrapers(); - checkRepository(); + loadRepositories(); }, []); const loadScrapers = async () => { @@ -763,6 +972,27 @@ const PluginsScreen: React.FC = () => { } }; + const loadRepositories = async () => { + try { + // First refresh repository names from manifests for existing repositories + await localScraperService.refreshRepositoryNamesFromManifests(); + + const repos = await localScraperService.getRepositories(); + setRepositories(repos); + setHasRepository(repos.length > 0); + + const currentRepoId = localScraperService.getCurrentRepositoryId(); + setCurrentRepositoryId(currentRepoId); + + const currentRepo = repos.find(r => r.id === currentRepoId); + if (currentRepo) { + setRepositoryUrl(currentRepo.url); + } + } catch (error) { + logger.error('[ScraperSettings] Failed to load repositories:', error); + } + }; + const checkRepository = async () => { try { const repoUrl = await localScraperService.getRepositoryUrl(); @@ -1024,10 +1254,11 @@ const PluginsScreen: React.FC = () => { styles={styles} > - Enter the URL of a Nuvio scraper repository to download and install scrapers. + Manage multiple scraper repositories. Switch between repositories to access different sets of scrapers. - {hasRepository && repositoryUrl && ( + {/* Current Repository */} + {currentRepositoryId && ( Current Repository: {localScraperService.getRepositoryName()} @@ -1035,58 +1266,84 @@ const PluginsScreen: React.FC = () => { )} - - - - Use GitHub raw URL format. Default: https://raw.githubusercontent.com/tapframe/nuvio-providers/main + {/* Repository List */} + {repositories.length > 0 && ( + + Available Repositories + {repositories.map((repo) => ( + + + + {repo.name} + {repo.id === currentRepositoryId && ( + + + Active + + )} + {switchingRepository === repo.id && ( + + + Switching... + + )} + + {repo.description && ( + {repo.description} + )} + {repo.url} + + {repo.scraperCount || 0} scrapers • Last updated: {repo.lastUpdated ? new Date(repo.lastUpdated).toLocaleDateString() : 'Never'} - - - Use Default Repository - + + {repo.id !== currentRepositoryId && ( + handleSwitchRepository(repo.id)} + disabled={switchingRepository === repo.id} + > + {switchingRepository === repo.id ? ( + + ) : ( + Switch + )} + + )} + handleRefreshRepository()} + disabled={isRefreshing || switchingRepository !== null} + > + {isRefreshing ? ( + + ) : ( + Refresh + )} + + {repositories.length > 1 && ( + handleRemoveRepository(repo.id)} + disabled={switchingRepository !== null} + > + Remove + + )} + + + ))} + + )} - - - {isLoading ? ( - - ) : ( - Save Repository - )} - - - {hasRepository && ( - - {isRefreshing ? ( - - ) : ( - Refresh - )} - - )} - + {/* Add Repository Button */} + setShowAddRepositoryModal(true)} + disabled={!settings.enableLocalScrapers || switchingRepository !== null} + > + Add New Repository + {/* Available Scrapers */} @@ -1112,9 +1369,9 @@ const PluginsScreen: React.FC = () => { {searchQuery.length > 0 && ( setSearchQuery('')}> - - )} - + + )} + {/* Filter Chips */} @@ -1132,7 +1389,7 @@ const PluginsScreen: React.FC = () => { selectedFilter === filter && styles.filterChipTextSelected ]}> {filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'} - + ))} @@ -1145,14 +1402,14 @@ const PluginsScreen: React.FC = () => { onPress={() => handleBulkToggle(true)} disabled={isRefreshing} > - Enable All + Enable All handleBulkToggle(false)} disabled={isRefreshing} > - Disable All + Disable All )} @@ -1169,7 +1426,7 @@ const PluginsScreen: React.FC = () => { /> {searchQuery ? 'No Scrapers Found' : 'No Scrapers Available'} - + {searchQuery ? `No scrapers match "${searchQuery}". Try a different search term.` @@ -1178,42 +1435,42 @@ const PluginsScreen: React.FC = () => { {searchQuery && ( setSearchQuery('')} > - Clear Search + Clear Search )} - - ) : ( - + + ) : ( + {filteredScrapers.map((scraper) => ( - {scraper.logo ? ( - - ) : ( + resizeMode="contain" + /> + ) : ( )} {scraper.name} - + {scraper.description} - - 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'))} - /> - + + 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'))} + /> + @@ -1237,64 +1494,64 @@ const PluginsScreen: React.FC = () => { {/* ShowBox Settings */} - {scraper.id === 'showboxog' && settings.enableLocalScrapers && ( + {scraper.id === 'showboxog' && settings.enableLocalScrapers && ( - ShowBox Cookie - - Region - - {regionOptions.map(opt => { - const selected = showboxRegion === opt.value; - return ( - setShowboxRegion(opt.value)} - > - - {opt.label} - - - ); - })} - - - { - await localScraperService.setScraperSettings('showboxog', { cookie: showboxCookie, region: showboxRegion }); - Alert.alert('Saved', 'ShowBox settings updated'); - }} - > - Save - - { - setShowboxCookie(''); - setShowboxRegion(''); - await localScraperService.setScraperSettings('showboxog', {}); - }} - > - Clear - - - - )} - + ShowBox Cookie + + Region + + {regionOptions.map(opt => { + const selected = showboxRegion === opt.value; + return ( + setShowboxRegion(opt.value)} + > + + {opt.label} + + + ); + })} + + + { + await localScraperService.setScraperSettings('showboxog', { cookie: showboxCookie, region: showboxRegion }); + Alert.alert('Saved', 'ShowBox settings updated'); + }} + > + Save + + { + setShowboxCookie(''); + setShowboxRegion(''); + await localScraperService.setScraperSettings('showboxog', {}); + }} + > + Clear + + + + )} + ))} - - )} + + )} {/* Additional Settings */} @@ -1445,7 +1702,7 @@ const PluginsScreen: React.FC = () => { 1. Enable Local Scrapers - Turn on the main switch to allow plugins - 2. Set Repository URL - Enter a GitHub raw URL or use the default repository + 2. Add Repository - Add a GitHub raw URL or use the default repository 3. Refresh Repository - Download available scrapers from the repository @@ -1462,6 +1719,85 @@ const PluginsScreen: React.FC = () => { + + {/* Add Repository Modal */} + setShowAddRepositoryModal(false)} + > + + + Add New Repository + + + Repository Name + {fetchingRepoName && ( + + )} + + + + Repository URL + + + Description (Optional) + + + + { + setShowAddRepositoryModal(false); + setNewRepositoryUrl(''); + setNewRepositoryName(''); + setNewRepositoryDescription(''); + setFetchingRepoName(false); + }} + > + Cancel + + + + {isLoading ? ( + + ) : ( + Add Repository + )} + + + + + ); }; diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts index 5988ae4..d9b460c 100644 --- a/src/services/localScraperService.ts +++ b/src/services/localScraperService.ts @@ -31,6 +31,18 @@ export interface ScraperInfo { // We support both `formats` and `supportedFormats` keys for manifest flexibility. formats?: string[]; supportedFormats?: string[]; + repositoryId?: string; // Which repository this scraper came from +} + +export interface RepositoryInfo { + id: string; + name: string; + url: string; + description?: string; + isDefault?: boolean; + enabled: boolean; + lastUpdated?: number; + scraperCount?: number; } export interface LocalScraperResult { @@ -55,12 +67,17 @@ class LocalScraperService { private static instance: LocalScraperService; private readonly STORAGE_KEY = 'local-scrapers'; private readonly REPOSITORY_KEY = 'scraper-repository-url'; + private readonly REPOSITORIES_KEY = 'scraper-repositories'; private readonly SCRAPER_SETTINGS_KEY = 'scraper-settings'; private installedScrapers: Map = new Map(); private scraperCode: Map = new Map(); + private repositories: Map = new Map(); + private currentRepositoryId: string = ''; private repositoryUrl: string = ''; private repositoryName: string = ''; private initialized: boolean = false; + private autoRefreshCompleted: boolean = false; + private isRefreshing: boolean = false; private scraperSettingsCache: Record | null = null; private constructor() { @@ -78,10 +95,43 @@ class LocalScraperService { if (this.initialized) return; try { - // Load repository URL - const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY); - if (storedRepoUrl) { - this.repositoryUrl = storedRepoUrl; + // Load repositories + const repositoriesData = await AsyncStorage.getItem(this.REPOSITORIES_KEY); + if (repositoriesData) { + const repos = JSON.parse(repositoriesData); + this.repositories = new Map(Object.entries(repos)); + } else { + // Migrate from old single repository format + const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY); + if (storedRepoUrl) { + const defaultRepo: RepositoryInfo = { + id: 'default', + name: this.extractRepositoryName(storedRepoUrl), + url: storedRepoUrl, + description: 'Default repository', + isDefault: true, + enabled: true, + lastUpdated: Date.now() + }; + this.repositories.set('default', defaultRepo); + this.currentRepositoryId = 'default'; + await this.saveRepositories(); + } + } + + // Load current repository + const currentRepoId = await AsyncStorage.getItem('current-repository-id'); + if (currentRepoId && this.repositories.has(currentRepoId)) { + this.currentRepositoryId = currentRepoId; + const currentRepo = this.repositories.get(currentRepoId)!; + this.repositoryUrl = currentRepo.url; + this.repositoryName = currentRepo.name; + } else if (this.repositories.size > 0) { + // Use first repository as default + const firstRepo = Array.from(this.repositories.values())[0]; + this.currentRepositoryId = firstRepo.id; + this.repositoryUrl = firstRepo.url; + this.repositoryName = firstRepo.name; } // Load installed scrapers @@ -156,14 +206,16 @@ class LocalScraperService { // Load scraper code from cache await this.loadScraperCode(); - // Auto-refresh repository on app startup if URL is configured - if (this.repositoryUrl) { + // Auto-refresh repository on app startup if URL is configured (only once) + if (this.repositoryUrl && !this.autoRefreshCompleted) { try { logger.log('[LocalScraperService] Auto-refreshing repository on startup'); await this.performRepositoryRefresh(); + this.autoRefreshCompleted = true; } catch (error) { logger.error('[LocalScraperService] Auto-refresh failed on startup:', error); // Don't fail initialization if auto-refresh fails + this.autoRefreshCompleted = true; // Mark as completed even on error to prevent retries } } @@ -199,6 +251,199 @@ class LocalScraperService { return this.repositoryName || 'Plugins'; } + // Multiple repository management methods + async getRepositories(): Promise { + await this.ensureInitialized(); + return Array.from(this.repositories.values()); + } + + async addRepository(repo: Omit): Promise { + await this.ensureInitialized(); + const id = `repo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Try to fetch the repository name from manifest if not provided + let repositoryName = repo.name; + if (!repositoryName || repositoryName.trim() === '') { + try { + repositoryName = await this.fetchRepositoryNameFromManifest(repo.url); + } catch (error) { + logger.warn('[LocalScraperService] Failed to fetch repository name from manifest, using fallback:', error); + repositoryName = this.extractRepositoryName(repo.url); + } + } + + const newRepo: RepositoryInfo = { + ...repo, + name: repositoryName, + id, + lastUpdated: Date.now(), + scraperCount: 0 + }; + this.repositories.set(id, newRepo); + await this.saveRepositories(); + logger.log('[LocalScraperService] Added repository:', newRepo.name); + return id; + } + + async updateRepository(id: string, updates: Partial): Promise { + await this.ensureInitialized(); + const repo = this.repositories.get(id); + if (!repo) { + throw new Error(`Repository with id ${id} not found`); + } + const updatedRepo = { ...repo, ...updates }; + this.repositories.set(id, updatedRepo); + await this.saveRepositories(); + + // If this is the current repository, update current values + if (id === this.currentRepositoryId) { + this.repositoryUrl = updatedRepo.url; + this.repositoryName = updatedRepo.name; + } + logger.log('[LocalScraperService] Updated repository:', updatedRepo.name); + } + + async removeRepository(id: string): Promise { + await this.ensureInitialized(); + if (!this.repositories.has(id)) { + throw new Error(`Repository with id ${id} not found`); + } + + // Don't allow removing the last repository + if (this.repositories.size <= 1) { + throw new Error('Cannot remove the last repository'); + } + + // If removing current repository, switch to another one + if (id === this.currentRepositoryId) { + const remainingRepos = Array.from(this.repositories.values()).filter(r => r.id !== id); + if (remainingRepos.length > 0) { + await this.setCurrentRepository(remainingRepos[0].id); + } + } + + // Remove scrapers from this repository + const scrapersToRemove = Array.from(this.installedScrapers.values()) + .filter(s => s.repositoryId === id) + .map(s => s.id); + + for (const scraperId of scrapersToRemove) { + this.installedScrapers.delete(scraperId); + this.scraperCode.delete(scraperId); + await AsyncStorage.removeItem(`scraper-code-${scraperId}`); + } + + this.repositories.delete(id); + await this.saveRepositories(); + await this.saveInstalledScrapers(); + logger.log('[LocalScraperService] Removed repository:', id); + } + + async setCurrentRepository(id: string): Promise { + await this.ensureInitialized(); + const repo = this.repositories.get(id); + if (!repo) { + throw new Error(`Repository with id ${id} not found`); + } + + this.currentRepositoryId = id; + this.repositoryUrl = repo.url; + this.repositoryName = repo.name; + + await AsyncStorage.setItem('current-repository-id', id); + + // Refresh the repository to get its scrapers + try { + logger.log('[LocalScraperService] Refreshing repository after switch:', repo.name); + await this.performRepositoryRefresh(); + } catch (error) { + logger.error('[LocalScraperService] Failed to refresh repository after switch:', error); + // Don't throw error, just log it - the switch should still succeed + } + + logger.log('[LocalScraperService] Switched to repository:', repo.name); + } + + getCurrentRepositoryId(): string { + return this.currentRepositoryId; + } + + // Public method to extract repository name from URL + extractRepositoryName(url: string): string { + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(part => part.length > 0); + if (pathParts.length >= 2) { + return `${pathParts[0]}/${pathParts[1]}`; + } + return urlObj.hostname || 'Unknown Repository'; + } catch { + return 'Unknown Repository'; + } + } + + // Fetch repository name from manifest.json + async fetchRepositoryNameFromManifest(repositoryUrl: string): Promise { + try { + logger.log('[LocalScraperService] Fetching repository name from manifest:', repositoryUrl); + + // Construct manifest URL + const baseManifestUrl = repositoryUrl.endsWith('/') + ? `${repositoryUrl}manifest.json` + : `${repositoryUrl}/manifest.json`; + const manifestUrl = `${baseManifestUrl}?t=${Date.now()}`; + + const response = await axios.get(manifestUrl, { + timeout: 10000, + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + } + }); + + if (response.data && response.data.name) { + logger.log('[LocalScraperService] Found repository name in manifest:', response.data.name); + return response.data.name; + } else { + logger.warn('[LocalScraperService] No name found in manifest, using fallback'); + return this.extractRepositoryName(repositoryUrl); + } + } catch (error) { + logger.error('[LocalScraperService] Failed to fetch repository name from manifest:', error); + throw error; + } + } + + // Update repository name from manifest for existing repositories + async refreshRepositoryNamesFromManifests(): Promise { + await this.ensureInitialized(); + + for (const [id, repo] of this.repositories) { + try { + const manifestName = await this.fetchRepositoryNameFromManifest(repo.url); + if (manifestName !== repo.name) { + logger.log('[LocalScraperService] Updating repository name:', repo.name, '->', manifestName); + repo.name = manifestName; + + // If this is the current repository, update the current name + if (id === this.currentRepositoryId) { + this.repositoryName = manifestName; + } + } + } catch (error) { + logger.warn('[LocalScraperService] Failed to refresh name for repository:', repo.name, error); + } + } + + await this.saveRepositories(); + } + + private async saveRepositories(): Promise { + const reposObject = Object.fromEntries(this.repositories); + await AsyncStorage.setItem(this.REPOSITORIES_KEY, JSON.stringify(reposObject)); + } + + // Check if a scraper is compatible with the current platform private isPlatformCompatible(scraper: ScraperInfo): boolean { const currentPlatform = Platform.OS as 'ios' | 'android'; @@ -223,6 +468,7 @@ class LocalScraperService { async refreshRepository(): Promise { await this.ensureInitialized(); await this.performRepositoryRefresh(); + this.autoRefreshCompleted = true; // Mark as completed after manual refresh } // Internal method to refresh repository without initialization check @@ -231,6 +477,14 @@ class LocalScraperService { throw new Error('No repository URL configured'); } + // Prevent multiple simultaneous refreshes + if (this.isRefreshing) { + logger.log('[LocalScraperService] Repository refresh already in progress, skipping'); + return; + } + + this.isRefreshing = true; + try { logger.log('[LocalScraperService] Fetching repository manifest from:', this.repositoryUrl); @@ -285,8 +539,10 @@ class LocalScraperService { const isPlatformCompatible = this.isPlatformCompatible(scraperInfo); if (isPlatformCompatible) { + // Add repository ID to scraper info + const scraperWithRepo = { ...scraperInfo, repositoryId: this.currentRepositoryId }; // Download/update the scraper (downloadScraper handles force disabling based on manifest.enabled) - await this.downloadScraper(scraperInfo); + await this.downloadScraper(scraperWithRepo); } else { logger.log('[LocalScraperService] Skipping platform-incompatible scraper:', scraperInfo.name); // Remove if it was previously installed but is now platform-incompatible @@ -300,11 +556,25 @@ class LocalScraperService { } await this.saveInstalledScrapers(); + + // Update repository info + const currentRepo = this.repositories.get(this.currentRepositoryId); + if (currentRepo) { + const scraperCount = Array.from(this.installedScrapers.values()) + .filter(s => s.repositoryId === this.currentRepositoryId).length; + await this.updateRepository(this.currentRepositoryId, { + lastUpdated: Date.now(), + scraperCount + }); + } + logger.log('[LocalScraperService] Repository refresh completed'); } catch (error) { logger.error('[LocalScraperService] Failed to refresh repository:', error); throw error; + } finally { + this.isRefreshing = false; } }