587 lines
16 KiB
JavaScript
587 lines
16 KiB
JavaScript
// 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;
|
|
}
|