mirror of
https://github.com/zoriya/flood.git
synced 2025-12-05 23:06:20 +00:00
425 lines
11 KiB
TypeScript
425 lines
11 KiB
TypeScript
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<FeedReader> = [];
|
|
rules: {
|
|
[feedID: string]: Array<Rule>;
|
|
} = {};
|
|
|
|
constructor(...args: ConstructorParameters<typeof BaseService>) {
|
|
super(...args);
|
|
|
|
this.onServicesUpdated = () => {
|
|
this.init();
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Subscribes to a feed
|
|
*
|
|
* @param {AddFeedOptions} options - An object of options...
|
|
* @return {Promise<Feed>} - Resolves with Feed or rejects with error.
|
|
*/
|
|
async addFeed({url, label, interval}: AddFeedOptions): Promise<Feed> {
|
|
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<Feed>((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<void>} - Rejects with error.
|
|
*/
|
|
async modifyFeed(id: string, {url, label, interval}: ModifyFeedOptions): Promise<void> {
|
|
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<void>((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<Rule> {
|
|
const newRule = await new Promise<Rule>((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<Feed>; rules: Array<Rule>}> {
|
|
if (this.db == null) {
|
|
return Promise.reject();
|
|
}
|
|
|
|
return new Promise<{feeds: Array<Feed>; rules: Array<Rule>}>((resolve, reject) => {
|
|
this.db.find({}, (err: Error | null, docs: Array<Feed | Rule>) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(
|
|
docs.reduce(
|
|
(memo: {feeds: Array<Feed>; rules: Array<Rule>}, 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<Array<Feed>> {
|
|
return new Promise<Array<Feed>>((resolve, reject) => {
|
|
this.db.find(id ? {_id: id} : {type: 'feed'}, (err: Error | null, feeds: Array<Feed>) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(feeds);
|
|
});
|
|
});
|
|
}
|
|
|
|
async getItems(id: string, search: string): Promise<Array<Item>> {
|
|
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<Array<string>> {
|
|
return new Promise((resolve, reject) => {
|
|
this.db.find({type: 'matchedTorrents'}, (err: Error, docs: Array<MatchedTorrents>) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(docs.reduce((matchedUrls: Array<string>, doc) => matchedUrls.concat(doc.urls), []));
|
|
});
|
|
});
|
|
}
|
|
|
|
async getRules(): Promise<Array<Rule>> {
|
|
return new Promise<Array<Rule>>((resolve, reject) => {
|
|
this.db.find({type: 'rule'}, (err: Error | null, rules: Array<Rule>) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
resolve(rules);
|
|
});
|
|
});
|
|
}
|
|
|
|
handleNewItems = (feedReaderOptions: FeedReaderOptions, feedItems: Array<FeedItem>): 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<Array<string>> => {
|
|
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<string>, urls: Array<string>) => 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<Feed | Rule>) => {
|
|
if (err) {
|
|
return;
|
|
}
|
|
|
|
// Create two arrays, one for feeds and one for rules.
|
|
const feedsSummary: {
|
|
feeds: Array<Feed>;
|
|
rules: Array<Rule>;
|
|
} = docs.reduce(
|
|
(accumulator: {feeds: Array<Feed>; rules: Array<Rule>}, 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<void> {
|
|
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;
|