multi plugin support

This commit is contained in:
tapframe 2025-12-31 19:50:08 +05:30
parent af96d30122
commit 3293b57537
3 changed files with 596 additions and 250 deletions

View file

@ -440,6 +440,45 @@ const createStyles = (colors: any) => StyleSheet.create({
color: colors.white,
fontWeight: '600',
},
// Repository tabs
repositoryTabsContainer: {
marginBottom: 16,
},
repositoryTabsScroll: {
flexDirection: 'row',
gap: 8,
},
repositoryTab: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: colors.elevation2,
borderWidth: 1,
borderColor: colors.elevation3,
minWidth: 80,
alignItems: 'center',
},
repositoryTabSelected: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
repositoryTabText: {
color: colors.mediumGray,
fontSize: 14,
fontWeight: '500',
},
repositoryTabTextSelected: {
color: colors.white,
fontWeight: '600',
},
repositoryTabCount: {
fontSize: 12,
color: colors.mediumGray,
marginTop: 2,
},
repositoryTabCountSelected: {
color: 'rgba(255, 255, 255, 0.8)',
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
@ -761,6 +800,29 @@ const createStyles = (colors: any) => StyleSheet.create({
fontWeight: '500',
color: colors.white,
},
repositoryHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
},
repositoryNameContainer: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
pluginRepositoryBadge: {
backgroundColor: colors.elevation3,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
marginRight: 8,
},
pluginRepositoryBadgeText: {
fontSize: 10,
color: colors.mediumGray,
fontWeight: '500',
},
});
// Helper component for collapsible sections
@ -881,6 +943,7 @@ const PluginsScreen: React.FC = () => {
// New UX state
const [searchQuery, setSearchQuery] = useState('');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'movie' | 'tv'>('all');
const [selectedRepositoryTab, setSelectedRepositoryTab] = useState<string>('all'); // 'all' or repository ID
const [expandedSections, setExpandedSections] = useState({
repository: true,
plugins: true,
@ -904,10 +967,20 @@ const PluginsScreen: React.FC = () => {
{ value: 'SZ', label: 'China' },
];
// Filtered plugins based on search and filter
// Get enabled repositories for tabs
const enabledRepositories = useMemo(() => {
return repositories.filter(r => r.enabled !== false);
}, [repositories]);
// Filtered plugins based on search, type filter, and repository tab
const filteredPlugins = useMemo(() => {
let filtered = installedPlugins;
// Filter by repository tab
if (selectedRepositoryTab !== 'all') {
filtered = filtered.filter(plugin => plugin.repositoryId === selectedRepositoryTab);
}
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
@ -926,7 +999,7 @@ const PluginsScreen: React.FC = () => {
}
return filtered;
}, [installedPlugins, searchQuery, selectedFilter]);
}, [installedPlugins, searchQuery, selectedFilter, selectedRepositoryTab]);
// Helper functions
const toggleSection = (section: keyof typeof expandedSections) => {
@ -1009,16 +1082,14 @@ const PluginsScreen: React.FC = () => {
enabled: true
});
await loadRepositories();
// Switch to the new repository and refresh it
await pluginService.setCurrentRepository(repoId);
// Refresh all enabled repositories to include the new one
await pluginService.refreshRepository();
await loadRepositories();
await loadPlugins();
setNewRepositoryUrl('');
setShowAddRepositoryModal(false);
openAlert('Success', 'Repository added and refreshed successfully');
openAlert('Success', 'Repository added and plugins loaded successfully');
} catch (error) {
logger.error('[PluginsScreen] Failed to add repository:', error);
openAlert('Error', 'Failed to add repository');
@ -1027,16 +1098,25 @@ const PluginsScreen: React.FC = () => {
}
};
const handleSwitchRepository = async (repoId: string) => {
const handleToggleRepositoryEnabled = async (repoId: string, enabled: boolean) => {
try {
setSwitchingRepository(repoId);
await pluginService.setCurrentRepository(repoId);
await pluginService.toggleRepositoryEnabled(repoId, enabled);
if (enabled) {
// When enabling, refresh just this repository to fetch its plugins
await pluginService.refreshSingleRepository(repoId);
}
// Reload the data
await loadRepositories();
await loadPlugins();
openAlert('Success', 'Repository switched successfully');
const repo = repositories.find(r => r.id === repoId);
openAlert('Success', `Repository "${repo?.name || 'Unknown'}" ${enabled ? 'enabled' : 'disabled'} successfully`);
} catch (error) {
logger.error('[PluginSettings] Failed to switch repository:', error);
openAlert('Error', 'Failed to switch repository');
logger.error('[PluginSettings] Failed to toggle repository:', error);
openAlert('Error', 'Failed to update repository');
} finally {
setSwitchingRepository(null);
}
@ -1441,40 +1521,43 @@ const PluginsScreen: React.FC = () => {
styles={styles}
>
<Text style={styles.sectionDescription}>
Manage multiple plugin repositories. Switch between repositories to access different sets of plugins.
Enable multiple repositories to combine plugins from different sources. Toggle each repository on or off below.
</Text>
{/* Current Repository */}
{currentRepositoryId && (
<View style={styles.currentRepoContainer}>
<Text style={styles.currentRepoLabel}>Current Repository:</Text>
<Text style={styles.currentRepoUrl}>{pluginService.getRepositoryName()}</Text>
<Text style={[styles.currentRepoUrl, { fontSize: 12, opacity: 0.7, marginTop: 4 }]}>{repositoryUrl}</Text>
</View>
)}
{/* Repository List */}
{repositories.length > 0 && (
<View style={styles.repositoriesList}>
<Text style={[styles.settingTitle, { marginBottom: 12 }]}>Available Repositories</Text>
<Text style={[styles.settingTitle, { marginBottom: 8 }]}>Your Repositories</Text>
<Text style={[styles.settingDescription, { marginBottom: 12 }]}>
Enable multiple repositories to combine plugins from different sources.
</Text>
{repositories.map((repo) => (
<View key={repo.id} style={styles.repositoryItem}>
<View style={styles.repositoryInfo}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<View key={repo.id} style={[styles.repositoryItem, repo.enabled === false && { opacity: 0.6 }]}>
<View style={styles.repositoryHeader}>
<View style={styles.repositoryNameContainer}>
<Text style={styles.repositoryName}>{repo.name}</Text>
{repo.id === currentRepositoryId && (
{repo.enabled !== false && (
<View style={[styles.statusBadge, { backgroundColor: '#34C759' }]}>
<Ionicons name="checkmark-circle" size={12} color="white" />
<Text style={styles.statusBadgeText}>Active</Text>
<Text style={styles.statusBadgeText}>Enabled</Text>
</View>
)}
{switchingRepository === repo.id && (
<View style={[styles.statusBadge, { backgroundColor: colors.primary }]}>
<ActivityIndicator size={12} color="white" />
<Text style={styles.statusBadgeText}>Switching...</Text>
<Text style={styles.statusBadgeText}>Updating...</Text>
</View>
)}
</View>
<Switch
value={repo.enabled !== false}
onValueChange={(enabled) => handleToggleRepositoryEnabled(repo.id, enabled)}
trackColor={{ false: colors.elevation3, true: colors.primary }}
thumbColor={repo.enabled !== false ? colors.white : '#f4f3f4'}
disabled={!settings.enableLocalScrapers || switchingRepository !== null}
/>
</View>
<View style={styles.repositoryInfo}>
{repo.description && (
<Text style={styles.repositoryDescription}>{repo.description}</Text>
)}
@ -1484,23 +1567,10 @@ const PluginsScreen: React.FC = () => {
</Text>
</View>
<View style={styles.repositoryActions}>
{repo.id !== currentRepositoryId && (
<TouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonPrimary]}
onPress={() => handleSwitchRepository(repo.id)}
disabled={switchingRepository === repo.id}
>
{switchingRepository === repo.id ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Text style={styles.repositoryActionButtonText}>Switch</Text>
)}
</TouchableOpacity>
)}
<TouchableOpacity
style={[styles.repositoryActionButton, styles.repositoryActionButtonSecondary]}
onPress={() => handleRefreshRepository()}
disabled={isRefreshing || switchingRepository !== null}
disabled={isRefreshing || switchingRepository !== null || repo.enabled === false}
>
{isRefreshing ? (
<ActivityIndicator size="small" color={colors.mediumGray} />
@ -1559,6 +1629,70 @@ const PluginsScreen: React.FC = () => {
)}
</View>
{/* Repository Tabs - only show if multiple repositories */}
{enabledRepositories.length > 1 && (
<View style={styles.repositoryTabsContainer}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.repositoryTabsScroll}
>
{/* All tab */}
<TouchableOpacity
style={[
styles.repositoryTab,
selectedRepositoryTab === 'all' && styles.repositoryTabSelected
]}
onPress={() => setSelectedRepositoryTab('all')}
>
<Text style={[
styles.repositoryTabText,
selectedRepositoryTab === 'all' && styles.repositoryTabTextSelected
]}>
All
</Text>
<Text style={[
styles.repositoryTabCount,
selectedRepositoryTab === 'all' && styles.repositoryTabCountSelected
]}>
{installedPlugins.length}
</Text>
</TouchableOpacity>
{/* Repository tabs */}
{enabledRepositories.map((repo) => {
const repoPluginCount = installedPlugins.filter(p => p.repositoryId === repo.id).length;
return (
<TouchableOpacity
key={repo.id}
style={[
styles.repositoryTab,
selectedRepositoryTab === repo.id && styles.repositoryTabSelected
]}
onPress={() => setSelectedRepositoryTab(repo.id)}
>
<Text
style={[
styles.repositoryTabText,
selectedRepositoryTab === repo.id && styles.repositoryTabTextSelected
]}
numberOfLines={1}
>
{repo.name}
</Text>
<Text style={[
styles.repositoryTabCount,
selectedRepositoryTab === repo.id && styles.repositoryTabCountSelected
]}>
{repoPluginCount}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
)}
{/* Filter Chips */}
<View style={styles.filterContainer}>
{['all', 'movie', 'tv'].map((filter) => (
@ -1574,7 +1708,7 @@ const PluginsScreen: React.FC = () => {
styles.filterChipText,
selectedFilter === filter && styles.filterChipTextSelected
]}>
{filter === 'all' ? 'All' : filter === 'movie' ? 'Movies' : 'TV Shows'}
{filter === 'all' ? 'All Types' : filter === 'movie' ? 'Movies' : 'TV Shows'}
</Text>
</TouchableOpacity>
))}
@ -1693,6 +1827,14 @@ const PluginsScreen: React.FC = () => {
</Text>
</View>
)}
{/* Repository badge */}
{plugin.repositoryId && repositories.length > 1 && (
<View style={styles.pluginRepositoryBadge}>
<Text style={styles.pluginRepositoryBadgeText}>
{repositories.find(r => r.id === plugin.repositoryId)?.name || 'Unknown'}
</Text>
</View>
)}
</View>
{/* ShowBox Settings - only visible when ShowBox plugin is available */}
@ -1782,7 +1924,7 @@ const PluginsScreen: React.FC = () => {
<View style={styles.settingInfo}>
<Text style={styles.settingTitle}>Group Plugin Streams</Text>
<Text style={styles.settingDescription}>
When enabled, all plugin streams are grouped under "{pluginService.getRepositoryName()}". When disabled, each plugin shows as a separate provider.
When enabled, plugin streams are grouped by repository. When disabled, each plugin shows as a separate provider.
</Text>
</View>
<Switch

View file

@ -588,7 +588,11 @@ export const useStreamsScreen = () => {
// Reset provider if no longer available
useEffect(() => {
const isSpecialFilter = selectedProvider === 'all' || selectedProvider === 'grouped-plugins';
const isSpecialFilter =
selectedProvider === 'all' ||
selectedProvider === 'grouped-plugins' ||
selectedProvider.startsWith('repo-');
if (isSpecialFilter) return;
const currentStreamsData = selectedEpisode ? episodeStreams : groupedStreams;
@ -753,8 +757,23 @@ export const useStreamsScreen = () => {
filterChips.push({ id: provider, name: installedAddon?.name || provider });
});
// Group plugins by repository
if (pluginProviders.length > 0) {
filterChips.push({ id: 'grouped-plugins', name: localScraperService.getRepositoryName() });
const repoMap = new Map<string, { id: string; name: string }>();
pluginProviders.forEach(providerId => {
const repoInfo = localScraperService.getScraperRepository(providerId);
if (repoInfo) {
if (!repoMap.has(repoInfo.id)) {
repoMap.set(repoInfo.id, { id: repoInfo.id, name: repoInfo.name });
}
}
});
// Add a chip for each repository that has plugins with streams
repoMap.forEach(repo => {
filterChips.push({ id: `repo-${repo.id}`, name: repo.name });
});
}
return filterChips;
@ -789,10 +808,26 @@ export const useStreamsScreen = () => {
const filteredEntries = Object.entries(streams).filter(([addonId]) => {
if (selectedProvider === 'all') return true;
// Handle repository-based filtering (repo-{repoId})
if (settings.streamDisplayMode === 'grouped' && selectedProvider && selectedProvider.startsWith('repo-')) {
const repoId = selectedProvider.replace('repo-', '');
if (!repoId) return false;
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
if (isInstalledAddon) return false; // Not a plugin
// Check if this plugin belongs to the selected repository
const repoInfo = localScraperService.getScraperRepository(addonId);
return !!(repoInfo && (repoInfo.id === repoId || repoInfo.id?.toLowerCase() === repoId?.toLowerCase()));
}
// Legacy: handle old grouped-plugins filter (fallback)
if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') {
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
return !isInstalledAddon;
}
return addonId === selectedProvider;
});
@ -847,12 +882,24 @@ export const useStreamsScreen = () => {
combinedStreams.push(...pluginStreams);
}
let sectionId = 'grouped-all';
let sectionTitle = 'Available Streams';
if (selectedProvider && selectedProvider.startsWith('repo-')) {
const repoId = selectedProvider.replace('repo-', '');
const repo = localScraperService.getRepository(repoId);
if (repo) {
sectionTitle = `Streams from ${repo.name}`;
sectionId = `grouped-${repoId}`;
}
}
if (combinedStreams.length === 0) return [];
return [
{
title: 'Available Streams',
addonId: 'grouped-all',
title: sectionTitle,
addonId: sectionId,
data: combinedStreams,
isEmptyDueToQualityFilter: false,
},

View file

@ -213,17 +213,15 @@ class LocalScraperService {
// Load scraper code from cache
await this.loadScraperCode();
// 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
}
// Auto-refresh ALL enabled repositories on app startup (non-blocking, in background)
const enabledRepos = Array.from(this.repositories.values()).filter(r => r.enabled !== false);
if (enabledRepos.length > 0 && !this.autoRefreshCompleted) {
this.autoRefreshCompleted = true; // Mark immediately to prevent duplicate calls
logger.log('[LocalScraperService] Scheduling background refresh of', enabledRepos.length, 'enabled repositories');
// Don't await - let it run in background so app loads fast
this.refreshAllEnabledRepositories().catch(error => {
logger.error('[LocalScraperService] Background auto-refresh failed:', error);
});
}
this.initialized = true;
@ -453,6 +451,39 @@ class LocalScraperService {
await mmkvStorage.setItem(this.REPOSITORIES_KEY, JSON.stringify(reposObject));
}
// Get a single repository
getRepository(id: string): RepositoryInfo | undefined {
return this.repositories.get(id);
}
// Get all enabled repositories (for multi-repo support)
async getEnabledRepositories(): Promise<RepositoryInfo[]> {
await this.ensureInitialized();
return Array.from(this.repositories.values()).filter(r => r.enabled !== false);
}
// Toggle a repository's enabled state (for multi-repo support)
async toggleRepositoryEnabled(id: string, enabled: boolean): Promise<void> {
await this.ensureInitialized();
const repo = this.repositories.get(id);
if (!repo) {
throw new Error(`Repository with id ${id} not found`);
}
repo.enabled = enabled;
this.repositories.set(id, repo);
await this.saveRepositories();
logger.log('[LocalScraperService] Toggled repository', repo.name, 'to', enabled ? 'enabled' : 'disabled');
}
// Get the repository info for a scraper
getScraperRepository(scraperId: string): RepositoryInfo | undefined {
const scraper = this.installedScrapers.get(scraperId);
if (!scraper?.repositoryId) return undefined;
return this.repositories.get(scraper.repositoryId);
}
// Check if a scraper is compatible with the current platform
private isPlatformCompatible(scraper: ScraperInfo): boolean {
@ -477,10 +508,103 @@ class LocalScraperService {
// Fetch and install scrapers from repository
async refreshRepository(): Promise<void> {
await this.ensureInitialized();
await this.performRepositoryRefresh();
await this.refreshAllEnabledRepositories();
this.autoRefreshCompleted = true; // Mark as completed after manual refresh
}
// Refresh ALL enabled repositories (for multi-repo support)
async refreshAllEnabledRepositories(): Promise<void> {
await this.ensureInitialized();
const enabledRepos = await this.getEnabledRepositories();
if (enabledRepos.length === 0) {
logger.log('[LocalScraperService] No enabled repositories to refresh');
// Clear all caches when no repositories are enabled
this.scraperCode.clear();
this.installedScrapers.clear();
this.inFlightByKey.clear();
this.scraperSettingsCache = null;
try {
cacheService.clearCache();
} catch (error) {
logger.warn('[LocalScraperService] Failed to clear cacheService:', error);
}
await this.saveInstalledScrapers();
return;
}
logger.log('[LocalScraperService] Refreshing', enabledRepos.length, 'enabled repositories...');
// IMPORTANT: Preserve user's enabled preferences before clearing
const previousEnabledStates = new Map<string, boolean>();
for (const [id, scraper] of this.installedScrapers) {
previousEnabledStates.set(id, scraper.enabled);
}
// Store it on the instance so downloadScraper can access it
(this as any)._previousEnabledStates = previousEnabledStates;
// Clear caches before refreshing all
this.scraperCode.clear();
this.installedScrapers.clear();
this.inFlightByKey.clear();
this.scraperSettingsCache = null;
try {
const allKeys = await mmkvStorage.getAllKeys();
const scraperCodeKeys = allKeys.filter(key => key.startsWith('scraper-code-'));
if (scraperCodeKeys.length > 0) {
await mmkvStorage.multiRemove(scraperCodeKeys);
logger.log('[LocalScraperService] Removed', scraperCodeKeys.length, 'cached scraper code entries');
}
} catch (error) {
logger.error('[LocalScraperService] Failed to clear cached scraper code:', error);
}
try {
cacheService.clearCache();
logger.log('[LocalScraperService] Cleared cacheService during refresh');
} catch (error) {
logger.warn('[LocalScraperService] Failed to clear cacheService:', error);
}
// Refresh all enabled repositories in PARALLEL for faster loading
logger.log('[LocalScraperService] Starting parallel refresh of', enabledRepos.length, 'repositories...');
const refreshResults = await Promise.allSettled(
enabledRepos.map(repo => this.refreshSingleRepository(repo.id))
);
// Log results
refreshResults.forEach((result, index) => {
const repo = enabledRepos[index];
if (result.status === 'fulfilled') {
logger.log('[LocalScraperService] Successfully refreshed repository:', repo.name);
} else {
logger.error('[LocalScraperService] Failed to refresh repository:', repo.name, result.reason);
}
});
await this.saveInstalledScrapers();
// Clean up the temporary preserved states
delete (this as any)._previousEnabledStates;
logger.log('[LocalScraperService] Finished refreshing all enabled repositories. Total scrapers:', this.installedScrapers.size);
}
// Refresh a single repository by ID (parallel-safe - no shared state mutation)
async refreshSingleRepository(repoId: string): Promise<void> {
await this.ensureInitialized();
const repo = this.repositories.get(repoId);
if (!repo) {
throw new Error(`Repository with id ${repoId} not found`);
}
// Directly call performSingleRepositoryRefresh - it handles everything with explicit repo object
await this.performSingleRepositoryRefresh(repo);
}
// Internal method to refresh repository without initialization check
private async performRepositoryRefresh(): Promise<void> {
if (!this.repositoryUrl) {
@ -599,12 +723,94 @@ class LocalScraperService {
}
}
// Download individual scraper
private async downloadScraper(scraperInfo: ScraperInfo): Promise<void> {
// Refresh a single repository without clearing others (for multi-repo support)
private async performSingleRepositoryRefresh(repo: RepositoryInfo): Promise<void> {
logger.log('[LocalScraperService] Fetching repository manifest from:', repo.url);
try {
const scraperUrl = this.repositoryUrl.endsWith('/')
? `${this.repositoryUrl}${scraperInfo.filename}`
: `${this.repositoryUrl}/${scraperInfo.filename}`;
// Fetch manifest with cache busting
const baseManifestUrl = repo.url.endsWith('/')
? `${repo.url}manifest.json`
: `${repo.url}/manifest.json`;
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`;
const response = await axios.get(manifestUrl, {
timeout: 10000,
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0'
}
});
const manifest: ScraperManifest = response.data;
// Update repository name from manifest
if (manifest.name) {
repo.name = manifest.name;
this.repositories.set(repo.id, repo);
}
logger.log('[LocalScraperService] Repository', repo.name, 'has', manifest.scrapers?.length || 0, 'scrapers');
// Get current manifest scraper IDs for this repository
const manifestScraperIds = new Set(manifest.scrapers.map(s => s.id));
// Remove scrapers from this repository that are no longer in its manifest
const currentScraperIds = Array.from(this.installedScrapers.keys());
for (const scraperId of currentScraperIds) {
const scraper = this.installedScrapers.get(scraperId);
if (scraper?.repositoryId === repo.id && !manifestScraperIds.has(scraperId)) {
logger.log('[LocalScraperService] Removing scraper no longer in manifest:', scraper.name);
this.installedScrapers.delete(scraperId);
this.scraperCode.delete(scraperId);
await mmkvStorage.removeItem(`scraper-code-${scraperId}`);
}
}
// Download and install each scraper from manifest
for (const scraperInfo of manifest.scrapers) {
const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
if (isPlatformCompatible) {
// Add repository ID to scraper info
const scraperWithRepo = { ...scraperInfo, repositoryId: repo.id };
// Download/update the scraper - pass repo.url explicitly for parallel-safe operation
await this.downloadScraper(scraperWithRepo, repo.url);
} else {
logger.log('[LocalScraperService] Skipping platform-incompatible scraper:', scraperInfo.name);
// Remove if it was previously installed but is now platform-incompatible
if (this.installedScrapers.has(scraperInfo.id)) {
this.installedScrapers.delete(scraperInfo.id);
this.scraperCode.delete(scraperInfo.id);
await mmkvStorage.removeItem(`scraper-code-${scraperInfo.id}`);
}
}
}
// Update repository info
const scraperCount = Array.from(this.installedScrapers.values())
.filter(s => s.repositoryId === repo.id).length;
await this.updateRepository(repo.id, {
lastUpdated: Date.now(),
scraperCount
});
logger.log('[LocalScraperService] Repository', repo.name, 'refresh completed with', scraperCount, 'scrapers');
} catch (error) {
logger.error('[LocalScraperService] Failed to refresh repository:', repo.name, error);
throw error;
}
}
// Download individual scraper (repositoryUrl passed explicitly for parallel-safe operation)
private async downloadScraper(scraperInfo: ScraperInfo, repositoryUrl?: string): Promise<void> {
try {
// Use passed repositoryUrl or fall back to this.repositoryUrl for backward compatibility
const repoUrl = repositoryUrl || this.repositoryUrl;
const scraperUrl = repoUrl.endsWith('/')
? `${repoUrl}${scraperInfo.filename}`
: `${repoUrl}/${scraperInfo.filename}`;
logger.log('[LocalScraperService] Downloading scraper:', scraperInfo.name);
@ -625,6 +831,11 @@ class LocalScraperService {
const existingScraper = this.installedScrapers.get(scraperInfo.id);
const isPlatformCompatible = this.isPlatformCompatible(scraperInfo);
// Check preserved states first (from refresh), then existing scraper, then default
const previousStates = (this as any)._previousEnabledStates as Map<string, boolean> | undefined;
const previousEnabled = previousStates?.get(scraperInfo.id);
const userEnabledState = previousEnabled !== undefined ? previousEnabled : (existingScraper?.enabled ?? true);
const updatedScraperInfo = {
...scraperInfo,
// Store the manifest's enabled state separately
@ -632,8 +843,8 @@ class LocalScraperService {
// Force disable if:
// 1. Manifest says enabled: false (globally disabled)
// 2. Platform incompatible
// Otherwise, preserve user's enabled state or default to true for new installations
enabled: scraperInfo.enabled && isPlatformCompatible ? (existingScraper?.enabled ?? true) : false
// Otherwise, preserve user's enabled state
enabled: scraperInfo.enabled && isPlatformCompatible ? userEnabledState : false
};
// Ensure contentLanguage is an array (migration for older scrapers)
@ -743,83 +954,29 @@ class LocalScraperService {
}
}
// Get available scrapers from manifest.json (for display in settings)
// Get available scrapers from ALL enabled repositories (for display in settings)
async getAvailableScrapers(): Promise<ScraperInfo[]> {
if (!this.repositoryUrl) {
logger.log('[LocalScraperService] No repository URL configured, returning installed scrapers');
return this.getInstalledScrapers();
await this.ensureInitialized();
const enabledRepos = await this.getEnabledRepositories();
if (enabledRepos.length === 0) {
logger.log('[LocalScraperService] No enabled repositories, returning empty list');
return [];
}
try {
logger.log('[LocalScraperService] Fetching available scrapers from manifest');
// Fetch manifest with cache busting
const baseManifestUrl = this.repositoryUrl.endsWith('/')
? `${this.repositoryUrl}manifest.json`
: `${this.repositoryUrl}/manifest.json`;
const manifestUrl = `${baseManifestUrl}?t=${Date.now()}&v=${Math.random()}`;
const response = await axios.get(manifestUrl, {
timeout: 10000,
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0'
}
});
const manifest: ScraperManifest = response.data;
// Store repository name from manifest
if (manifest.name) {
this.repositoryName = manifest.name;
}
// Return scrapers from manifest, respecting manifest's enabled field and platform compatibility
const availableScrapers = manifest.scrapers
.filter(scraperInfo => this.isPlatformCompatible(scraperInfo))
.map(scraperInfo => {
const installedScraper = this.installedScrapers.get(scraperInfo.id);
// Create a copy with manifest data
const scraperWithManifestData = {
...scraperInfo,
// Store the manifest's enabled state separately
manifestEnabled: scraperInfo.enabled,
// If manifest says enabled: false, scraper cannot be enabled
// If manifest says enabled: true, use installed state or default to true for new installs
enabled: scraperInfo.enabled ? (installedScraper?.enabled ?? true) : false
};
// Normalize formats fields (support both `formats` and `supportedFormats`)
const anyScraper: any = scraperWithManifestData as any;
if (typeof anyScraper.formats === 'string') {
anyScraper.formats = [anyScraper.formats];
}
if (typeof anyScraper.supportedFormats === 'string') {
anyScraper.supportedFormats = [anyScraper.supportedFormats];
}
if (!anyScraper.supportedFormats && anyScraper.formats) {
anyScraper.supportedFormats = anyScraper.formats;
}
return scraperWithManifestData;
// Return installed scrapers from all enabled repositories
// These are already synced with manifests during refresh
const allScrapers = Array.from(this.installedScrapers.values())
.filter(scraper => {
// Only include scrapers from enabled repositories
const repo = this.repositories.get(scraper.repositoryId || '');
return repo?.enabled !== false;
});
logger.log('[LocalScraperService] Found', availableScrapers.length, 'available scrapers in repository');
logger.log('[LocalScraperService] Found', allScrapers.length, 'scrapers from', enabledRepos.length, 'enabled repositories');
// Log final scraper states being returned to UI
availableScrapers.forEach(scraper => {
logger.log(`[LocalScraperService] Final scraper ${scraper.name}: manifestEnabled=${scraper.manifestEnabled}, enabled=${scraper.enabled}`);
});
return availableScrapers;
} catch (error) {
logger.error('[LocalScraperService] Failed to fetch available scrapers from manifest:', error);
// Fallback to installed scrapers if manifest fetch fails
return this.getInstalledScrapers();
}
return allScrapers;
}
// Check if a given scraper declares support for a specific format (e.g., 'mkv')
@ -920,7 +1077,7 @@ class LocalScraperService {
if (enabledScrapers.length > 0) {
try {
logger.log('[LocalScraperService] Enabled scrapers:', enabledScrapers.map(s => s.name).join(', '));
} catch {}
} catch { }
}
if (enabledScrapers.length === 0) {
@ -983,7 +1140,7 @@ class LocalScraperService {
promise.finally(() => {
const current = this.inFlightByKey.get(flightKey);
if (current === promise) this.inFlightByKey.delete(flightKey);
}).catch(() => {});
}).catch(() => { });
}
const results = await promise;