diff --git a/client/src/javascript/actions/WatchActions.ts b/client/src/javascript/actions/WatchActions.ts new file mode 100644 index 000000000..10fb4b904 --- /dev/null +++ b/client/src/javascript/actions/WatchActions.ts @@ -0,0 +1,37 @@ +import axios from 'axios'; + +import ConfigStore from '@client/stores/ConfigStore'; +import WatchStore from '@client/stores/WatchStore'; + +import type {AddWatchOptions, ModifyWatchOptions} from '@shared/types/api/watch-monitor'; +import {WatchedDirectory} from '@shared/types/Watch'; + +const {baseURI} = ConfigStore; + +const WatchActions = { + addWatch: (options: AddWatchOptions) => + axios.put(`${baseURI}api/watch-monitor`, options).then(() => WatchActions.fetchWatchMonitors()), + + modifyWatch: (id: string, options: ModifyWatchOptions) => + axios.patch(`${baseURI}api/watch-monitor/${id}`, options).then(() => WatchActions.fetchWatchMonitors()), + + fetchWatchMonitors: () => + axios.get>(`${baseURI}api/watch-monitor`).then( + ({data}) => { + WatchStore.handleWatchedDirectoryFetchSuccess(data); + }, + () => { + // do nothing. + }, + ), + + removeWatchMonitors: (id: string) => + axios.delete(`${baseURI}api/watch-monitor/${id}`).then( + () => WatchActions.fetchWatchMonitors(), + () => { + // do nothing. + }, + ), +} as const; + +export default WatchActions; diff --git a/client/src/javascript/components/modals/Modals.tsx b/client/src/javascript/components/modals/Modals.tsx index 54176bcea..c9cd711a5 100644 --- a/client/src/javascript/components/modals/Modals.tsx +++ b/client/src/javascript/components/modals/Modals.tsx @@ -16,6 +16,7 @@ import TorrentDetailsModal from './torrent-details-modal/TorrentDetailsModal'; import UIStore from '../../stores/UIStore'; import type {Modal} from '../../stores/UIStore'; +import WatchesModal from '@client/components/modals/fwatch-modal/WatchesModal'; const createModal = (id: Modal['id']): React.ReactNode => { switch (id) { @@ -25,6 +26,8 @@ const createModal = (id: Modal['id']): React.ReactNode => { return ; case 'feeds': return ; + case 'watches': + return ; case 'generate-magnet': return ; case 'move-torrents': diff --git a/client/src/javascript/components/modals/fwatch-modal/WatchList.tsx b/client/src/javascript/components/modals/fwatch-modal/WatchList.tsx new file mode 100644 index 000000000..d01ce3dc7 --- /dev/null +++ b/client/src/javascript/components/modals/fwatch-modal/WatchList.tsx @@ -0,0 +1,98 @@ +import {FC} from 'react'; +import {observer} from 'mobx-react'; +import {Trans} from '@lingui/react'; + +import {Close, Edit} from '@client/ui/icons'; + +import {WatchedDirectory} from '@shared/types/Watch'; +import WatchStore from '@client/stores/WatchStore'; + +interface FeedListProps { + currentWatch: WatchedDirectory | null; + onSelect: (watcher: WatchedDirectory) => void; + onRemove: (watcher: WatchedDirectory) => void; +} + +const WatchList: FC = observer( + ({currentWatch, onSelect, onRemove}: FeedListProps) => { + const {watchedDirectories} = WatchStore; + + if (watchedDirectories.length === 0) { + return ( +
    +
  • + +
  • +
+ ); + } + + return ( +
    + {watchedDirectories.map((watcher) => { + const matchedCount = watcher.count || 0; + + return ( +
  • +
    +
      +
    • + {watcher.label} +
    • +
    • + +
    • + {watcher === currentWatch && ( +
    • + Modifying +
    • + )} +
    + +
    + + +
  • + ); + })} +
+ ); + }, +); + +export default WatchList; diff --git a/client/src/javascript/components/modals/fwatch-modal/WatchesForm.tsx b/client/src/javascript/components/modals/fwatch-modal/WatchesForm.tsx new file mode 100644 index 000000000..fd4c564bd --- /dev/null +++ b/client/src/javascript/components/modals/fwatch-modal/WatchesForm.tsx @@ -0,0 +1,86 @@ +import {FC, useRef} from 'react'; +import {Trans, useLingui} from '@lingui/react'; + +import {Button, FormRow, FormRowGroup, Textbox} from '@client/ui'; + +import {WatchedDirectory} from '@shared/types/Watch'; +import TagSelect from '@client/components/general/form-elements/TagSelect'; +import SettingStore from '@client/stores/SettingStore'; +import FilesystemBrowserTextbox from '@client/components/general/form-elements/FilesystemBrowserTextbox'; + +interface WatchFormProps { + currentWatch: WatchedDirectory | null; + defaultWatch: Pick; + isSubmitting: boolean; + onCancel: () => void; +} + +const WatchesForm: FC = ({ + currentWatch, + defaultWatch, + isSubmitting, + onCancel +}: WatchFormProps) => { + const {i18n} = useLingui(); + + const dirTextboxRef = useRef(null); + const destTextboxRef = useRef(null); + const tagsTextboxRef = useRef(null); + + return ( + + + + + + + + + + + + { + if (tagsTextboxRef.current != null) { + const suggestedPath = SettingStore.floodSettings.torrentDestinations?.[tags[0]]; + if (typeof suggestedPath === 'string' && tagsTextboxRef.current != null) { + tagsTextboxRef.current.value = suggestedPath; + tagsTextboxRef.current.dispatchEvent(new Event('input', {bubbles: true})); + } + } + }} + /> + + + + + + + ); +}; + +export default WatchesForm; diff --git a/client/src/javascript/components/modals/fwatch-modal/WatchesModal.tsx b/client/src/javascript/components/modals/fwatch-modal/WatchesModal.tsx new file mode 100644 index 000000000..ac3d2cb2c --- /dev/null +++ b/client/src/javascript/components/modals/fwatch-modal/WatchesModal.tsx @@ -0,0 +1,25 @@ +import {FC, useEffect} from 'react'; +import {useLingui} from '@lingui/react'; + +import WatchesTab from './WatchesTab'; +import Modal from '../Modal'; +import WatchActions from '@client/actions/WatchActions'; + +const WatchesModal: FC = () => { + const {i18n} = useLingui(); + + useEffect(() => { + WatchActions.fetchWatchMonitors(); + }, []); + + const tabs = { + watches: { + content: WatchesTab, + label: i18n._('watches.tabs.watches'), + } + }; + + return ; +}; + +export default WatchesModal; diff --git a/client/src/javascript/components/modals/fwatch-modal/WatchesTab.tsx b/client/src/javascript/components/modals/fwatch-modal/WatchesTab.tsx new file mode 100644 index 000000000..8ef903518 --- /dev/null +++ b/client/src/javascript/components/modals/fwatch-modal/WatchesTab.tsx @@ -0,0 +1,187 @@ +import {FC, ReactElement, useRef, useState} from 'react'; +import {Trans, useLingui} from '@lingui/react'; + +import {Button, Form, FormError, FormRow, FormRowItem} from '@client/ui'; +import {isNotEmpty} from '@client/util/validators'; + + +import ModalFormSectionHeader from '../ModalFormSectionHeader'; +import WatchActions from '@client/actions/WatchActions'; +import {AddWatchOptions} from '@shared/types/api/watch-monitor'; +import WatchesForm from '@client/components/modals/fwatch-modal/WatchesForm'; +import {WatchedDirectory} from '@shared/types/Watch'; +import WatchList from '@client/components/modals/fwatch-modal/WatchList'; + +const validatedFields = { + label: { + isValid: isNotEmpty, + error: 'feeds.validation.must.specify.label', + }, + dir: { + isValid: isNotEmpty, + error: 'feeds.validation.must.specify.valid.feed.url', + } +} as const; + +type ValidatedField = keyof typeof validatedFields; + +const validateField = (validatedField: ValidatedField, value: string | undefined): string | undefined => + validatedFields[validatedField]?.isValid(value) ? undefined : validatedFields[validatedField]?.error; + +interface WatchesFormData { + label: string; + dir: string; + destination: string; + tags: string; +} + +const defaultWatch: AddWatchOptions = { + label: '', + dir: '', + destination: '', + tags: [] as string[] +}; + +const WatchesTab: FC = () => { + const formRef = useRef
(null); + const {i18n} = useLingui(); + const [currentWatch, setCurrentWatch] = useState(null); + const [errors, setErrors] = useState>({}); + const [isEditing, setIsEditing] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( +
+ { + const validatedField = (event.target as HTMLInputElement).name as ValidatedField; + const feedForm = formData as unknown as WatchesFormData; + + setErrors({ + ...errors, + [validatedField]: validateField(validatedField, feedForm[validatedField]), + }); + }} + onSubmit={async () => { + const feedForm = formRef.current?.getFormData() as unknown as WatchesFormData; + if (formRef.current == null || feedForm == null) { + return; + } + + setIsSubmitting(true); + + const currentErrors = Object.keys(validatedFields).reduce((memo, key) => { + const validatedField = key as ValidatedField; + + return { + ...memo, + [validatedField]: validateField(validatedField, feedForm[validatedField]), + }; + }, {} as Record); + setErrors(currentErrors); + + const isFormValid = Object.keys(currentErrors).every((key) => currentErrors[key] === undefined); + + if (isFormValid) { + const watch: AddWatchOptions = { + label: feedForm.label, + dir: feedForm.dir, + destination: feedForm.destination, + tags: feedForm.tags.split(',') + }; + + let success = true; + try { + if (currentWatch === null) { + await WatchActions.addWatch(watch); + } else if (currentWatch?._id != null) { + await WatchActions.modifyWatch(currentWatch._id, watch); + } + } catch { + success = false; + } + + if (success) { + formRef.current.resetForm(); + setErrors({}); + setCurrentWatch(null); + setIsEditing(false); + } else { + setErrors({backend: 'general.error.unknown'}); + } + } + + setIsSubmitting(false); + }} + ref={formRef} + > + + + + {Object.keys(errors).reduce((memo: Array, key) => { + if (errors[key as ValidatedField] != null) { + memo.push( + + {i18n._(errors?.[key as ValidatedField] as string)} + , + ); + } + + return memo; + }, [])} + + + { + setCurrentWatch(feed); + setIsEditing(true); + }} + onRemove={(watch) => { + if (watch === currentWatch) { + if (isEditing) { + setErrors({}); + setIsEditing(false); + } + + setCurrentWatch(null); + } + + if (watch._id != null) { + WatchActions.removeWatchMonitors(watch._id); + } + }} + /> + + + {isEditing ? ( + { + setErrors({}); + setIsEditing(false); + setCurrentWatch(null); + }} + /> + ) : ( + + + + + )} + +
+ ); +}; + +export default WatchesTab; diff --git a/client/src/javascript/components/sidebar/Sidebar.tsx b/client/src/javascript/components/sidebar/Sidebar.tsx index aa747e806..5040bb806 100644 --- a/client/src/javascript/components/sidebar/Sidebar.tsx +++ b/client/src/javascript/components/sidebar/Sidebar.tsx @@ -14,6 +14,7 @@ import TagFilters from './TagFilters'; import ThemeSwitchButton from './ThemeSwitchButton'; import TrackerFilters from './TrackerFilters'; import TransferData from './TransferData'; +import WatchesButton from '@client/components/sidebar/WatchesButton'; const Sidebar: FC = () => ( ( + diff --git a/client/src/javascript/components/sidebar/WatchesButton.tsx b/client/src/javascript/components/sidebar/WatchesButton.tsx new file mode 100644 index 000000000..9ea942b4f --- /dev/null +++ b/client/src/javascript/components/sidebar/WatchesButton.tsx @@ -0,0 +1,33 @@ +import {FC, useRef} from 'react'; +import {useLingui} from '@lingui/react'; + +import UIStore from '@client/stores/UIStore'; + +import Tooltip from '../general/Tooltip'; +import Watch from '@client/ui/icons/Watch'; + +const WatchesButton: FC = () => { + const {i18n} = useLingui(); + const tooltipRef = useRef(null); + + return ( + { + if (tooltipRef.current != null) { + tooltipRef.current.dismissTooltip(); + } + + UIStore.setActiveModal({id: 'watches'}); + }} + ref={tooltipRef} + position="bottom" + wrapperClassName="sidebar__action sidebar__icon-button + sidebar__icon-button--interactive tooltip__wrapper" + > + + + ); +}; + +export default WatchesButton; diff --git a/client/src/javascript/i18n/strings/en.json b/client/src/javascript/i18n/strings/en.json index 9b7c4220c..88a6441ca 100644 --- a/client/src/javascript/i18n/strings/en.json +++ b/client/src/javascript/i18n/strings/en.json @@ -390,5 +390,11 @@ "unit.time.minute": "m", "unit.time.second": "s", "unit.time.week": "wk", - "unit.time.year": "yr" + "unit.time.year": "yr", + "watches.tabs.watches": "Watches", + "watches.tabs.heading": "Directory Watching", + "sidebar.button.watches": "Watches", + "watches.existing.watches": "Existing Watched Directories", + "watches.dir": "Watch Directory", + "watches.no.watches.defined": "No Watched Directories defined." } diff --git a/client/src/javascript/stores/UIStore.ts b/client/src/javascript/stores/UIStore.ts index fad92c7e5..c48dc0f72 100644 --- a/client/src/javascript/stores/UIStore.ts +++ b/client/src/javascript/stores/UIStore.ts @@ -60,6 +60,7 @@ export type Modal = | { id: | 'feeds' + | 'watches' | 'generate-magnet' | 'move-torrents' | 'remove-torrents' diff --git a/client/src/javascript/stores/WatchStore.ts b/client/src/javascript/stores/WatchStore.ts new file mode 100644 index 000000000..9542dc265 --- /dev/null +++ b/client/src/javascript/stores/WatchStore.ts @@ -0,0 +1,26 @@ +import {makeAutoObservable} from 'mobx'; +import {WatchedDirectory} from '@shared/types/Watch'; + +class WatchStore { + watchedDirectories: Array = []; + + constructor() { + makeAutoObservable(this); + } + + setWatchedDirectories(newWatches: Array): void { + if (newWatches == null) { + this.watchedDirectories = []; + return; + } + + this.watchedDirectories = [...newWatches].sort((a, b) => a.label.localeCompare(b.label)); + } + + + handleWatchedDirectoryFetchSuccess(newWatches: Array): void { + this.setWatchedDirectories(newWatches); + } +} + +export default new WatchStore(); diff --git a/client/src/javascript/ui/icons/Watch.tsx b/client/src/javascript/ui/icons/Watch.tsx new file mode 100644 index 000000000..a286a86e6 --- /dev/null +++ b/client/src/javascript/ui/icons/Watch.tsx @@ -0,0 +1,24 @@ +import classnames from 'classnames'; +import {FC, memo} from 'react'; + +interface WatchProps { + className?: string; +} + +const Watch: FC = memo(({className}: WatchProps) => ( + + {/**/} + + + // + // + // +)); + +Watch.defaultProps = { + className: undefined, +}; + +export default Watch; diff --git a/package-lock.json b/package-lock.json index 13ae139fd..9991b3a66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "body-parser": "^1.20.2", "case-sensitive-paths-webpack-plugin": "2.4.0", "chalk": "^4.1.2", + "chokidar": "^3.6.0", "classnames": "^2.3.2", "content-disposition": "^0.5.4", "cookie-parser": "^1.4.6", @@ -3859,6 +3860,27 @@ "typescript": "2 || 3 || 4" } }, + "node_modules/@lingui/cli/node_modules/chokidar": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", + "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.1", + "braces": "~3.0.2", + "glob-parent": "~5.1.0", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.5.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.1" + } + }, "node_modules/@lingui/cli/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -3874,6 +3896,18 @@ "node": ">=10" } }, + "node_modules/@lingui/cli/node_modules/readdirp": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", + "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/@lingui/conf": { "version": "3.17.2", "resolved": "https://registry.npmjs.org/@lingui/conf/-/conf-3.17.2.tgz", @@ -6649,24 +6683,27 @@ "dev": true }, "node_modules/chokidar": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.1.tgz", - "integrity": "sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "dependencies": { - "anymatch": "~3.1.1", + "anymatch": "~3.1.2", "braces": "~3.0.2", - "glob-parent": "~5.1.0", + "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", - "readdirp": "~3.5.0" + "readdirp": "~3.6.0" }, "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { - "fsevents": "~2.3.1" + "fsevents": "~2.3.2" } }, "node_modules/chownr": { @@ -16054,9 +16091,9 @@ } }, "node_modules/readdirp": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", - "integrity": "sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "dependencies": { "picomatch": "^2.2.1" @@ -19050,33 +19087,6 @@ "ajv": "^8.8.2" } }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/webpack-dev-server/node_modules/ipaddr.js": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", @@ -19092,18 +19102,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.1.0.tgz", diff --git a/package.json b/package.json index 279c1526d..fd6c0be60 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "prepack": "rm -rf dist && npm run build", "start": "node --enable-source-maps --use_strict dist/index.js", "start:development:client": "node client/scripts/development.js", - "start:development:server": "NODE_ENV=development tsx watch server/bin/start.ts", + "start:development:server": "tsx watch server/bin/start.ts", "start:test:rtorrent": "node scripts/testsetup.js", "test": "jest --forceExit", "test:watch": "jest --watchAll --forceExit", @@ -127,6 +127,7 @@ "body-parser": "^1.20.2", "case-sensitive-paths-webpack-plugin": "2.4.0", "chalk": "^4.1.2", + "chokidar": "^3.6.0", "classnames": "^2.3.2", "content-disposition": "^0.5.4", "cookie-parser": "^1.4.6", diff --git a/server/models/DirectoryWatcher.ts b/server/models/DirectoryWatcher.ts new file mode 100644 index 000000000..3656ed049 --- /dev/null +++ b/server/models/DirectoryWatcher.ts @@ -0,0 +1,58 @@ +import chokidar, {FSWatcher} from 'chokidar'; +import {openAndDecodeTorrent} from '../util/torrentFileUtil'; +import type {WatchItem} from '@shared/types/Watch'; +export interface DirectoryWatcherOptions { + watcherID: string; + watcherLabel: string; + directory: string; + destination: string; + tags: string[]; + maxHistory: number; + onNewItems: (options: DirectoryWatcherOptions, torrent: WatchItem | null) => void; +} + +export default class DirectoryWatcher { + private options: DirectoryWatcherOptions; + private watcher: FSWatcher | null = null; + + constructor(options: DirectoryWatcherOptions) { + this.options = options; + + this.initReader(); + } + + modify(options: Partial) { + this.options = {...this.options, ...options}; + this.initReader(); + } + + getOptions() { + return this.options; + } + + initReader() { + this.watcher = chokidar.watch(this.options.directory, { + ignored: /(^|[\/\\])\../, // ignore dotfiles + persistent: true + }); + + this.watcher + .on('add', this.handleAdd) + .on('error', error => console.log('Watcher error:', error)); + } + + handleAdd = async (path: string) => { + const filename = path.replace(/^.*[\\\/]/, ''); + if(filename.endsWith(".added") || await openAndDecodeTorrent(path) == null) + return; + this.options.onNewItems(this.options, { file: path, filename: filename }); + }; + + stopWatching() { + if (this.watcher != null) { + this.watcher.close().then( + this.watcher = null + ); + } + } +} \ No newline at end of file diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index 1dd0a7fcb..ed230920e 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -17,6 +17,7 @@ import clientRoutes from './client'; import clientActivityStream from '../../middleware/clientActivityStream'; import eventStream from '../../middleware/eventStream'; import feedMonitorRoutes from './feed-monitor'; +import watchMonitorRoutes from './watch-monitor'; import {getAuthToken, verifyToken} from '../../util/authUtil'; import torrentsRoutes from './torrents'; @@ -78,6 +79,7 @@ router.use('/feed-monitor', feedMonitorRoutes); router.use('/torrents', torrentsRoutes); +router.use('/watch-monitor', watchMonitorRoutes); /** * GET /api/activity-stream * @summary Subscribes to activity stream diff --git a/server/routes/api/watch-monitor.test.ts b/server/routes/api/watch-monitor.test.ts new file mode 100644 index 000000000..ab3b30ccf --- /dev/null +++ b/server/routes/api/watch-monitor.test.ts @@ -0,0 +1,192 @@ +import fastify from 'fastify'; +import fs from 'fs'; +import supertest from 'supertest'; + +import constructRoutes from '..'; +import {getAuthToken} from '../../util/authUtil'; +import {getTempPath} from '../../models/TemporaryStorage'; +import {WatchedDirectory} from '@shared/types/Watch'; +import {AddWatchOptions, ModifyWatchOptions} from '@shared/types/api/watch-monitor'; + +const app = fastify({disableRequestLogging: true, logger: false}); +let request: supertest.SuperTest; + +beforeAll(async () => { + await constructRoutes(app); + await app.ready(); + request = supertest(app.server); +}); + +afterAll(async () => { + await app.close(); +}); + +const authToken = `jwt=${getAuthToken('_config')}`; + +const tempDirectory = getTempPath('download'); +fs.mkdirSync(tempDirectory, {recursive: true}); + +const watchOptions: AddWatchOptions = { + label: 'Watch Temp Directory', + dir: tempDirectory, + destination: 'dest', + tags: ['tag'] +}; + +let addedWatch: WatchedDirectory | null = null; +describe('GET /api/watch-monitor', () => { + it('Expects nothing, yet. Verifies data structure.', (done) => { + request + .get('/api/watch-monitor') + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + const expectedResponse: WatchedDirectory[] = []; + + expect(res.body).toStrictEqual(expectedResponse); + + done(); + }); + }); +}); + +describe('PUT /api/watch-monitor', () => { + it('Watches a directory', (done) => { + request + .put('/api/watch-monitor') + .send(watchOptions) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + const response: WatchedDirectory = res.body; + + expect(response).toMatchObject(watchOptions); + + expect(response._id).not.toBeNull(); + expect(typeof response._id).toBe('string'); + + addedWatch = response; + + done(); + }); + }); + + it('GET /api/watch-monitor to verify added watch', (done) => { + request + .get('/api/watch-monitor') + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + const expectedResponse = [addedWatch]; + + expect(res.body).toStrictEqual(expectedResponse); + + done(); + }); + }) +}); + +describe('PATCH /api/watch-monitor/{id}', () => { + const modifyFeedOptions: ModifyWatchOptions = { + label: 'Modified Feed', + }; + + it('Modifies the added feed', (done) => { + expect(addedWatch).not.toBe(null); + if (addedWatch == null) return; + + request + .patch(`/api/watch-monitor/${addedWatch._id}`) + .send(modifyFeedOptions) + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) { + done(err); + return; + } + + done(); + }); + }); + + it('GET /api/watch-monitor to verify modified feed', (done) => { + expect(addedWatch).not.toBe(null); + if (addedWatch == null) return; + + request + .get(`/api/watch-monitor/${addedWatch._id}`) + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) { + done(err); + return; + } + + addedWatch = {...(addedWatch as WatchedDirectory), ...modifyFeedOptions}; + + expect(res.body).toStrictEqual([addedWatch]); + + done(); + }); + }); +}); + +describe('DELETE /api/watch-monitor/{id}', () => { + it('Deletes the added feed', (done) => { + expect(addedWatch).not.toBe(null); + if (addedWatch == null) return; + + request + .delete(`/api/watch-monitor/${addedWatch._id}`) + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, _res) => { + if (err) done(err); + + done(); + }); + }); + + it('GET /api/watch-monitor to verify watch has been deleted', (done) => { + request + .get('/api/watch-monitor') + .send() + .set('Cookie', [authToken]) + .set('Accept', 'application/json') + .expect(200) + .expect('Content-Type', /json/) + .end((err, res) => { + if (err) done(err); + + const expectedResponse: WatchedDirectory[] = []; + + expect(res.body).toStrictEqual(expectedResponse); + + done(); + }); + }); +}); diff --git a/server/routes/api/watch-monitor.ts b/server/routes/api/watch-monitor.ts new file mode 100644 index 000000000..23ac149d7 --- /dev/null +++ b/server/routes/api/watch-monitor.ts @@ -0,0 +1,97 @@ +import express, {Response} from 'express'; + +import type {AddWatchOptions, ModifyWatchOptions} from '@shared/types/api/watch-monitor'; + +const router = express.Router(); + +/** + * GET /api/watch-monitor + * @summary Gets watched directories and their stats + * @tags Watch + * @security User + * @return {{watches: Array}} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.get( + '/', + async (req, res): Promise => + req.services.watchService.getAll().then( + (watches) => res.status(200).json(watches), + ({code, message}) => res.status(500).json({code, message}), + ), +); + +/** + * GET /api/watch-monitor/{id?} + * @summary Gets subscribed feeds + * @tags Feeds + * @security User + * @param id.path.optional - Unique ID of the watch subscription + * @return {Array}} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.get<{id: string}>( + '/:id?', + async (req, res): Promise => + req.services.watchService.getWatch(req.params.id).then( + (watch) => res.status(200).json(watch), + ({code, message}) => res.status(500).json({code, message}), + ), +); + +/** + * PUT /api/watch-monitor + * @summary Subscribes to a watch + * @tags watch + * @security User + * @param {AddWatchOptions} request.body.required - options - application/json + * @return {Feed} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.put( + '/', + async (req, res): Promise => + req.services.watchService.addWatch(req.body).then( + (feed) => res.status(200).json(feed), + ({code, message}) => res.status(500).json({code, message}), + ), +); + +/** + * PATCH /api/watch-monitor/{id} + * @summary Modifies the options of a feed subscription + * @tags Feeds + * @security User + * @param id.path - Unique ID of the feed subscription + * @param {ModifyWatchOptions} request.body.required - options - application/json + * @return {} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.patch<{id: string}, unknown, ModifyWatchOptions>( + '/:id', + async (req, res): Promise => + req.services.watchService.modifyWatch(req.params.id, req.body).then( + (response) => res.status(200).json(response), + ({code, message}) => res.status(500).json({code, message}), + ), +); + +/** + * DELETE /api/watch-monitor/{id} + * @summary Deletes watch subscription + * @tags Watch + * @security User + * @param id.path - Unique ID of the item + * @return {} 200 - success response - application/json + * @return {Error} 500 - failure response - application/json + */ +router.delete<{id: string}>( + '/:id', + async (req, res): Promise => + req.services.watchService.removeItem(req.params.id).then( + (response) => res.status(200).json(response), + ({code, message}) => res.status(500).json({code, message}), + ), +); + +export default router; diff --git a/server/services/index.ts b/server/services/index.ts index 3cd0c9c2a..3ef90e0fd 100644 --- a/server/services/index.ts +++ b/server/services/index.ts @@ -7,6 +7,7 @@ import NotificationService from './notificationService'; import SettingService from './settingService'; import TaxonomyService from './taxonomyService'; import TorrentService from './torrentService'; +import WatchService from './watchService'; import DelugeClientGatewayService from './Deluge/clientGatewayService'; import QBittorrentClientGatewayService from './qBittorrent/clientGatewayService'; @@ -21,6 +22,7 @@ export interface ServiceInstances { settingService: SettingService; taxonomyService: TaxonomyService; torrentService: TorrentService; + watchService: WatchService; } const serviceInstances: Record = {}; @@ -67,6 +69,7 @@ export const bootstrapServicesForUser = (user: UserInDatabase) => { settingService: new SettingService(user), taxonomyService: new TaxonomyService(user), torrentService: new TorrentService(user), + watchService: new WatchService(user) }; Object.keys(userServiceInstances).forEach((key) => { diff --git a/server/services/watchService.test.ts b/server/services/watchService.test.ts new file mode 100644 index 000000000..d49113aaf --- /dev/null +++ b/server/services/watchService.test.ts @@ -0,0 +1,34 @@ +import {WatchedDirectory} from '@shared/types/Watch'; +import WatchService from './watchService'; +import type {UserInDatabase} from '@shared/schema/Auth'; +import Users from '../models/Users'; +import {getTempPath} from '../models/TemporaryStorage'; +import fs from 'fs'; + +const tempDirectory = getTempPath('download'); +fs.mkdirSync(tempDirectory, {recursive: true}); +describe('Watch Service', () => { + it('Listens for a new torrent.', (done) => { + const user:UserInDatabase = { + ...Users.getConfigUser(), + }; + + const watchService = new WatchService(user); + + const newWatch : WatchedDirectory = { + type: 'watchedDirectory', + _id: '1', + label: 'test', + dir: tempDirectory, + count: 0, + destination: 'dest', + tags: ['tag'] + } + watchService.addWatch(newWatch) + .then(() => { + fs.copyFile('fixtures/single.torrent', tempDirectory+'/single.torrent', ()=>{ console.log("lol") }) + done() + } + ); + }); +}); \ No newline at end of file diff --git a/server/services/watchService.ts b/server/services/watchService.ts new file mode 100644 index 000000000..3a6fdff98 --- /dev/null +++ b/server/services/watchService.ts @@ -0,0 +1,166 @@ +import path from 'path'; +import Datastore from '@seald-io/nedb'; + +import BaseService from './BaseService'; +import config from '../../config'; +import DirectoryWatcher, {DirectoryWatcherOptions} from '../models/DirectoryWatcher'; + +import type {WatchedDirectory, WatchItem} from '@shared/types/Watch'; +import {AddWatchOptions, ModifyWatchOptions} from '@shared/types/api/watch-monitor'; +import fs from 'fs'; + +class WatchService extends BaseService> { + directoryWatchers: Array = []; + db = new Datastore({ + autoload: true, + filename: path.join(config.dbPath, this.user._id, 'settings', 'watch.db'), + }); + + constructor(...args: ConstructorParameters) { + super(...args); + + this.onServicesUpdated = async () => { + // Execute once only. + this.onServicesUpdated = () => undefined; + + // Loads state from database. + const watchesSummary = await this.db.findAsync({}).catch(() => undefined); + + if (watchesSummary == null) { + return; + } + + // Initiate all feeds. + watchesSummary.forEach((feed) => { + this.startNewWatch(feed); + }); + }; + } + + async destroy(drop: boolean) { + if (drop) { + await this.db.dropDatabaseAsync(); + } + + return super.destroy(drop); + } + + private startNewWatch(watchedDirectory: WatchedDirectory) { + const {_id : watcherId, label , dir, destination, tags} = watchedDirectory; + + this.directoryWatchers.push( + new DirectoryWatcher({ + watcherID: watcherId, + watcherLabel: label, + directory: dir, + maxHistory: 100, + onNewItems: this.handleNewItems, + destination, + tags + }), + ); + return true; + } + + + /** + * Subscribes to a feed + * + * @param {AddFeedOptions} options - An object of options... + * @return {Promise} - Resolves with Feed or rejects with error. + */ + async addWatch({dir, label, destination, tags}: AddWatchOptions): Promise { + const newWatch = (await this.db.insertAsync>({type: 'watchedDirectory', dir, label, destination, tags})) as WatchedDirectory; + + this.startNewWatch(newWatch); + + return newWatch; + } + + /** + * 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 modifyWatch(id: string, {dir, label}: ModifyWatchOptions): Promise { + const modifiedDirWatcher = this.directoryWatchers.find((feedReader) => feedReader.getOptions().watcherID === id); + + if (modifiedDirWatcher == null) { + throw new Error(); + } + + modifiedDirWatcher.stopWatching(); + modifiedDirWatcher.modify(JSON.parse(JSON.stringify({feedLabel: label, directory: dir}))); + + return this.db + .updateAsync({_id: id}, {$set: JSON.parse(JSON.stringify({label, dir }))}, {}) + .then(() => undefined); + } + + async getAll(): Promise> { + return this.db.findAsync({}); + } + + async getWatch(id: string): Promise> { + return this.db.findAsync({_id: id}); + } + + handleNewItems = async (options: DirectoryWatcherOptions, torrent: WatchItem | null): Promise => { + console.log(options, torrent) + if(torrent == null) + return; + const { watcherID, watcherLabel, tags, destination} = options; + + await this.services?.clientGatewayService.addTorrentsByURL({ + urls: [torrent.file], + cookies: {}, + destination: destination, + tags: tags, + isBasePath: false, + isCompleted: false, + isSequential: false, + isInitialSeeding:false, + start: true, + }).then(() => { + this.db.update({_id: watcherID}, {$inc: {count: 1}}, {upsert: true}); + this.services?.notificationService.addNotification( + [{ + id: 'notification.feed.torrent.added', + data: { + title: torrent.filename, + feedLabel: watcherLabel, + ruleLabel: '' + } + }], + ); + this.services?.torrentService.fetchTorrentList(); + fs.rename(torrent.file, torrent.file+'.added', (error) => { + if (error) { console.error(error);} + }); + }) + .catch(console.error); + }; + + async removeItem(id: string): Promise { + let directoryWatcherToRemoveIndex = -1; + const directoryWatcherToRemove = this.directoryWatchers.find((watcher, index) => { + if (watcher.getOptions().watcherID === id) { + directoryWatcherToRemoveIndex = index; + return true; + } + + return false; + }); + + if (directoryWatcherToRemove != null) { + directoryWatcherToRemove.stopWatching(); + this.directoryWatchers.splice(directoryWatcherToRemoveIndex, 1); + } + + return this.db.removeAsync({_id: id}, {}).then(() => undefined); + } +} + +export default WatchService; diff --git a/server/util/torrentFileUtil.ts b/server/util/torrentFileUtil.ts index 75545fb81..6ba7785ad 100644 --- a/server/util/torrentFileUtil.ts +++ b/server/util/torrentFileUtil.ts @@ -6,7 +6,7 @@ import {LibTorrentFilePriority} from '../../shared/types/TorrentFile'; import type {LibTorrentResume, RTorrentFile, TorrentFile} from '../../shared/types/TorrentFile'; -const openAndDecodeTorrent = async (torrentPath: string): Promise => { +export const openAndDecodeTorrent = async (torrentPath: string): Promise => { let torrentData: TorrentFile | null = null; try { diff --git a/shared/types/Watch.ts b/shared/types/Watch.ts new file mode 100644 index 000000000..b645e450e --- /dev/null +++ b/shared/types/Watch.ts @@ -0,0 +1,18 @@ +export interface WatchedDirectory { + type: 'watchedDirectory'; + // Unique ID of the feed, generated by server by the time the feed is added. + _id: string; + // User-facing label (name) of the feed. + label: string; + // URL of the feed. + dir: string; + // How many times rules have matched items of the feed. + count?: number; + destination: string; + tags: string[] +} + +export interface WatchItem { + file: string, //Based64 file + filename: string //FileName +} diff --git a/shared/types/api/watch-monitor.ts b/shared/types/api/watch-monitor.ts new file mode 100644 index 000000000..adfb44d04 --- /dev/null +++ b/shared/types/api/watch-monitor.ts @@ -0,0 +1,6 @@ +import {WatchedDirectory} from '../Watch'; + +export type AddWatchOptions = Omit; + +export type ModifyWatchOptions = Partial; +