diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index a69e8a3..b45e059 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -63,6 +63,7 @@ import AccountManageScreen from '../screens/AccountManageScreen';
import { useAccount } from '../contexts/AccountContext';
import { LoadingProvider, useLoading } from '../contexts/LoadingContext';
import PluginsScreen from '../screens/PluginsScreen';
+import PluginTesterScreen from '../screens/PluginTesterScreen';
import CastMoviesScreen from '../screens/CastMoviesScreen';
import UpdateScreen from '../screens/UpdateScreen';
import AISettingsScreen from '../screens/AISettingsScreen';
@@ -127,6 +128,7 @@ export type RootStackParamList = {
duration?: number;
addonId?: string;
};
+ PluginTester: undefined;
PlayerIOS: {
uri: string;
title?: string;
@@ -1628,6 +1630,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
},
}}
/>
+
{
+ 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 [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) || ' '}
+
+ );
+ };
+
+ // 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,
+ },
+ 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 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);
+ }
+ })()}
+
+
+ ))
+ )}
+
+ );
+
+ 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()
+ ) : (
+ <>
+
+ setActiveTab('code')}
+ >
+
+ Code
+
+ setActiveTab('logs')}
+ >
+
+ Logs
+
+ {logs.length}
+
+
+ setActiveTab('results')}
+ >
+
+ Results
+
+ {streams.length}
+
+
+
+
+ {activeTab === 'code' && renderCodeTab()}
+ {activeTab === 'logs' && renderLogsTab()}
+ {activeTab === 'results' && renderResultsTab()}
+ >
+ )}
+
+ );
+};
+
+export default PluginTesterScreen;
diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx
index 9d32e26..fcb60a6 100644
--- a/src/screens/PluginsScreen.tsx
+++ b/src/screens/PluginsScreen.tsx
@@ -1602,6 +1602,7 @@ const PluginsScreen: React.FC = () => {
>
{t('plugins.add_new_repo')}
+
{/* Available Plugins */}
diff --git a/src/screens/settings/ContentDiscoverySettingsScreen.tsx b/src/screens/settings/ContentDiscoverySettingsScreen.tsx
index a0279ce..4ecaf01 100644
--- a/src/screens/settings/ContentDiscoverySettingsScreen.tsx
+++ b/src/screens/settings/ContentDiscoverySettingsScreen.tsx
@@ -80,7 +80,7 @@ export const ContentDiscoverySettingsContent: React.FC
- {hasVisibleItems(['addons', 'debrid', 'plugins']) && (
+ {hasVisibleItems(['addons', 'debrid']) && (
{isItemVisible('addons') && (
}
onPress={() => navigation.navigate('Addons')}
+ isLast={!isItemVisible('debrid')}
isTablet={isTablet}
/>
)}
@@ -99,16 +100,6 @@ export const ContentDiscoverySettingsContent: React.FC }
onPress={() => navigation.navigate('DebridIntegration')}
- isTablet={isTablet}
- />
- )}
- {isItemVisible('plugins') && (
- }
- renderControl={() => }
- onPress={() => navigation.navigate('ScraperSettings')}
isLast
isTablet={isTablet}
/>
diff --git a/src/screens/settings/DeveloperSettingsScreen.tsx b/src/screens/settings/DeveloperSettingsScreen.tsx
index 8d1cef2..5ce51fe 100644
--- a/src/screens/settings/DeveloperSettingsScreen.tsx
+++ b/src/screens/settings/DeveloperSettingsScreen.tsx
@@ -94,6 +94,13 @@ const DeveloperSettingsScreen: React.FC = () => {
contentContainerStyle={[styles.scrollContent, { paddingBottom: insets.bottom + 24 }]}
>
+ navigation.navigate('PluginTester')}
+ renderControl={() => }
+ />
{
+ private async executePlugin(code: string, params: any, consoleOverride?: any): Promise {
try {
// Get URL validation setting from storage
const settingsData = await mmkvStorage.getItem('app_settings');
@@ -1326,7 +1326,7 @@ class LocalScraperService {
CryptoJS,
cheerio,
logger,
- console, // Expose console to plugins for debugging
+ consoleOverride || console, // Expose console (or override) to plugins for debugging
params,
MOVIEBOX_PRIMARY_KEY,
MOVIEBOX_TMDB_API_KEY,
@@ -1542,6 +1542,73 @@ class LocalScraperService {
}
}
+ // Test a plugin independently with log capturing.
+ // If onLog is provided, each formatted log line is emitted as it happens.
+ async testPlugin(
+ code: string,
+ params: { tmdbId: string; mediaType: string; season?: number; episode?: number },
+ options?: { onLog?: (line: string) => void }
+ ): Promise<{ streams: Stream[]; logs: string[] }> {
+ const logs: string[] = [];
+ const emit = (line: string) => {
+ logs.push(line);
+ options?.onLog?.(line);
+ };
+
+ // Create a console proxy to capture logs
+ const consoleProxy = {
+ log: (...args: any[]) => {
+ const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
+ emit(`[LOG] ${msg}`);
+ console.log('[PluginTest]', msg);
+ },
+ error: (...args: any[]) => {
+ const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
+ emit(`[ERROR] ${msg}`);
+ console.error('[PluginTest]', msg);
+ },
+ warn: (...args: any[]) => {
+ const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
+ emit(`[WARN] ${msg}`);
+ console.warn('[PluginTest]', msg);
+ },
+ info: (...args: any[]) => {
+ const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
+ emit(`[INFO] ${msg}`);
+ console.info('[PluginTest]', msg);
+ },
+ debug: (...args: any[]) => {
+ const msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ');
+ emit(`[DEBUG] ${msg}`);
+ console.debug('[PluginTest]', msg);
+ }
+ };
+
+ try {
+ const results = await this.executePlugin(code, params, consoleProxy);
+
+ // Convert results using a dummy scraper info since we don't have one for ad-hoc tests
+ const dummyScraperInfo: ScraperInfo = {
+ id: 'test-plugin',
+ name: 'Test Plugin',
+ version: '1.0.0',
+ description: 'Test',
+ filename: 'test.js',
+ supportedTypes: ['movie', 'tv'],
+ enabled: true
+ };
+
+ const streams = this.convertToStreams(results, dummyScraperInfo);
+ return { streams, logs };
+ } catch (error: any) {
+ emit(`[FATAL ERROR] ${error.message || String(error)}`);
+ if (error.stack) {
+ emit(`[STACK] ${error.stack}`);
+ }
+ return { streams: [], logs };
+ }
+ }
+
}
export const localScraperService = LocalScraperService.getInstance();