316 lines
No EOL
10 KiB
TypeScript
316 lines
No EOL
10 KiB
TypeScript
import { NativeModules, NativeEventEmitter, EmitterSubscription, Platform } from 'react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { logger } from '../utils/logger';
|
|
|
|
// Mock implementation for Expo environment
|
|
const MockTorrentStreamModule = {
|
|
TORRENT_PROGRESS_EVENT: 'torrentProgress',
|
|
startStream: async (magnetUri: string): Promise<string> => {
|
|
logger.log('[MockTorrentService] Starting mock stream for:', magnetUri);
|
|
// Return a fake URL that would look like a file path
|
|
return `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`;
|
|
},
|
|
stopStream: () => {
|
|
logger.log('[MockTorrentService] Stopping mock stream');
|
|
},
|
|
fileExists: async (path: string): Promise<boolean> => {
|
|
logger.log('[MockTorrentService] Checking if file exists:', path);
|
|
return false;
|
|
},
|
|
// Add these methods to satisfy NativeModule interface
|
|
addListener: () => {},
|
|
removeListeners: () => {}
|
|
};
|
|
|
|
// Create an EventEmitter that doesn't rely on native modules
|
|
class MockEventEmitter {
|
|
private listeners: Map<string, Function[]> = new Map();
|
|
|
|
addListener(eventName: string, callback: Function): { remove: () => void } {
|
|
if (!this.listeners.has(eventName)) {
|
|
this.listeners.set(eventName, []);
|
|
}
|
|
this.listeners.get(eventName)?.push(callback);
|
|
|
|
return {
|
|
remove: () => {
|
|
const eventListeners = this.listeners.get(eventName);
|
|
if (eventListeners) {
|
|
const index = eventListeners.indexOf(callback);
|
|
if (index !== -1) {
|
|
eventListeners.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
emit(eventName: string, ...args: any[]) {
|
|
const eventListeners = this.listeners.get(eventName);
|
|
if (eventListeners) {
|
|
eventListeners.forEach(listener => listener(...args));
|
|
}
|
|
}
|
|
|
|
removeAllListeners(eventName: string) {
|
|
this.listeners.delete(eventName);
|
|
}
|
|
}
|
|
|
|
// Use the mock module and event emitter since we're in Expo
|
|
const TorrentStreamModule = Platform.OS === 'web' ? null : MockTorrentStreamModule;
|
|
const mockEmitter = new MockEventEmitter();
|
|
|
|
const CACHE_KEY = '@torrent_cache_mapping';
|
|
|
|
export interface TorrentProgress {
|
|
bufferProgress: number;
|
|
downloadSpeed: number;
|
|
progress: number;
|
|
seeds: number;
|
|
}
|
|
|
|
export interface TorrentStreamEvents {
|
|
onProgress?: (progress: TorrentProgress) => void;
|
|
}
|
|
|
|
class TorrentService {
|
|
private eventEmitter: NativeEventEmitter | MockEventEmitter;
|
|
private progressListener: EmitterSubscription | { remove: () => void } | null = null;
|
|
private static TORRENT_PROGRESS_EVENT = TorrentStreamModule?.TORRENT_PROGRESS_EVENT || 'torrentProgress';
|
|
private cachedTorrents: Map<string, string> = new Map(); // Map of magnet URI to cached file path
|
|
private initialized: boolean = false;
|
|
private mockProgressInterval: NodeJS.Timeout | null = null;
|
|
|
|
constructor() {
|
|
// Use mock event emitter since we're in Expo
|
|
this.eventEmitter = mockEmitter;
|
|
this.loadCache();
|
|
}
|
|
|
|
private async loadCache() {
|
|
try {
|
|
const cacheData = await AsyncStorage.getItem(CACHE_KEY);
|
|
if (cacheData) {
|
|
const cacheMap = JSON.parse(cacheData);
|
|
this.cachedTorrents = new Map(Object.entries(cacheMap));
|
|
logger.log('[TorrentService] Loaded cache mapping:', this.cachedTorrents);
|
|
}
|
|
this.initialized = true;
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error loading cache:', error);
|
|
this.initialized = true;
|
|
}
|
|
}
|
|
|
|
private async saveCache() {
|
|
try {
|
|
const cacheData = Object.fromEntries(this.cachedTorrents);
|
|
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
|
logger.log('[TorrentService] Saved cache mapping');
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error saving cache:', error);
|
|
}
|
|
}
|
|
|
|
public async startStream(magnetUri: string, events?: TorrentStreamEvents): Promise<string> {
|
|
// Wait for cache to be loaded
|
|
while (!this.initialized) {
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
|
|
try {
|
|
// First check if we have this torrent cached
|
|
const cachedPath = this.cachedTorrents.get(magnetUri);
|
|
if (cachedPath) {
|
|
logger.log('[TorrentService] Found cached torrent file:', cachedPath);
|
|
|
|
// In mock mode, we'll always use the cached path if available
|
|
if (!TorrentStreamModule) {
|
|
// Still set up progress listeners for cached content
|
|
this.setupProgressListener(events);
|
|
|
|
// Simulate progress for cached content too
|
|
if (events?.onProgress) {
|
|
this.startMockProgressUpdates(events.onProgress);
|
|
}
|
|
|
|
return cachedPath;
|
|
}
|
|
|
|
// For native implementations, verify the file still exists
|
|
try {
|
|
const exists = await TorrentStreamModule.fileExists(cachedPath);
|
|
if (exists) {
|
|
logger.log('[TorrentService] Using cached torrent file');
|
|
|
|
// Setup progress listener if callback provided
|
|
this.setupProgressListener(events);
|
|
|
|
// Start the stream in cached mode
|
|
await TorrentStreamModule.startStream(magnetUri);
|
|
return cachedPath;
|
|
} else {
|
|
logger.log('[TorrentService] Cached file not found, removing from cache');
|
|
this.cachedTorrents.delete(magnetUri);
|
|
await this.saveCache();
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error checking cached file:', error);
|
|
// Continue to download again if there's an error
|
|
}
|
|
}
|
|
|
|
// First stop any existing stream
|
|
await this.stopStreamAndWait();
|
|
|
|
// Setup progress listener if callback provided
|
|
this.setupProgressListener(events);
|
|
|
|
// If we're in mock mode (Expo), simulate progress
|
|
if (!TorrentStreamModule) {
|
|
logger.log('[TorrentService] Using mock implementation');
|
|
const mockUrl = `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`;
|
|
|
|
// Save to cache
|
|
this.cachedTorrents.set(magnetUri, mockUrl);
|
|
await this.saveCache();
|
|
|
|
// Start mock progress updates if events callback provided
|
|
if (events?.onProgress) {
|
|
this.startMockProgressUpdates(events.onProgress);
|
|
}
|
|
|
|
// Return immediately with mock URL
|
|
return mockUrl;
|
|
}
|
|
|
|
// Start the actual stream if native module is available
|
|
logger.log('[TorrentService] Starting torrent stream');
|
|
const filePath = await TorrentStreamModule.startStream(magnetUri);
|
|
|
|
// Save to cache
|
|
if (filePath) {
|
|
logger.log('[TorrentService] Adding path to cache:', filePath);
|
|
this.cachedTorrents.set(magnetUri, filePath);
|
|
await this.saveCache();
|
|
}
|
|
|
|
return filePath;
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error starting torrent stream:', error);
|
|
this.cleanup(); // Clean up on error
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private setupProgressListener(events?: TorrentStreamEvents) {
|
|
if (events?.onProgress) {
|
|
logger.log('[TorrentService] Setting up progress listener');
|
|
this.progressListener = this.eventEmitter.addListener(
|
|
TorrentService.TORRENT_PROGRESS_EVENT,
|
|
(progress) => {
|
|
logger.log('[TorrentService] Progress event received:', progress);
|
|
if (events.onProgress) {
|
|
events.onProgress(progress);
|
|
}
|
|
}
|
|
);
|
|
} else {
|
|
logger.log('[TorrentService] No progress callback provided');
|
|
}
|
|
}
|
|
|
|
private startMockProgressUpdates(onProgress: (progress: TorrentProgress) => void) {
|
|
// Clear any existing interval
|
|
if (this.mockProgressInterval) {
|
|
clearInterval(this.mockProgressInterval);
|
|
}
|
|
|
|
// Start at 0% progress
|
|
let mockProgress = 0;
|
|
|
|
// Update every second
|
|
this.mockProgressInterval = setInterval(() => {
|
|
// Increase by 10% each time
|
|
mockProgress += 10;
|
|
|
|
// Create mock progress object
|
|
const progress: TorrentProgress = {
|
|
bufferProgress: mockProgress,
|
|
downloadSpeed: 1024 * 1024 * (1 + Math.random()), // Random speed around 1MB/s
|
|
progress: mockProgress,
|
|
seeds: Math.floor(5 + Math.random() * 20), // Random seed count between 5-25
|
|
};
|
|
|
|
// Emit the event instead of directly calling callback
|
|
if (this.eventEmitter instanceof MockEventEmitter) {
|
|
(this.eventEmitter as MockEventEmitter).emit(TorrentService.TORRENT_PROGRESS_EVENT, progress);
|
|
} else {
|
|
// Fallback to direct callback if needed
|
|
onProgress(progress);
|
|
}
|
|
|
|
// If we reach 100%, clear the interval
|
|
if (mockProgress >= 100) {
|
|
if (this.mockProgressInterval) {
|
|
clearInterval(this.mockProgressInterval);
|
|
this.mockProgressInterval = null;
|
|
}
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
public async stopStreamAndWait(): Promise<void> {
|
|
logger.log('[TorrentService] Stopping stream and waiting for cleanup');
|
|
this.cleanup();
|
|
|
|
if (TorrentStreamModule) {
|
|
try {
|
|
TorrentStreamModule.stopStream();
|
|
// Wait a moment to ensure native side has cleaned up
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error stopping torrent stream:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
public stopStream(): void {
|
|
try {
|
|
logger.log('[TorrentService] Stopping stream and cleaning up');
|
|
this.cleanup();
|
|
|
|
if (TorrentStreamModule) {
|
|
TorrentStreamModule.stopStream();
|
|
}
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error stopping torrent stream:', error);
|
|
// Still attempt cleanup even if stop fails
|
|
this.cleanup();
|
|
}
|
|
}
|
|
|
|
private cleanup(): void {
|
|
logger.log('[TorrentService] Cleaning up event listeners and intervals');
|
|
|
|
// Clean up progress listener
|
|
if (this.progressListener) {
|
|
try {
|
|
this.progressListener.remove();
|
|
} catch (error) {
|
|
logger.error('[TorrentService] Error removing progress listener:', error);
|
|
} finally {
|
|
this.progressListener = null;
|
|
}
|
|
}
|
|
|
|
// Clean up mock progress interval
|
|
if (this.mockProgressInterval) {
|
|
clearInterval(this.mockProgressInterval);
|
|
this.mockProgressInterval = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const torrentService = new TorrentService();
|