import path from 'path'; import Datastore from 'nedb'; import type {FeedItem} from 'feedsub'; import BaseService from './BaseService'; import config from '../../config'; import FeedReader from '../models/FeedReader'; import {getFeedItemsMatchingRules, getTorrentUrlsFromFeedItem} from '../util/feedUtil'; import type {AddFeedOptions, AddRuleOptions, ModifyFeedOptions} from '../../shared/types/api/feed-monitor'; import type {Feed, Item, MatchedTorrents, Rule} from '../../shared/types/Feed'; import type {FeedReaderOptions} from '../models/FeedReader'; class FeedService extends BaseService { db = this.loadDatabase(); feedReaders: Array = []; rules: { [feedID: string]: Array; } = {}; constructor(...args: ConstructorParameters) { super(...args); this.onServicesUpdated = () => { this.init(); }; } /** * Subscribes to a feed * * @param {AddFeedOptions} options - An object of options... * @return {Promise} - Resolves with Feed or rejects with error. */ async addFeed({url, label, interval}: AddFeedOptions): Promise { if (typeof url !== 'string' || typeof label !== 'string' || typeof interval !== 'number') { return Promise.reject(); } if (this.db == null) { return Promise.reject(); } const newFeed = await new Promise((resolve, reject) => { this.db.insert({type: 'feed', url, label, interval}, (err, newDoc) => { if (err) { reject(err); return; } resolve(newDoc as Feed); }); }); this.startNewFeed(newFeed); return newFeed; } /** * Modifies the options of a feed subscription * * @param {string} id - Unique ID of the feed * @param {ModifyFeedOptions} options - An object of options... * @return {Promise} - Rejects with error. */ async modifyFeed(id: string, {url, label, interval}: ModifyFeedOptions): Promise { if (url != null && typeof url !== 'string') { return Promise.reject(); } if (label != null && typeof label !== 'string') { return Promise.reject(); } if (interval != null && typeof interval !== 'number') { return Promise.reject(); } const modifiedFeedReader = this.feedReaders.find((feedReader) => feedReader.getOptions().feedID === id); if (modifiedFeedReader == null || this.db == null) { return Promise.reject(); } // JSON.parse(JSON.stringify()) to remove undefined properties modifiedFeedReader.stopReader(); modifiedFeedReader.modify(JSON.parse(JSON.stringify({feedLabel: label, url, interval}))); return new Promise((resolve, reject) => { this.db.update({_id: id}, {$set: JSON.parse(JSON.stringify({label, url, interval}))}, {}, (err) => { if (err) { reject(err); return; } resolve(); }); }); } async addRule(options: AddRuleOptions): Promise { const newRule = await new Promise((resolve, reject) => { this.db.insert({type: 'rule', ...options}, (err, newDoc) => { if (err) { reject(err); return; } resolve(newDoc as Rule); }); }); if (this.rules[newRule.feedIDs[0]] == null) { this.rules[newRule.feedIDs[0]] = []; } this.rules[newRule.feedIDs[0]].push(newRule); const associatedFeedReader = this.feedReaders.find( (feedReader) => feedReader.getOptions().feedID === newRule.feedIDs[0], ); if (associatedFeedReader) { this.handleNewItems(associatedFeedReader.getOptions(), associatedFeedReader.getItems()); } return newRule; } async getAll(): Promise<{feeds: Array; rules: Array}> { if (this.db == null) { return Promise.reject(); } return new Promise<{feeds: Array; rules: Array}>((resolve, reject) => { this.db.find({}, (err: Error | null, docs: Array) => { if (err) { reject(err); return; } resolve( docs.reduce( (memo: {feeds: Array; rules: Array}, item) => { if (item.type === 'feed') { memo.feeds.push(item); } if (item.type === 'rule') { memo.rules.push(item); } return memo; }, {feeds: [], rules: []}, ), ); }); }); } async getFeeds(id?: string): Promise> { return new Promise>((resolve, reject) => { this.db.find(id ? {_id: id} : {type: 'feed'}, (err: Error | null, feeds: Array) => { if (err) { reject(err); return; } resolve(feeds); }); }); } async getItems(id: string, search: string): Promise> { const selectedFeedReader = this.feedReaders.find((feedReader) => feedReader.getOptions().feedID === id); if (selectedFeedReader == null) { return Promise.reject(); } const items = selectedFeedReader.getItems(); const filteredItems = search ? items.filter((item) => { if (typeof item.title === 'string') { return item.title.toLowerCase().includes(search.toLowerCase()); } return false; }) : items; return filteredItems.map((item) => { return { title: typeof item.title === 'string' ? item.title : 'Unknown', urls: getTorrentUrlsFromFeedItem(item), }; }); } async getPreviouslyMatchedUrls(): Promise> { return new Promise((resolve, reject) => { this.db.find({type: 'matchedTorrents'}, (err: Error, docs: Array) => { if (err) { reject(err); return; } resolve(docs.reduce((matchedUrls: Array, doc) => matchedUrls.concat(doc.urls), [])); }); }); } async getRules(): Promise> { return new Promise>((resolve, reject) => { this.db.find({type: 'rule'}, (err: Error | null, rules: Array) => { if (err) { reject(err); return; } resolve(rules); }); }); } handleNewItems = (feedReaderOptions: FeedReaderOptions, feedItems: Array): void => { this.getPreviouslyMatchedUrls() .then((previouslyMatchedUrls) => { const {feedID, feedLabel} = feedReaderOptions; const applicableRules = this.rules[feedID]; if (!applicableRules) return; const itemsMatchingRules = getFeedItemsMatchingRules(feedItems, applicableRules); const itemsToDownload = itemsMatchingRules.filter((item) => item.urls.some((url) => !previouslyMatchedUrls.includes(url)), ); if (itemsToDownload.length === 0) { return; } Promise.all( itemsToDownload.map( async (item): Promise> => { const {urls, destination, start, tags, ruleID} = item; await this.services?.clientGatewayService ?.addTorrentsByURL({ urls, cookies: {}, destination, tags, start, isBasePath: false, isCompleted: false, }) .then(() => { this.db.update({_id: feedID}, {$inc: {count: 1}}, {upsert: true}); this.db.update({_id: ruleID}, {$inc: {count: 1}}, {upsert: true}); }) .catch(console.error); return urls; }, ), ).then((ArrayOfURLArrays) => { const addedURLs = ArrayOfURLArrays.reduce( (URLArray: Array, urls: Array) => URLArray.concat(urls), [], ); this.db.update({type: 'matchedTorrents'}, {$push: {urls: {$each: addedURLs}}}, {upsert: true}); this.services?.notificationService.addNotification( itemsToDownload.map((item) => ({ id: 'notification.feed.torrent.added', data: { title: item.matchTitle, feedLabel, ruleLabel: item.ruleLabel, }, })), ); this.services?.torrentService.fetchTorrentList(); }); }) .catch(console.error); }; private init() { this.db.find({}, (err: Error, docs: Array) => { if (err) { return; } // Create two arrays, one for feeds and one for rules. const feedsSummary: { feeds: Array; rules: Array; } = docs.reduce( (accumulator: {feeds: Array; rules: Array}, doc) => { if (doc.type === 'feed') { accumulator.feeds.push(doc); } if (doc.type === 'rule') { accumulator.rules.push(doc); } return accumulator; }, {feeds: [], rules: []}, ); // Add all download rules to the local state. feedsSummary.rules.forEach((rule) => { const feedID = rule?.feedIDs?.[0]; // Migration if (feedID == null) { this.removeItem(rule._id).then(() => { const oldFeedID = ((rule as unknown) as {feedID: string})?.feedID; if (oldFeedID != null) { this.addRule({ destination: rule.destination, tags: rule.tags, label: rule.label, feedIDs: [oldFeedID], field: rule.field, match: rule.match, exclude: rule.exclude, startOnLoad: rule.startOnLoad, isBasePath: rule.isBasePath, }); } }); return; } if (this.rules[feedID] == null) { this.rules[feedID] = []; } this.rules[feedID].push(rule); }); // Initiate all feeds. feedsSummary.feeds.forEach((feed) => { this.startNewFeed(feed); }); }); } private loadDatabase(): Datastore { if (this.db != null) { return this.db; } const db = new Datastore({ autoload: true, filename: path.join(config.dbPath, this.user._id, 'settings', 'feeds.db'), }); return db; } async removeItem(id: string): Promise { let feedReaderToRemoveIndex = -1; const feedReaderToRemove = this.feedReaders.find((feedReader, index) => { if (feedReader.getOptions().feedID === id) { feedReaderToRemoveIndex = index; return true; } return false; }); if (feedReaderToRemove != null) { feedReaderToRemove.stopReader(); this.feedReaders.splice(feedReaderToRemoveIndex, 1); } return new Promise((resolve, reject) => { this.db.remove({_id: id}, {}, (err) => { if (err) { reject(err); return; } resolve(); }); }); } private startNewFeed(feed: Feed) { const {_id: feedID, label: feedLabel, url, interval} = feed; if (typeof feedID !== 'string' || typeof url !== 'string') { return false; } if (typeof interval !== 'number') { return false; } this.feedReaders.push( new FeedReader({ feedID, feedLabel, url, interval, maxHistory: 100, onNewItems: this.handleNewItems, }), ); return true; } } export default FeedService;