transmission-rss-manager/rss-implementation.js
2025-02-26 08:48:34 +01:00

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;
}