diff --git a/src/screens/PluginTesterScreen.tsx b/src/screens/PluginTesterScreen.tsx index ef6d98c..08cdeb5 100644 --- a/src/screens/PluginTesterScreen.tsx +++ b/src/screens/PluginTesterScreen.tsx @@ -18,6 +18,31 @@ import { useTheme } from '../contexts/ThemeContext'; import { pluginService } from '../services/pluginService'; import axios from 'axios'; +type RepoScraper = { + id: string; + name?: string; + filename?: string; + enabled?: boolean; + [key: string]: any; +}; + +type RepoManifest = { + name?: string; + scrapers?: RepoScraper[]; + [key: string]: any; +}; + +type RepoTestStatus = 'idle' | 'running' | 'ok' | 'ok-empty' | 'fail'; + +type RepoTestResult = { + status: RepoTestStatus; + streamsCount?: number; + error?: string; + triedUrl?: string; + logs?: string[]; + durationMs?: number; +}; + const PluginTesterScreen = () => { const navigation = useNavigation(); const insets = useSafeAreaInsets(); @@ -33,6 +58,7 @@ const PluginTesterScreen = () => { const [logs, setLogs] = useState([]); const [streams, setStreams] = useState([]); const [isRunning, setIsRunning] = useState(false); + const [mainTab, setMainTab] = useState<'individual' | 'repo'>('individual'); const [activeTab, setActiveTab] = useState<'code' | 'logs' | 'results'>('code'); const [isEditorFocused, setIsEditorFocused] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -40,6 +66,24 @@ const PluginTesterScreen = () => { const [matches, setMatches] = useState>([]); const focusedEditorScrollRef = useRef(null); + // Repo tester state + const [repoUrl, setRepoUrl] = useState(''); + const [repoResolvedBaseUrl, setRepoResolvedBaseUrl] = useState(null); + const [repoManifest, setRepoManifest] = useState(null); + const [repoScrapers, setRepoScrapers] = useState([]); + const [repoIsFetching, setRepoIsFetching] = useState(false); + const [repoFetchError, setRepoFetchError] = useState(null); + const [repoFetchTriedUrl, setRepoFetchTriedUrl] = useState(null); + const [repoResults, setRepoResults] = useState>({}); + const [repoIsTestingAll, setRepoIsTestingAll] = useState(false); + const [repoOpenLogsForId, setRepoOpenLogsForId] = useState(null); + + // Repo tester parameters (separate from single-plugin tester) + const [repoTmdbId, setRepoTmdbId] = useState('550'); + const [repoMediaType, setRepoMediaType] = useState<'movie' | 'tv'>('movie'); + const [repoSeason, setRepoSeason] = useState('1'); + const [repoEpisode, setRepoEpisode] = useState('1'); + const CODE_LINE_HEIGHT = 18; const CODE_PADDING_V = 12; const MIN_EDITOR_HEIGHT = 240; @@ -111,6 +155,321 @@ const PluginTesterScreen = () => { ); }; + const extractRepositoryName = (url: 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 || 'Repository'; + } catch { + return 'Repository'; + } + }; + + const getRepositoryBaseUrl = (input: string) => { + const trimmed = input.trim(); + if (!trimmed) return ''; + + // Remove query/fragment + const noHash = trimmed.split('#')[0]; + const noQuery = noHash.split('?')[0]; + + // If user provided manifest.json directly, strip it to get base. + const withoutManifest = noQuery.replace(/\/manifest\.json$/i, ''); + return withoutManifest.replace(/\/+$/, ''); + }; + + const addCacheBust = (url: string) => { + const hasQuery = url.includes('?'); + const sep = hasQuery ? '&' : '?'; + return `${url}${sep}t=${Date.now()}&v=${Math.random()}`; + }; + + const stripQueryAndHash = (url: string) => url.split('#')[0].split('?')[0]; + + const buildManifestCandidates = (input: string) => { + const trimmed = input.trim(); + const candidates: string[] = []; + if (!trimmed) return candidates; + + const noQuery = stripQueryAndHash(trimmed); + + // If input already looks like a manifest URL, try it first. + if (/\/manifest\.json$/i.test(noQuery)) { + candidates.push(noQuery); + candidates.push(addCacheBust(noQuery)); + } + + const base = getRepositoryBaseUrl(trimmed); + if (base) { + const manifestUrl = `${base}/manifest.json`; + candidates.push(manifestUrl); + candidates.push(addCacheBust(manifestUrl)); + } + + // De-dup while preserving order + return candidates.filter((u, idx) => candidates.indexOf(u) === idx); + }; + + const buildScraperCandidates = (baseRepoUrl: string, filename: string) => { + const candidates: string[] = []; + const cleanFilename = String(filename || '').trim(); + if (!cleanFilename) return candidates; + + // If manifest provides an absolute URL, respect it. + if (cleanFilename.startsWith('http://') || cleanFilename.startsWith('https://')) { + const noQuery = stripQueryAndHash(cleanFilename); + candidates.push(noQuery); + candidates.push(addCacheBust(noQuery)); + return candidates.filter((u, idx) => candidates.indexOf(u) === idx); + } + + const base = (baseRepoUrl || '').replace(/\/+$/, ''); + const rel = cleanFilename.replace(/^\/+/, ''); + const full = base ? `${base}/${rel}` : rel; + candidates.push(full); + candidates.push(addCacheBust(full)); + return candidates.filter((u, idx) => candidates.indexOf(u) === idx); + }; + + const fetchRepository = async () => { + const input = repoUrl.trim(); + if (!input) { + Alert.alert('Error', 'Please enter a repository URL'); + return; + } + + if (!input.startsWith('https://raw.githubusercontent.com/') && !input.startsWith('http://') && !input.startsWith('https://')) { + Alert.alert( + 'Invalid URL', + 'Use a GitHub raw URL or a local http(s) URL.\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/main' + ); + return; + } + + setRepoIsFetching(true); + setRepoFetchError(null); + setRepoFetchTriedUrl(null); + setRepoManifest(null); + setRepoScrapers([]); + setRepoResults({}); + setRepoResolvedBaseUrl(null); + + try { + const candidates = buildManifestCandidates(input); + if (candidates.length === 0) { + throw new Error('Could not build a manifest URL from the input'); + } + + let response: any = null; + let usedUrl: string | null = null; + let lastError: any = null; + + for (const candidate of candidates) { + try { + setRepoFetchTriedUrl(candidate); + response = await axios.get(candidate, { + timeout: 15000, + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + }, + }); + usedUrl = candidate; + break; + } catch (e) { + lastError = e; + } + } + + if (!response) { + throw lastError || new Error('Failed to fetch manifest'); + } + + const manifest: RepoManifest = response.data; + const scrapers = Array.isArray(manifest?.scrapers) ? manifest.scrapers : []; + + const resolvedBase = getRepositoryBaseUrl(usedUrl || input); + setRepoResolvedBaseUrl(resolvedBase || null); + + setRepoManifest({ + ...manifest, + name: manifest?.name || extractRepositoryName(resolvedBase || input), + }); + setRepoScrapers(scrapers); + + const initialResults: Record = {}; + for (const scraper of scrapers) { + if (!scraper?.id) continue; + initialResults[scraper.id] = { status: 'idle' }; + } + setRepoResults(initialResults); + } catch (error: any) { + const status = error?.response?.status; + const statusText = error?.response?.statusText; + const messageBase = error?.message ? String(error.message) : 'Failed to fetch repository manifest'; + const message = status ? `${messageBase} (HTTP ${status}${statusText ? ` ${statusText}` : ''})` : messageBase; + setRepoFetchError(message); + Alert.alert('Error', message); + } finally { + setRepoIsFetching(false); + } + }; + + const testRepoScraper = async (scraper: RepoScraper) => { + const manifestBase = repoResolvedBaseUrl || getRepositoryBaseUrl(repoUrl); + const effectiveBase = manifestBase; + if (!effectiveBase) return; + if (!scraper?.id) return; + + const filename = scraper.filename; + if (!filename) { + setRepoResults(prev => ({ + ...prev, + [scraper.id]: { + status: 'fail', + error: 'Missing filename in manifest', + }, + })); + return; + } + + setRepoResults(prev => ({ + ...prev, + [scraper.id]: { + ...(prev[scraper.id] || { status: 'idle' }), + status: 'running', + error: undefined, + triedUrl: undefined, + logs: [], + }, + })); + + const startedAt = Date.now(); + + try { + const candidates = buildScraperCandidates(effectiveBase, filename); + if (candidates.length === 0) throw new Error('Could not build a scraper URL'); + + let res: any = null; + let usedUrl: string | null = null; + let lastError: any = null; + + for (const candidate of candidates) { + try { + usedUrl = candidate; + res = await axios.get(candidate, { + timeout: 20000, + headers: { + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + }, + }); + break; + } catch (e) { + // Keep the latest URL so the UI can show what was attempted. + setRepoResults(prev => ({ + ...prev, + [scraper.id]: { + ...(prev[scraper.id] || { status: 'running' as const }), + triedUrl: candidate, + }, + })); + lastError = e; + } + } + + if (!res) { + throw lastError || new Error('Failed to download scraper'); + } + + const scraperCode = typeof res.data === 'string' ? res.data : JSON.stringify(res.data, null, 2); + + const params = { + tmdbId: repoTmdbId, + mediaType: repoMediaType, + season: repoMediaType === 'tv' ? parseInt(repoSeason) || 1 : undefined, + episode: repoMediaType === 'tv' ? parseInt(repoEpisode) || 1 : undefined, + }; + + const MAX_LOG_LINES = 400; + const result = await pluginService.testPlugin(scraperCode, params, { + onLog: (line) => { + setRepoResults(prev => { + const current = prev[scraper.id] || { status: 'running' as const }; + const nextLogs = [...(current.logs || []), line]; + const capped = nextLogs.length > MAX_LOG_LINES ? nextLogs.slice(-MAX_LOG_LINES) : nextLogs; + return { + ...prev, + [scraper.id]: { + ...current, + logs: capped, + }, + }; + }); + }, + }); + + const streamsCount = Array.isArray(result?.streams) ? result.streams.length : 0; + const status: RepoTestStatus = streamsCount > 0 ? 'ok' : 'ok-empty'; + + setRepoResults(prev => ({ + ...prev, + [scraper.id]: { + status, + streamsCount, + triedUrl: usedUrl || undefined, + logs: prev[scraper.id]?.logs, + durationMs: Date.now() - startedAt, + }, + })); + } catch (error: any) { + const status = error?.response?.status; + const statusText = error?.response?.statusText; + const messageBase = error?.message ? String(error.message) : 'Test failed'; + const message = status ? `${messageBase} (HTTP ${status}${statusText ? ` ${statusText}` : ''})` : messageBase; + setRepoResults(prev => ({ + ...prev, + [scraper.id]: { + status: 'fail', + error: message, + triedUrl: prev[scraper.id]?.triedUrl, + logs: prev[scraper.id]?.logs, + durationMs: Date.now() - startedAt, + }, + })); + } + }; + + const runWithConcurrency = async (items: T[], limit: number, worker: (item: T) => Promise) => { + const queue = [...items]; + const runners: Promise[] = []; + + const runOne = async () => { + while (queue.length > 0) { + const item = queue.shift(); + if (!item) return; + await worker(item); + } + }; + + const count = Math.max(1, Math.min(limit, items.length)); + for (let i = 0; i < count; i++) runners.push(runOne()); + await Promise.all(runners); + }; + + const testAllRepoScrapers = async () => { + if (repoScrapers.length === 0) return; + setRepoIsTestingAll(true); + try { + await runWithConcurrency(repoScrapers, 3, async (scraper) => { + await testRepoScraper(scraper); + }); + } finally { + setRepoIsTestingAll(false); + } + }; + // Styles const styles = StyleSheet.create({ container: { @@ -190,6 +549,86 @@ const PluginTesterScreen = () => { borderColor: currentTheme.colors.elevation3, marginBottom: 12, }, + repoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 10, + borderTopWidth: 1, + borderTopColor: currentTheme.colors.elevation3, + }, + repoRowLeft: { + flex: 1, + paddingRight: 10, + }, + repoRowTitle: { + fontSize: 13, + fontWeight: '700', + color: currentTheme.colors.highEmphasis, + }, + repoRowSub: { + marginTop: 2, + fontSize: 12, + color: currentTheme.colors.mediumEmphasis, + }, + statusPill: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + borderWidth: 1, + alignSelf: 'flex-start', + }, + statusPillText: { + fontSize: 11, + fontWeight: '800', + }, + statusIdle: { + backgroundColor: currentTheme.colors.elevation1, + borderColor: currentTheme.colors.elevation3, + }, + statusRunning: { + backgroundColor: currentTheme.colors.primary + '20', + borderColor: currentTheme.colors.primary, + }, + statusOk: { + backgroundColor: currentTheme.colors.success + '20', + borderColor: currentTheme.colors.success, + }, + statusOkEmpty: { + backgroundColor: currentTheme.colors.warning + '20', + borderColor: currentTheme.colors.warning, + }, + statusFail: { + backgroundColor: currentTheme.colors.error + '20', + borderColor: currentTheme.colors.error, + }, + repoMiniButton: { + paddingHorizontal: 10, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: currentTheme.colors.elevation1, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + }, + repoMiniButtonText: { + fontSize: 12, + fontWeight: '800', + color: currentTheme.colors.highEmphasis, + }, + repoLogsPanel: { + marginTop: 10, + backgroundColor: currentTheme.colors.elevation1, + borderRadius: 12, + borderWidth: 1, + borderColor: currentTheme.colors.elevation3, + padding: 10, + }, + repoLogsTitle: { + fontSize: 12, + fontWeight: '800', + color: currentTheme.colors.highEmphasis, + marginBottom: 8, + }, cardTitleRow: { flexDirection: 'row', alignItems: 'center', @@ -676,6 +1115,276 @@ const PluginTesterScreen = () => { ); + const renderRepoTab = () => ( + + + + + Repo Tester + + + + Fetch a repository (local URL or GitHub raw) and test each provider. + + + + + + {repoIsFetching ? ( + + ) : ( + + )} + + + + {!!repoFetchError && ( + + {repoFetchError} + + )} + + {!!repoFetchTriedUrl && ( + + Trying: {repoFetchTriedUrl} + + )} + + + + + Repo Test Parameters + + + These parameters are used only for Repo Tester. + + + setRepoMediaType('movie')} + > + + Movie + + setRepoMediaType('tv')} + > + + TV + + + + + + TMDB ID + + + + {repoMediaType === 'tv' && ( + <> + + Season + + + + Episode + + + + )} + + + + Using: {repoMediaType.toUpperCase()} • TMDB {repoTmdbId}{repoMediaType === 'tv' ? ` • S${repoSeason}E${repoEpisode}` : ''} + + + + + + Providers + + + {repoManifest ? ( + + {repoManifest.name || 'Repository'} • {repoScrapers.length} providers + + ) : ( + Fetch a repo to list providers. + )} + + {repoScrapers.length > 0 && ( + + + {repoIsTestingAll ? ( + + ) : ( + + )} + {repoIsTestingAll ? 'Testing…' : 'Test All'} + + + { + setRepoManifest(null); + setRepoScrapers([]); + setRepoResults({}); + setRepoFetchError(null); + setRepoFetchTriedUrl(null); + setRepoResolvedBaseUrl(null); + }} + disabled={repoIsTestingAll || repoIsFetching} + > + + + + )} + + {repoScrapers.map((scraper, idx) => { + const result = repoResults[scraper.id] || { status: 'idle' as const }; + + const getStatusStyle = () => { + switch (result.status) { + case 'running': + return styles.statusRunning; + case 'ok': + return styles.statusOk; + case 'ok-empty': + return styles.statusOkEmpty; + case 'fail': + return styles.statusFail; + default: + return styles.statusIdle; + } + }; + + const getStatusText = () => { + switch (result.status) { + case 'running': + return 'RUNNING'; + case 'ok': + return `OK (${result.streamsCount ?? 0})`; + case 'ok-empty': + return 'OK (0)'; + case 'fail': + return 'FAILED'; + default: + return 'IDLE'; + } + }; + + const statusColor = (() => { + switch (result.status) { + case 'running': + return currentTheme.colors.primary; + case 'ok': + return currentTheme.colors.success; + case 'ok-empty': + return currentTheme.colors.warning; + case 'fail': + return currentTheme.colors.error; + default: + return currentTheme.colors.mediumEmphasis; + } + })(); + + return ( + + + {scraper.name || scraper.id} + + {scraper.id}{scraper.filename ? ` • ${scraper.filename}` : ''} + + {!!result.triedUrl && result.status === 'fail' && ( + + Tried: {result.triedUrl} + + )} + {!!result.error && ( + + {result.error} + + )} + + {repoOpenLogsForId === scraper.id && ( + + Provider Logs + + + {(result.logs && result.logs.length > 0) ? result.logs.join('\n') : 'No logs captured.'} + + + + )} + + + + + {getStatusText()} + + + testRepoScraper(scraper)} + disabled={result.status === 'running' || repoIsTestingAll} + > + Test + + setRepoOpenLogsForId(prev => (prev === scraper.id ? null : scraper.id))} + disabled={result.status === 'idle' || result.status === 'running'} + > + Logs + + + + + ); + })} + + + + ); + const renderFocusedEditor = () => ( { <> setActiveTab('code')} + style={[styles.tab, mainTab === 'individual' && styles.activeTab]} + onPress={() => { + setMainTab('individual'); + setActiveTab('code'); + }} > - - Code + + Individual setActiveTab('logs')} + style={[styles.tab, mainTab === 'repo' && styles.activeTab]} + onPress={() => setMainTab('repo')} > - - Logs - - {logs.length} - - - setActiveTab('results')} - > - - Results - - {streams.length} - + + Repo - {activeTab === 'code' && renderCodeTab()} - {activeTab === 'logs' && renderLogsTab()} - {activeTab === 'results' && renderResultsTab()} + {mainTab === 'individual' && ( + + setActiveTab('code')} + > + + Code + + setActiveTab('logs')} + > + + Logs + + {logs.length} + + + setActiveTab('results')} + > + + Results + + {streams.length} + + + + )} + + {mainTab === 'repo' ? ( + renderRepoTab() + ) : ( + <> + {activeTab === 'code' && renderCodeTab()} + {activeTab === 'logs' && renderLogsTab()} + {activeTab === 'results' && renderResultsTab()} + + )} )}