From 2169354f0d90458603e469d1201eda456cbdbccc Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 8 Jan 2026 03:41:32 +0530 Subject: [PATCH] refactored plugintest screen --- src/screens/PluginTesterScreen.tsx | 1661 +---------------- .../plugin-tester/IndividualTester.tsx | 549 ++++++ src/screens/plugin-tester/RepoTester.tsx | 624 +++++++ src/screens/plugin-tester/components.tsx | 62 + src/screens/plugin-tester/styles.ts | 455 +++++ src/screens/plugin-tester/types.ts | 24 + 6 files changed, 1735 insertions(+), 1640 deletions(-) create mode 100644 src/screens/plugin-tester/IndividualTester.tsx create mode 100644 src/screens/plugin-tester/RepoTester.tsx create mode 100644 src/screens/plugin-tester/components.tsx create mode 100644 src/screens/plugin-tester/styles.ts create mode 100644 src/screens/plugin-tester/types.ts diff --git a/src/screens/PluginTesterScreen.tsx b/src/screens/PluginTesterScreen.tsx index 08cdeb5..e038bff 100644 --- a/src/screens/PluginTesterScreen.tsx +++ b/src/screens/PluginTesterScreen.tsx @@ -1,1652 +1,33 @@ -import React, { useState, useRef } from 'react'; -import { - View, - Text, - StyleSheet, - TextInput, - TouchableOpacity, - ScrollView, - ActivityIndicator, - Platform, - Alert, - KeyboardAvoidingView -} from 'react-native'; +import React, { useState } from 'react'; +import { View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Ionicons } from '@expo/vector-icons'; import { useNavigation } from '@react-navigation/native'; 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; -}; +import { RepoTester } from './plugin-tester/RepoTester'; +import { IndividualTester } from './plugin-tester/IndividualTester'; +import { Header, MainTabBar } from './plugin-tester/components'; +import { getPluginTesterStyles } from './plugin-tester/styles'; const PluginTesterScreen = () => { - const navigation = useNavigation(); - const insets = useSafeAreaInsets(); - const { currentTheme } = useTheme(); - - // State - const [code, setCode] = useState(''); - const [url, setUrl] = useState(''); - const [tmdbId, setTmdbId] = useState('550'); // Fight Club default - const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie'); - const [season, setSeason] = useState('1'); - const [episode, setEpisode] = useState('1'); - 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(''); - const [currentMatchIndex, setCurrentMatchIndex] = useState(0); - 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; - - const logsScrollRef = useRef(null); - const codeInputRefFocused = useRef(null); - - // Calculate matches when code or search query changes - React.useEffect(() => { - if (!searchQuery.trim()) { - setMatches([]); - setCurrentMatchIndex(0); - return; - } - - const query = searchQuery.toLowerCase(); - const codeToSearch = code.toLowerCase(); - const foundMatches: Array<{ start: number; end: number }> = []; - let index = 0; - - while ((index = codeToSearch.indexOf(query, index)) !== -1) { - foundMatches.push({ start: index, end: index + query.length }); - index += 1; - } - - setMatches(foundMatches); - setCurrentMatchIndex(0); - }, [searchQuery, code]); - - const jumpToMatch = (matchIndex: number) => { - if (!isEditorFocused) return; - if (!searchQuery.trim()) return; - if (matches.length === 0) return; - - const safeIndex = Math.min(Math.max(matchIndex, 0), matches.length - 1); - const match = matches[safeIndex]; - - requestAnimationFrame(() => { - // Scroll the ScrollView so the highlighted match becomes visible. - const before = code.slice(0, match.start); - const lineIndex = before.split('\n').length - 1; - const y = Math.max(0, (lineIndex - 2) * CODE_LINE_HEIGHT); - focusedEditorScrollRef.current?.scrollTo({ y, animated: true }); - }); - }; - - const getEditorHeight = () => { - const lineCount = Math.max(1, code.split('\n').length); - const contentHeight = lineCount * CODE_LINE_HEIGHT + CODE_PADDING_V * 2; - return Math.max(MIN_EDITOR_HEIGHT, contentHeight); - }; - - const renderHighlightedCode = () => { - if (!searchQuery.trim() || matches.length === 0) { - return {code || ' '}; - } - - const safeIndex = Math.min(Math.max(currentMatchIndex, 0), matches.length - 1); - const match = matches[safeIndex]; - const start = Math.max(0, Math.min(match.start, code.length)); - const end = Math.max(start, Math.min(match.end, code.length)); - - return ( - - {code.slice(0, start)} - {code.slice(start, end) || ' '} - {code.slice(end) || ' '} - - ); - }; - - 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: { - flex: 1, - backgroundColor: currentTheme.colors.darkBackground, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: currentTheme.colors.elevation3, - }, - headerTitle: { - fontSize: 20, - fontWeight: 'bold', - color: currentTheme.colors.text, - }, - headerSubtitle: { - fontSize: 12, - color: currentTheme.colors.mediumEmphasis, - marginTop: 2, - }, - tabBar: { - flexDirection: 'row', - backgroundColor: currentTheme.colors.elevation1, - padding: 6, - marginHorizontal: 16, - marginTop: 12, - borderRadius: 12, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - tab: { - flex: 1, - paddingVertical: 10, - alignItems: 'center', - justifyContent: 'center', - borderRadius: 10, - flexDirection: 'row', - gap: 6, - }, - activeTab: { - backgroundColor: currentTheme.colors.primary + '20', - }, - tabText: { - fontSize: 14, - fontWeight: '600', - color: currentTheme.colors.mediumEmphasis, - }, - activeTabText: { - color: currentTheme.colors.primary, - }, - tabBadge: { - paddingHorizontal: 8, - paddingVertical: 2, - borderRadius: 999, - backgroundColor: currentTheme.colors.elevation3, - }, - tabBadgeText: { - fontSize: 11, - fontWeight: '700', - color: currentTheme.colors.highEmphasis, - }, - content: { - flex: 1, - paddingHorizontal: 16, - paddingTop: 12, - }, - card: { - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - padding: 14, - borderWidth: 1, - 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', - justifyContent: 'space-between', - marginBottom: 10, - }, - cardTitle: { - fontSize: 15, - fontWeight: '700', - color: currentTheme.colors.white, - letterSpacing: 0.2, - }, - helperText: { - fontSize: 12, - color: currentTheme.colors.mediumEmphasis, - lineHeight: 16, - }, - input: { - backgroundColor: currentTheme.colors.elevation1, - borderRadius: 8, - paddingVertical: 12, - paddingHorizontal: 12, - color: currentTheme.colors.white, - fontSize: 14, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - minHeight: 48, - }, - codeInput: { - backgroundColor: currentTheme.colors.elevation1, - borderRadius: 12, - paddingVertical: 12, - paddingHorizontal: 12, - color: currentTheme.colors.highEmphasis, - fontSize: 13, - lineHeight: 18, - fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', - minHeight: 240, - textAlignVertical: 'top', - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - focusedEditorShell: { - borderRadius: 12, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - backgroundColor: currentTheme.colors.elevation1, - overflow: 'hidden', - }, - highlightLayer: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - paddingVertical: 12, - paddingHorizontal: 12, - }, - highlightText: { - color: currentTheme.colors.highEmphasis, - fontSize: 13, - lineHeight: 18, - fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', - }, - highlightActive: { - backgroundColor: '#FFD400', - color: currentTheme.colors.black, - }, - codeInputTransparent: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - paddingVertical: 12, - paddingHorizontal: 12, - color: 'transparent', - backgroundColor: 'transparent', - fontSize: 13, - lineHeight: 18, - fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', - }, - row: { - flexDirection: 'row', - gap: 12, - }, - fieldLabel: { - fontSize: 12, - fontWeight: '600', - color: currentTheme.colors.mediumEmphasis, - marginBottom: 6, - }, - segment: { - flexDirection: 'row', - backgroundColor: currentTheme.colors.elevation1, - borderRadius: 12, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - overflow: 'hidden', - }, - segmentItem: { - flex: 1, - paddingVertical: 10, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - gap: 8, - }, - segmentItemActive: { - backgroundColor: currentTheme.colors.primary + '20', - }, - segmentText: { - fontSize: 14, - fontWeight: '700', - color: currentTheme.colors.highEmphasis, - }, - segmentTextActive: { - color: currentTheme.colors.primary, - }, - button: { - backgroundColor: currentTheme.colors.primary, - borderRadius: 12, - paddingVertical: 12, - paddingHorizontal: 16, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', - gap: 8, - }, - buttonText: { - color: currentTheme.colors.white, - fontWeight: '700', - fontSize: 15, - }, - secondaryButton: { - backgroundColor: currentTheme.colors.elevation1, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - secondaryButtonText: { - color: currentTheme.colors.highEmphasis, - }, - stickyFooter: { - paddingHorizontal: 16, - paddingTop: 10, - borderTopWidth: 1, - borderTopColor: currentTheme.colors.elevation3, - backgroundColor: currentTheme.colors.darkBackground, - }, - footerCard: { - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - padding: 12, - marginBottom: 10, - }, - footerTitleRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 10, - }, - footerTitle: { - fontSize: 13, - fontWeight: '700', - color: currentTheme.colors.white, - }, - headerRightButton: { - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 10, - backgroundColor: currentTheme.colors.elevation2, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - headerRightButtonText: { - fontSize: 13, - fontWeight: '700', - color: currentTheme.colors.highEmphasis, - }, - codeInputFocused: { - flex: 1, - minHeight: 0, - }, - cardActionsRow: { - flexDirection: 'row', - alignItems: 'center', - }, - cardActionButton: { - padding: 6, - marginRight: 6, - borderRadius: 10, - backgroundColor: currentTheme.colors.elevation1, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - findToolbar: { - backgroundColor: currentTheme.colors.elevation2, - borderBottomWidth: 1, - borderBottomColor: currentTheme.colors.elevation3, - paddingHorizontal: 12, - paddingVertical: 10, - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - findInput: { - flex: 1, - backgroundColor: currentTheme.colors.elevation1, - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 8, - color: currentTheme.colors.white, - fontSize: 13, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - findCounter: { - fontSize: 12, - color: currentTheme.colors.mediumEmphasis, - minWidth: 40, - textAlign: 'right', - fontWeight: '600', - }, - findButton: { - padding: 8, - borderRadius: 8, - backgroundColor: currentTheme.colors.elevation1, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - findButtonActive: { - backgroundColor: currentTheme.colors.primary + '20', - borderColor: currentTheme.colors.primary, - }, - logItem: { - fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', - fontSize: 12, - marginBottom: 4, - color: currentTheme.colors.mediumEmphasis, - }, - logError: { - color: currentTheme.colors.error, - }, - logWarn: { - color: currentTheme.colors.warning, - }, - logInfo: { - color: currentTheme.colors.info, - }, - logDebug: { - color: currentTheme.colors.lightGray, - }, - logContainer: { - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - padding: 12, - }, - resultItem: { - backgroundColor: currentTheme.colors.elevation2, - borderRadius: 12, - padding: 12, - marginBottom: 8, - borderWidth: 1, - borderColor: currentTheme.colors.elevation3, - }, - resultTitle: { - fontSize: 16, - fontWeight: '600', - color: currentTheme.colors.white, - marginBottom: 4, - }, - resultMeta: { - fontSize: 12, - color: currentTheme.colors.mediumGray, - marginBottom: 2, - }, - resultUrl: { - fontSize: 12, - color: currentTheme.colors.mediumEmphasis, - marginBottom: 2, - }, - emptyState: { - alignItems: 'center', - justifyContent: 'center', - padding: 32, - }, - emptyText: { - color: currentTheme.colors.mediumGray, - marginTop: 8, - }, - }); - - const fetchFromUrl = async () => { - if (!url) { - Alert.alert('Error', 'Please enter a URL'); - return; - } - - try { - const response = await axios.get(url, { headers: { 'Cache-Control': 'no-cache' } }); - const content = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); - setCode(content); - Alert.alert('Success', 'Code loaded from URL'); - } catch (error: any) { - Alert.alert('Error', `Failed to fetch: ${error.message}`); - } - }; - - const runTest = async () => { - if (!code.trim()) { - Alert.alert('Error', 'No code to run'); - return; - } - - setIsRunning(true); - setLogs([]); - setStreams([]); - setActiveTab('logs'); - - try { - const params = { - tmdbId, - mediaType, - season: mediaType === 'tv' ? parseInt(season) || 1 : undefined, - episode: mediaType === 'tv' ? parseInt(episode) || 1 : undefined, - }; - - const result = await pluginService.testPlugin(code, params, { - onLog: (line) => { - setLogs(prev => [...prev, line]); - }, - }); - - // Logs were already appended in real-time via onLog - setStreams(result.streams); - - if (result.streams.length > 0) { - setActiveTab('results'); - } - } catch (error: any) { - setLogs(prev => [...prev, `[FATAL] ${error.message}`]); - } finally { - setIsRunning(false); - } - }; - - const renderCodeTab = () => ( - - - - - Load from URL - - - - Paste a raw GitHub URL or local IP and tap download. - - - - - - - - - - - - Plugin Code - - setIsEditorFocused(true)} - accessibilityLabel="Focus code editor" - > - - - - - - - - - - - - - Test Parameters - - - - - setMediaType('movie')} - > - - Movie - - setMediaType('tv')} - > - - TV - - - - - - TMDB ID - - - - {mediaType === 'tv' && ( - <> - - Season - - - - Episode - - - - )} - - - - {isRunning ? ( - - ) : ( - - )} - {isRunning ? 'Running…' : 'Run Test'} - - - - ); - - 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 = () => ( - - - jumpToMatch(currentMatchIndex)} - placeholder="Find in code…" - placeholderTextColor={currentTheme.colors.mediumEmphasis} - autoCapitalize="none" - autoCorrect={false} - /> - - {matches.length === 0 ? '–' : `${currentMatchIndex + 1}/${matches.length}`} - - 0 && styles.findButtonActive]} - onPress={() => { - if (matches.length === 0) return; - const nextIndex = currentMatchIndex === 0 ? matches.length - 1 : currentMatchIndex - 1; - setCurrentMatchIndex(nextIndex); - jumpToMatch(nextIndex); - }} - disabled={matches.length === 0} - > - 0 ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis} - /> - - 0 && styles.findButtonActive]} - onPress={() => { - if (matches.length === 0) return; - const nextIndex = currentMatchIndex === matches.length - 1 ? 0 : currentMatchIndex + 1; - setCurrentMatchIndex(nextIndex); - jumpToMatch(nextIndex); - }} - disabled={matches.length === 0} - > - 0 ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis} - /> - - { - setSearchQuery(''); - setMatches([]); - setCurrentMatchIndex(0); - }} - > - - - - - (focusedEditorScrollRef.current = r)} - style={styles.content} - contentContainerStyle={{ paddingBottom: 24 }} - keyboardShouldPersistTaps="handled" - > - - - {renderHighlightedCode()} - - - - - - - ); - - const renderLogsTab = () => ( - (logsScrollRef.current = r)} - style={styles.content} - onContentSizeChange={() => { - if (activeTab === 'logs') { - logsScrollRef.current?.scrollToEnd({ animated: true }); - } - }} - > - {logs.length === 0 ? ( - - - No logs yet. Run a test to see output. - - ) : ( - - {logs.map((log, i) => { - let style = styles.logItem; - if (log.includes('[ERROR]') || log.includes('[FATAL')) style = { ...style, ...styles.logError }; - else if (log.includes('[WARN]')) style = { ...style, ...styles.logWarn }; - else if (log.includes('[INFO]')) style = { ...style, ...styles.logInfo }; - else if (log.includes('[DEBUG]')) style = { ...style, ...styles.logDebug }; - - return ( - - {log} - - ); - })} - - )} - - ); - - const renderResultsTab = () => ( - - {streams.length === 0 ? ( - - - No streams found yet. - - ) : ( - streams.map((stream, i) => ( - - {stream.title || stream.name} - Quality: {stream.quality || 'Unknown'} - Size: {stream.description || 'Unknown'} - URL: {stream.url} - - - {(() => { - try { - return JSON.stringify(stream, null, 2); - } catch { - return String(stream); - } - })()} - - - )) - )} - - ); + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + const navigation = useNavigation(); + const styles = getPluginTesterStyles(currentTheme); + + if (mainTab === 'individual') { + return ; + } return ( - - (isEditorFocused ? setIsEditorFocused(false) : navigation.goBack())}> - - - - {isEditorFocused ? 'Edit Code' : 'Plugin Tester'} - {!isEditorFocused && ( - Run scrapers and inspect logs in real-time - )} - - {isEditorFocused ? ( - { - setIsEditorFocused(false); - setSearchQuery(''); - setMatches([]); - setCurrentMatchIndex(0); - }}> - Done - - ) : ( - - )} - - - {isEditorFocused ? ( - renderFocusedEditor() - ) : ( - <> - - { - setMainTab('individual'); - setActiveTab('code'); - }} - > - - Individual - - setMainTab('repo')} - > - - Repo - - - - {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()} - - )} - - )} +
navigation.goBack()} + /> + + ); }; diff --git a/src/screens/plugin-tester/IndividualTester.tsx b/src/screens/plugin-tester/IndividualTester.tsx new file mode 100644 index 0000000..6ad67fa --- /dev/null +++ b/src/screens/plugin-tester/IndividualTester.tsx @@ -0,0 +1,549 @@ +import React, { useState, useRef } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { useTheme } from '../../contexts/ThemeContext'; +import { pluginService } from '../../services/pluginService'; +import axios from 'axios'; +import { getPluginTesterStyles } from './styles'; +import { Header, MainTabBar } from './components'; + +interface IndividualTesterProps { + onSwitchTab: (tab: 'individual' | 'repo') => void; +} + +export const IndividualTester = ({ onSwitchTab }: IndividualTesterProps) => { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); + const styles = getPluginTesterStyles(currentTheme); + + // State + const [code, setCode] = useState(''); + const [url, setUrl] = useState(''); + const [tmdbId, setTmdbId] = useState('550'); // Fight Club default + const [mediaType, setMediaType] = useState<'movie' | 'tv'>('movie'); + const [season, setSeason] = useState('1'); + const [episode, setEpisode] = useState('1'); + const [logs, setLogs] = useState([]); + const [streams, setStreams] = useState([]); + const [isRunning, setIsRunning] = useState(false); + const [activeTab, setActiveTab] = useState<'code' | 'logs' | 'results'>('code'); + const [isEditorFocused, setIsEditorFocused] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [matches, setMatches] = useState>([]); + const focusedEditorScrollRef = useRef(null); + + const CODE_LINE_HEIGHT = 18; + const CODE_PADDING_V = 12; + const MIN_EDITOR_HEIGHT = 240; + + const logsScrollRef = useRef(null); + const codeInputRefFocused = useRef(null); + + // Calculate matches when code or search query changes + React.useEffect(() => { + if (!searchQuery.trim()) { + setMatches([]); + setCurrentMatchIndex(0); + return; + } + + const query = searchQuery.toLowerCase(); + const codeToSearch = code.toLowerCase(); + const foundMatches: Array<{ start: number; end: number }> = []; + let index = 0; + + while ((index = codeToSearch.indexOf(query, index)) !== -1) { + foundMatches.push({ start: index, end: index + query.length }); + index += 1; + } + + setMatches(foundMatches); + setCurrentMatchIndex(0); + }, [searchQuery, code]); + + const jumpToMatch = (matchIndex: number) => { + if (!isEditorFocused) return; + if (!searchQuery.trim()) return; + if (matches.length === 0) return; + + const safeIndex = Math.min(Math.max(matchIndex, 0), matches.length - 1); + const match = matches[safeIndex]; + + requestAnimationFrame(() => { + // Scroll the ScrollView so the highlighted match becomes visible. + const before = code.slice(0, match.start); + const lineIndex = before.split('\n').length - 1; + const y = Math.max(0, (lineIndex - 2) * CODE_LINE_HEIGHT); + focusedEditorScrollRef.current?.scrollTo({ y, animated: true }); + }); + }; + + const getEditorHeight = () => { + const lineCount = Math.max(1, code.split('\n').length); + const contentHeight = lineCount * CODE_LINE_HEIGHT + CODE_PADDING_V * 2; + return Math.max(MIN_EDITOR_HEIGHT, contentHeight); + }; + + const renderHighlightedCode = () => { + if (!searchQuery.trim() || matches.length === 0) { + return {code || ' '}; + } + + const safeIndex = Math.min(Math.max(currentMatchIndex, 0), matches.length - 1); + const match = matches[safeIndex]; + const start = Math.max(0, Math.min(match.start, code.length)); + const end = Math.max(start, Math.min(match.end, code.length)); + + return ( + + {code.slice(0, start)} + {code.slice(start, end) || ' '} + {code.slice(end) || ' '} + + ); + }; + + const fetchFromUrl = async () => { + if (!url) { + Alert.alert('Error', 'Please enter a URL'); + return; + } + + try { + const response = await axios.get(url, { headers: { 'Cache-Control': 'no-cache' } }); + const content = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); + setCode(content); + Alert.alert('Success', 'Code loaded from URL'); + } catch (error: any) { + Alert.alert('Error', `Failed to fetch: ${error.message}`); + } + }; + + const runTest = async () => { + if (!code.trim()) { + Alert.alert('Error', 'No code to run'); + return; + } + + setIsRunning(true); + setLogs([]); + setStreams([]); + setActiveTab('logs'); + + try { + const params = { + tmdbId, + mediaType, + season: mediaType === 'tv' ? parseInt(season) || 1 : undefined, + episode: mediaType === 'tv' ? parseInt(episode) || 1 : undefined, + }; + + const result = await pluginService.testPlugin(code, params, { + onLog: (line) => { + setLogs(prev => [...prev, line]); + }, + }); + + // Logs were already appended in real-time via onLog + setStreams(result.streams); + + if (result.streams.length > 0) { + setActiveTab('results'); + } + } catch (error: any) { + setLogs(prev => [...prev, `[FATAL] ${error.message}`]); + } finally { + setIsRunning(false); + } + }; + + const renderCodeTab = () => ( + + + + + Load from URL + + + + Paste a raw GitHub URL or local IP and tap download. + + + + + + + + + + + + Plugin Code + + setIsEditorFocused(true)} + accessibilityLabel="Focus code editor" + > + + + + + + + + + + + + + Test Parameters + + + + + setMediaType('movie')} + > + + Movie + + setMediaType('tv')} + > + + TV + + + + + + TMDB ID + + + + {mediaType === 'tv' && ( + <> + + Season + + + + Episode + + + + )} + + + + {isRunning ? ( + + ) : ( + + )} + {isRunning ? 'Running…' : 'Run Test'} + + + + ); + + const renderLogsTab = () => ( + (logsScrollRef.current = r)} + style={styles.content} + onContentSizeChange={() => { + if (activeTab === 'logs') { + logsScrollRef.current?.scrollToEnd({ animated: true }); + } + }} + > + {logs.length === 0 ? ( + + + No logs yet. Run a test to see output. + + ) : ( + + {logs.map((log, i) => { + let style = styles.logItem; + if (log.includes('[ERROR]') || log.includes('[FATAL')) style = { ...style, ...styles.logError }; + else if (log.includes('[WARN]')) style = { ...style, ...styles.logWarn }; + else if (log.includes('[INFO]')) style = { ...style, ...styles.logInfo }; + else if (log.includes('[DEBUG]')) style = { ...style, ...styles.logDebug }; + + return ( + + {log} + + ); + })} + + )} + + ); + + const renderResultsTab = () => ( + + {streams.length === 0 ? ( + + + No streams found yet. + + ) : ( + streams.map((stream, i) => ( + + {stream.title || stream.name} + Quality: {stream.quality || 'Unknown'} + Size: {stream.description || 'Unknown'} + URL: {stream.url} + + + {(() => { + try { + return JSON.stringify(stream, null, 2); + } catch { + return String(stream); + } + })()} + + + )) + )} + + ); + + const renderFocusedEditor = () => ( + + + jumpToMatch(currentMatchIndex)} + placeholder="Find in code…" + placeholderTextColor={currentTheme.colors.mediumEmphasis} + autoCapitalize="none" + autoCorrect={false} + /> + + {matches.length === 0 ? '–' : `${currentMatchIndex + 1}/${matches.length}`} + + 0 && styles.findButtonActive]} + onPress={() => { + if (matches.length === 0) return; + const nextIndex = currentMatchIndex === 0 ? matches.length - 1 : currentMatchIndex - 1; + setCurrentMatchIndex(nextIndex); + jumpToMatch(nextIndex); + }} + disabled={matches.length === 0} + > + 0 ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis} + /> + + 0 && styles.findButtonActive]} + onPress={() => { + if (matches.length === 0) return; + const nextIndex = currentMatchIndex === matches.length - 1 ? 0 : currentMatchIndex + 1; + setCurrentMatchIndex(nextIndex); + jumpToMatch(nextIndex); + }} + disabled={matches.length === 0} + > + 0 ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis} + /> + + { + setSearchQuery(''); + setMatches([]); + setCurrentMatchIndex(0); + }} + > + + + + + (focusedEditorScrollRef.current = r)} + style={styles.content} + contentContainerStyle={{ paddingBottom: 24 }} + keyboardShouldPersistTaps="handled" + > + + + {renderHighlightedCode()} + + + + + + + ); + + return ( + + {isEditorFocused ? ( + <> +
setIsEditorFocused(false)} + rightElement={ + { + setIsEditorFocused(false); + setSearchQuery(''); + setMatches([]); + setCurrentMatchIndex(0); + }}> + Done + + } + /> + {renderFocusedEditor()} + + ) : ( + <> +
navigation.goBack()} + /> + + + + setActiveTab('code')} + > + Code + + setActiveTab('logs')} + > + Logs + + setActiveTab('results')} + > + Results + + + + {activeTab === 'code' && renderCodeTab()} + {activeTab === 'logs' && renderLogsTab()} + {activeTab === 'results' && renderResultsTab()} + + )} + + ); +}; diff --git a/src/screens/plugin-tester/RepoTester.tsx b/src/screens/plugin-tester/RepoTester.tsx new file mode 100644 index 0000000..01d55bb --- /dev/null +++ b/src/screens/plugin-tester/RepoTester.tsx @@ -0,0 +1,624 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Platform +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; +import { pluginService } from '../../services/pluginService'; +import axios from 'axios'; +import { getPluginTesterStyles } from './styles'; +import { RepoManifest, RepoScraper, RepoTestResult, RepoTestStatus } from './types'; + +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); +}; + +export const RepoTester = () => { + const { currentTheme } = useTheme(); + const styles = getPluginTesterStyles(currentTheme); + + // 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 + const [repoTmdbId, setRepoTmdbId] = useState('550'); + const [repoMediaType, setRepoMediaType] = useState<'movie' | 'tv'>('movie'); + const [repoSeason, setRepoSeason] = useState('1'); + const [repoEpisode, setRepoEpisode] = useState('1'); + + 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); + } + }; + + return ( + + + + + 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 + + + + + ); + })} + + + + ); +}; diff --git a/src/screens/plugin-tester/components.tsx b/src/screens/plugin-tester/components.tsx new file mode 100644 index 0000000..6648010 --- /dev/null +++ b/src/screens/plugin-tester/components.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; +import { getPluginTesterStyles } from './styles'; + +interface HeaderProps { + title: string; + subtitle?: string; + onBack?: () => void; + backIcon?: keyof typeof Ionicons.glyphMap; + rightElement?: React.ReactNode; +} + +export const Header = ({ title, subtitle, onBack, backIcon = 'arrow-back', rightElement }: HeaderProps) => { + const { currentTheme } = useTheme(); + const styles = getPluginTesterStyles(currentTheme); + + return ( + + + + + + {title} + {subtitle && ( + {subtitle} + )} + + {rightElement || } + + ); +}; + +interface MainTabBarProps { + activeTab: 'individual' | 'repo'; + onTabChange: (tab: 'individual' | 'repo') => void; +} + +export const MainTabBar = ({ activeTab, onTabChange }: MainTabBarProps) => { + const { currentTheme } = useTheme(); + const styles = getPluginTesterStyles(currentTheme); + + return ( + + onTabChange('individual')} + > + + Individual + + onTabChange('repo')} + > + + Repo Tester + + + ); +}; diff --git a/src/screens/plugin-tester/styles.ts b/src/screens/plugin-tester/styles.ts new file mode 100644 index 0000000..481f8ac --- /dev/null +++ b/src/screens/plugin-tester/styles.ts @@ -0,0 +1,455 @@ +import { StyleSheet, Platform } from 'react-native'; + +export const getPluginTesterStyles = (theme: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.colors.darkBackground, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.elevation3, + }, + headerTitle: { + fontSize: 20, + fontWeight: 'bold', + color: theme.colors.text, + }, + headerSubtitle: { + fontSize: 12, + color: theme.colors.mediumEmphasis, + marginTop: 2, + }, + tabBar: { + flexDirection: 'row', + backgroundColor: theme.colors.elevation1, + padding: 6, + marginHorizontal: 16, + marginTop: 12, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + tab: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 10, + flexDirection: 'row', + gap: 6, + }, + activeTab: { + backgroundColor: theme.colors.primary + '20', + }, + tabText: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.mediumEmphasis, + }, + activeTabText: { + color: theme.colors.primary, + }, + tabBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: theme.colors.elevation3, + }, + tabBadgeText: { + fontSize: 11, + fontWeight: '700', + color: theme.colors.highEmphasis, + }, + content: { + flex: 1, + paddingHorizontal: 16, + paddingTop: 12, + }, + card: { + backgroundColor: theme.colors.elevation2, + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: theme.colors.elevation3, + marginBottom: 12, + }, + repoRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 10, + borderTopWidth: 1, + borderTopColor: theme.colors.elevation3, + }, + repoRowLeft: { + flex: 1, + paddingRight: 10, + }, + repoRowTitle: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.highEmphasis, + }, + repoRowSub: { + marginTop: 2, + fontSize: 12, + color: theme.colors.mediumEmphasis, + }, + statusPill: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + borderWidth: 1, + alignSelf: 'flex-start', + }, + statusPillText: { + fontSize: 11, + fontWeight: '800', + }, + statusIdle: { + backgroundColor: theme.colors.elevation1, + borderColor: theme.colors.elevation3, + }, + statusRunning: { + backgroundColor: theme.colors.primary + '20', + borderColor: theme.colors.primary, + }, + statusOk: { + backgroundColor: theme.colors.success + '20', + borderColor: theme.colors.success, + }, + statusOkEmpty: { + backgroundColor: theme.colors.warning + '20', + borderColor: theme.colors.warning, + }, + statusFail: { + backgroundColor: theme.colors.error + '20', + borderColor: theme.colors.error, + }, + repoMiniButton: { + paddingHorizontal: 10, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: theme.colors.elevation1, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + repoMiniButtonText: { + fontSize: 12, + fontWeight: '800', + color: theme.colors.highEmphasis, + }, + repoLogsPanel: { + marginTop: 10, + backgroundColor: theme.colors.elevation1, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.elevation3, + padding: 10, + }, + repoLogsTitle: { + fontSize: 12, + fontWeight: '800', + color: theme.colors.highEmphasis, + marginBottom: 8, + }, + cardTitleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, + }, + cardTitle: { + fontSize: 15, + fontWeight: '700', + color: theme.colors.white, + letterSpacing: 0.2, + }, + helperText: { + fontSize: 12, + color: theme.colors.mediumEmphasis, + lineHeight: 16, + }, + input: { + backgroundColor: theme.colors.elevation1, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 12, + color: theme.colors.white, + fontSize: 14, + borderWidth: 1, + borderColor: theme.colors.elevation3, + minHeight: 48, + }, + codeInput: { + backgroundColor: theme.colors.elevation1, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 12, + color: theme.colors.highEmphasis, + fontSize: 13, + lineHeight: 18, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + minHeight: 240, + textAlignVertical: 'top', + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + focusedEditorShell: { + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.elevation3, + backgroundColor: theme.colors.elevation1, + overflow: 'hidden', + }, + highlightLayer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + paddingVertical: 12, + paddingHorizontal: 12, + }, + highlightText: { + color: theme.colors.highEmphasis, + fontSize: 13, + lineHeight: 18, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + }, + highlightActive: { + backgroundColor: '#FFD400', + color: theme.colors.black, + }, + codeInputTransparent: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + paddingVertical: 12, + paddingHorizontal: 12, + color: 'transparent', + backgroundColor: 'transparent', + fontSize: 13, + lineHeight: 18, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + }, + row: { + flexDirection: 'row', + gap: 12, + }, + fieldLabel: { + fontSize: 12, + fontWeight: '600', + color: theme.colors.mediumEmphasis, + marginBottom: 6, + }, + segment: { + flexDirection: 'row', + backgroundColor: theme.colors.elevation1, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.elevation3, + overflow: 'hidden', + }, + segmentItem: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 8, + }, + segmentItemActive: { + backgroundColor: theme.colors.primary + '20', + }, + segmentText: { + fontSize: 14, + fontWeight: '700', + color: theme.colors.highEmphasis, + }, + segmentTextActive: { + color: theme.colors.primary, + }, + button: { + backgroundColor: theme.colors.primary, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 8, + }, + buttonText: { + color: theme.colors.white, + fontWeight: '700', + fontSize: 15, + }, + secondaryButton: { + backgroundColor: theme.colors.elevation1, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + secondaryButtonText: { + color: theme.colors.highEmphasis, + }, + stickyFooter: { + paddingHorizontal: 16, + paddingTop: 10, + borderTopWidth: 1, + borderTopColor: theme.colors.elevation3, + backgroundColor: theme.colors.darkBackground, + }, + footerCard: { + backgroundColor: theme.colors.elevation2, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.elevation3, + padding: 12, + marginBottom: 10, + }, + footerTitleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, + }, + footerTitle: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.white, + }, + headerRightButton: { + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 10, + backgroundColor: theme.colors.elevation2, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + headerRightButtonText: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.highEmphasis, + }, + codeInputFocused: { + flex: 1, + minHeight: 0, + }, + cardActionsRow: { + flexDirection: 'row', + alignItems: 'center', + }, + cardActionButton: { + padding: 6, + marginRight: 6, + borderRadius: 10, + backgroundColor: theme.colors.elevation1, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + findToolbar: { + backgroundColor: theme.colors.elevation2, + borderBottomWidth: 1, + borderBottomColor: theme.colors.elevation3, + paddingHorizontal: 12, + paddingVertical: 10, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + findInput: { + flex: 1, + backgroundColor: theme.colors.elevation1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + color: theme.colors.white, + fontSize: 13, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + findCounter: { + fontSize: 12, + color: theme.colors.mediumEmphasis, + minWidth: 40, + textAlign: 'right', + fontWeight: '600', + }, + findButton: { + padding: 8, + borderRadius: 8, + backgroundColor: theme.colors.elevation1, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + findButtonActive: { + backgroundColor: theme.colors.primary + '20', + borderColor: theme.colors.primary, + }, + logItem: { + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + fontSize: 12, + marginBottom: 4, + color: theme.colors.mediumEmphasis, + }, + logError: { + color: theme.colors.error, + }, + logWarn: { + color: theme.colors.warning, + }, + logInfo: { + color: theme.colors.info, + }, + logDebug: { + color: theme.colors.lightGray, + }, + logContainer: { + backgroundColor: theme.colors.elevation2, + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.elevation3, + padding: 12, + }, + resultItem: { + backgroundColor: theme.colors.elevation2, + borderRadius: 12, + padding: 12, + marginBottom: 8, + borderWidth: 1, + borderColor: theme.colors.elevation3, + }, + resultTitle: { + fontSize: 16, + fontWeight: '600', + color: theme.colors.white, + marginBottom: 4, + }, + resultMeta: { + fontSize: 12, + color: theme.colors.mediumGray, + marginBottom: 2, + }, + resultUrl: { + fontSize: 12, + color: theme.colors.mediumEmphasis, + marginBottom: 2, + }, + emptyState: { + alignItems: 'center', + justifyContent: 'center', + padding: 32, + }, + emptyText: { + color: theme.colors.mediumGray, + marginTop: 8, + }, +}); diff --git a/src/screens/plugin-tester/types.ts b/src/screens/plugin-tester/types.ts new file mode 100644 index 0000000..a7cc93b --- /dev/null +++ b/src/screens/plugin-tester/types.ts @@ -0,0 +1,24 @@ +export type RepoScraper = { + id: string; + name?: string; + filename?: string; + enabled?: boolean; + [key: string]: any; +}; + +export type RepoManifest = { + name?: string; + scrapers?: RepoScraper[]; + [key: string]: any; +}; + +export type RepoTestStatus = 'idle' | 'running' | 'ok' | 'ok-empty' | 'fail'; + +export type RepoTestResult = { + status: RepoTestStatus; + streamsCount?: number; + error?: string; + triedUrl?: string; + logs?: string[]; + durationMs?: number; +};