// rssFeedManager.js - Manages RSS feeds and parsing const xml2js = require('xml2js'); const fetch = require('node-fetch'); const fs = require('fs').promises; const path = require('path'); class RssFeedManager { constructor(config = {}) { this.config = { feeds: [], updateIntervalMinutes: 60, maxItemsPerFeed: 100, autoDownload: false, downloadFilters: {}, ...config }; this.parser = new xml2js.Parser({ explicitArray: false, normalize: true, normalizeTags: true }); this.feedItems = []; this.updateIntervalId = null; } // Start RSS feed monitoring start() { if (this.updateIntervalId) { this.stop(); } this.updateIntervalId = setInterval(() => { this.updateAllFeeds(); }, this.config.updateIntervalMinutes * 60 * 1000); console.log('RSS feed manager started'); this.updateAllFeeds(); // Do an initial update return this; } // Stop RSS feed monitoring stop() { if (this.updateIntervalId) { clearInterval(this.updateIntervalId); this.updateIntervalId = null; } console.log('RSS feed manager stopped'); return this; } // Update all feeds async updateAllFeeds() { console.log('Updating all RSS feeds'); const results = { total: this.config.feeds.length, success: 0, failed: 0, newItems: 0 }; for (const feed of this.config.feeds) { try { const newItems = await this.updateFeed(feed); results.success++; results.newItems += newItems; } catch (error) { console.error(`Error updating feed ${feed.name || feed.url}:`, error); results.failed++; } } console.log(`Feed update complete: ${results.success}/${results.total} feeds updated, ${results.newItems} new items`); // Save updated feed items to database await this.saveItems(); return results; } // Update a single feed async updateFeed(feed) { console.log(`Updating feed: ${feed.name || feed.url}`); try { const response = await fetch(feed.url); if (!response.ok) { throw new Error(`Failed to fetch RSS feed: ${response.statusText}`); } const xml = await response.text(); const result = await this.parseXml(xml); // Extract and format feed items const items = this.extractItems(result, feed); // Count new items (not already in our database) let newItemCount = 0; // Process each item for (const item of items) { // Generate a unique ID for the item const itemId = this.generateItemId(item); // Check if item already exists const existingItem = this.feedItems.find(i => i.id === itemId); if (!existingItem) { // Add feed info to item item.feedId = feed.id; item.feedName = feed.name; item.id = itemId; item.firstSeen = new Date().toISOString(); item.downloaded = false; // Add to items array this.feedItems.push(item); newItemCount++; // Auto-download if enabled and item matches filter if (feed.autoDownload && this.matchesFilter(item, feed.filters)) { this.downloadItem(item); } } } console.log(`Feed updated: ${feed.name || feed.url}, ${newItemCount} new items`); return newItemCount; } catch (error) { console.error(`Error updating feed: ${error.message}`); throw error; } } // Parse XML data async parseXml(xml) { return new Promise((resolve, reject) => { this.parser.parseString(xml, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); } // Extract items from parsed feed extractItems(parsedFeed, feedConfig) { const items = []; // Handle different feed formats let feedItems = []; if (parsedFeed.rss && parsedFeed.rss.channel) { // Standard RSS feedItems = parsedFeed.rss.channel.item || []; if (!Array.isArray(feedItems)) { feedItems = [feedItems]; } } else if (parsedFeed.feed && parsedFeed.feed.entry) { // Atom format feedItems = parsedFeed.feed.entry || []; if (!Array.isArray(feedItems)) { feedItems = [feedItems]; } } // Process each item for (const item of feedItems) { const processedItem = this.processItem(item, feedConfig); if (processedItem) { items.push(processedItem); } } return items; } // Process a single feed item processItem(item, feedConfig) { // Extract basic fields const processed = { title: item.title || '', description: item.description || item.summary || '', pubDate: item.pubdate || item.published || item.date || new Date().toISOString(), size: 0, seeders: 0, leechers: 0, quality: 'Unknown', category: 'Unknown' }; // Set download link if (item.link) { if (typeof item.link === 'string') { processed.link = item.link; } else if (item.link.$ && item.link.$.href) { processed.link = item.link.$.href; } else if (Array.isArray(item.link) && item.link.length > 0) { const magnetLink = item.link.find(link => (typeof link === 'string' && link.startsWith('magnet:')) || (link.$ && link.$.href && link.$.href.startsWith('magnet:')) ); if (magnetLink) { processed.link = typeof magnetLink === 'string' ? magnetLink : magnetLink.$.href; } else { processed.link = typeof item.link[0] === 'string' ? item.link[0] : (item.link[0].$ ? item.link[0].$.href : ''); } } } else if (item.enclosure && item.enclosure.url) { processed.link = item.enclosure.url; } // Skip item if no link found if (!processed.link) { return null; } // Try to extract size information if (item.size) { processed.size = parseInt(item.size, 10) || 0; } else if (item.enclosure && item.enclosure.length) { processed.size = parseInt(item.enclosure.length, 10) || 0; } else if (item['torrent:contentlength']) { processed.size = parseInt(item['torrent:contentlength'], 10) || 0; } // Convert size to MB if it's in bytes if (processed.size > 1000000) { processed.size = Math.round(processed.size / 1000000); } // Try to extract seeders/leechers if (item.seeders || item['torrent:seeds']) { processed.seeders = parseInt(item.seeders || item['torrent:seeds'], 10) || 0; } if (item.leechers || item['torrent:peers']) { processed.leechers = parseInt(item.leechers || item['torrent:peers'], 10) || 0; } // Try to determine category if (item.category) { if (Array.isArray(item.category)) { processed.category = item.category[0] || 'Unknown'; } else { processed.category = item.category; } } else if (feedConfig.defaultCategory) { processed.category = feedConfig.defaultCategory; } // Detect quality from title processed.quality = this.detectQuality(processed.title); return processed; } // Detect quality from title detectQuality(title) { const lowerTitle = title.toLowerCase(); // Quality patterns const qualityPatterns = [ { regex: /\b(2160p|uhd|4k)\b/i, quality: '2160p/4K' }, { regex: /\b(1080p|fullhd|fhd)\b/i, quality: '1080p' }, { regex: /\b(720p|hd)\b/i, quality: '720p' }, { regex: /\b(bluray|bdremux|bdrip)\b/i, quality: 'BluRay' }, { regex: /\b(webdl|web-dl|webrip)\b/i, quality: 'WebDL' }, { regex: /\b(dvdrip|dvd-rip)\b/i, quality: 'DVDRip' }, { regex: /\b(hdtv)\b/i, quality: 'HDTV' }, { regex: /\b(hdtc|hd-tc)\b/i, quality: 'HDTC' }, { regex: /\b(hdts|hd-ts|hdcam)\b/i, quality: 'HDTS' }, { regex: /\b(cam|camrip)\b/i, quality: 'CAM' } ]; for (const pattern of qualityPatterns) { if (pattern.regex.test(lowerTitle)) { return pattern.quality; } } return 'Unknown'; } // Generate a unique ID for an item generateItemId(item) { // Use hash of title + link as ID const hash = require('crypto').createHash('md5'); hash.update(item.title + item.link); return hash.digest('hex'); } // Check if an item matches a filter matchesFilter(item, filters) { if (!filters) return true; // Name contains filter if (filters.nameContains && !item.title.toLowerCase().includes(filters.nameContains.toLowerCase())) { return false; } // Size filters if (filters.minSize && item.size < filters.minSize) { return false; } if (filters.maxSize && filters.maxSize > 0 && item.size > filters.maxSize) { return false; } // Category filter if (filters.includeCategory && !item.category.toLowerCase().includes(filters.includeCategory.toLowerCase())) { return false; } // Exclude terms if (filters.excludeTerms) { const terms = filters.excludeTerms.toLowerCase().split(',').map(t => t.trim()); if (terms.some(term => item.title.toLowerCase().includes(term))) { return false; } } // Unwanted formats if (filters.unwantedFormats) { const formats = filters.unwantedFormats.toLowerCase().split(',').map(f => f.trim()); if (formats.some(format => item.title.toLowerCase().includes(format) || (item.quality && item.quality.toLowerCase() === format.toLowerCase()))) { return false; } } // Minimum quality if (filters.minimumQuality) { const qualityRanks = { 'unknown': 0, 'cam': 0, 'ts': 0, 'hdts': 0, 'tc': 0, 'hdtc': 0, 'dvdscr': 1, 'webrip': 1, 'webdl': 1, 'dvdrip': 2, 'hdtv': 2, '720p': 3, 'hd': 3, '1080p': 4, 'fullhd': 4, 'bluray': 4, '2160p': 5, '4k': 5, 'uhd': 5 }; const minQualityRank = qualityRanks[filters.minimumQuality.toLowerCase()] || 0; const itemQualityRank = this.getQualityRank(item.quality); if (itemQualityRank < minQualityRank) { return false; } } // Minimum seeders if (filters.minimumSeeders && item.seeders < filters.minimumSeeders) { return false; } // Passed all filters return true; } // Calculate quality rank for an item getQualityRank(quality) { if (!quality) return 0; const qualityStr = quality.toLowerCase(); if (qualityStr.includes('2160p') || qualityStr.includes('4k') || qualityStr.includes('uhd')) { return 5; } if (qualityStr.includes('1080p') || qualityStr.includes('fullhd') || (qualityStr.includes('bluray') && !qualityStr.includes('720p'))) { return 4; } if (qualityStr.includes('720p') || qualityStr.includes('hd')) { return 3; } if (qualityStr.includes('dvdrip') || qualityStr.includes('sdbd')) { return 2; } if (qualityStr.includes('webrip') || qualityStr.includes('webdl') || qualityStr.includes('web-dl')) { return 1; } // Low quality or unknown return 0; } // Download an item (add to Transmission) async downloadItem(item, transmissionClient) { if (!transmissionClient) { console.error('No Transmission client provided for download'); return { success: false, message: 'No Transmission client provided' }; } try { // Add the torrent to Transmission const result = await new Promise((resolve, reject) => { transmissionClient.addUrl(item.link, (err, result) => { if (err) { reject(err); } else { resolve(result); } }); }); // Mark the item as downloaded const existingItem = this.feedItems.find(i => i.id === item.id); if (existingItem) { existingItem.downloaded = true; existingItem.downloadDate = new Date().toISOString(); existingItem.transmissionId = result.id || result.hashString || null; } // Save updated items await this.saveItems(); return { success: true, result }; } catch (error) { console.error(`Error downloading item: ${error.message}`); return { success: false, message: error.message }; } } // Save items to persistent storage async saveItems() { try { // In Node.js environment const dbPath = path.join(__dirname, 'rss-items.json'); await fs.writeFile(dbPath, JSON.stringify(this.feedItems, null, 2), 'utf8'); return true; } catch (error) { console.error('Error saving RSS items:', error); return false; } } // Load items from persistent storage async loadItems() { try { // In Node.js environment const dbPath = path.join(__dirname, 'rss-items.json'); try { const data = await fs.readFile(dbPath, 'utf8'); this.feedItems = JSON.parse(data); } catch (err) { if (err.code !== 'ENOENT') { throw err; } // File doesn't exist, use empty array this.feedItems = []; } return true; } catch (error) { console.error('Error loading RSS items:', error); return false; } } // Get all feed items getAllItems() { return [...this.feedItems]; } // Get undownloaded feed items getUndownloadedItems() { return this.feedItems.filter(item => !item.downloaded); } // Filter items based on criteria filterItems(filters) { return this.feedItems.filter(item => this.matchesFilter(item, filters)); } // Add a new feed addFeed(feed) { // Generate an ID if not provided if (!feed.id) { feed.id = `feed-${Date.now()}`; } // Add the feed to config this.config.feeds.push(feed); // Update the feed immediately this.updateFeed(feed); return feed; } // Remove a feed removeFeed(feedId) { const index = this.config.feeds.findIndex(f => f.id === feedId); if (index !== -1) { this.config.feeds.splice(index, 1); return true; } return false; } // Update feed configuration updateFeedConfig(feedId, updates) { const feed = this.config.feeds.find(f => f.id === feedId); if (feed) { Object.assign(feed, updates); return true; } return false; } // Get all feeds getAllFeeds() { return [...this.config.feeds]; } // Save configuration async saveConfig() { try { // In Node.js environment const configPath = path.join(__dirname, 'rss-config.json'); await fs.writeFile(configPath, JSON.stringify(this.config, null, 2), 'utf8'); return true; } catch (error) { console.error('Error saving RSS config:', error); return false; } } // Load configuration async loadConfig() { try { // In Node.js environment const configPath = path.join(__dirname, 'rss-config.json'); try { const data = await fs.readFile(configPath, 'utf8'); const loadedConfig = JSON.parse(data); this.config = { ...this.config, ...loadedConfig }; } catch (err) { if (err.code !== 'ENOENT') { throw err; } // Config file doesn't exist, use defaults } return true; } catch (error) { console.error('Error loading RSS config:', error); return false; } } } // Export for Node.js or browser if (typeof module !== 'undefined' && module.exports) { module.exports = RssFeedManager; } else if (typeof window !== 'undefined') { window.RssFeedManager = RssFeedManager; }