diff --git a/client/src/javascript/actions/AuthActions.tsx b/client/src/javascript/actions/AuthActions.ts similarity index 100% rename from client/src/javascript/actions/AuthActions.tsx rename to client/src/javascript/actions/AuthActions.ts diff --git a/client/src/javascript/actions/ClientActions.js b/client/src/javascript/actions/ClientActions.ts similarity index 61% rename from client/src/javascript/actions/ClientActions.js rename to client/src/javascript/actions/ClientActions.ts index ac8ac97c..71119bd1 100644 --- a/client/src/javascript/actions/ClientActions.js +++ b/client/src/javascript/actions/ClientActions.ts @@ -1,63 +1,68 @@ import axios from 'axios'; -import ActionTypes from '../constants/ActionTypes'; +import type {ConnectionSettings} from '@shared/types/Auth'; +import type {TransferDirection} from '@shared/types/TransferData'; + import AppDispatcher from '../dispatcher/AppDispatcher'; import ConfigStore from '../stores/ConfigStore'; +import type {ClientSettingsSaveSuccessAction} from '../constants/ServerActions'; +import type {SettingUpdatesClient} from '../stores/SettingsStore'; + const baseURI = ConfigStore.getBaseURI(); const ClientActions = { - fetchSettings: (property) => + fetchSettings: (property?: Record) => axios .get(`${baseURI}api/client/settings`, {params: {property}}) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS, + type: 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SETTINGS_FETCH_REQUEST_ERROR, + type: 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR', error, }); }, ), - saveSettings: (settings, options) => + saveSettings: (settings: SettingUpdatesClient, options: ClientSettingsSaveSuccessAction['options']) => axios .patch(`${baseURI}api/client/settings`, settings) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SETTINGS_SAVE_SUCCESS, + type: 'CLIENT_SETTINGS_SAVE_SUCCESS', data, options, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SETTINGS_SAVE_ERROR, + type: 'CLIENT_SETTINGS_SAVE_ERROR', error, options, }); }, ), - setThrottle: (direction, throttle) => + setThrottle: (direction: TransferDirection, throttle: number) => axios .put(`${baseURI}api/client/settings/speed-limits`, { direction, throttle, }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (transferData) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_THROTTLE_SUCCESS, + type: 'CLIENT_SET_THROTTLE_SUCCESS', data: { transferData, }, @@ -65,7 +70,7 @@ const ClientActions = { }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_THROTTLE_ERROR, + type: 'CLIENT_SET_THROTTLE_ERROR', data: { error, }, @@ -73,29 +78,29 @@ const ClientActions = { }, ), - testClientConnectionSettings: (connectionSettings) => { + testClientConnectionSettings: (connectionSettings: ConnectionSettings) => { const requestPayload = { host: connectionSettings.rtorrentHost, port: connectionSettings.rtorrentPort, socketPath: connectionSettings.rtorrentSocketPath, }; - return axios.post(`${baseURI}api/client/connection-test`, requestPayload).then((json = {}) => json.data); + return axios.post(`${baseURI}api/client/connection-test`, requestPayload).then((json) => json.data); }, testConnection: () => axios .get(`${baseURI}api/client/connection-test`) - .then((json = {}) => json.data) + .then((json) => json.data) .then( () => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_CONNECTION_TEST_SUCCESS, + type: 'CLIENT_CONNECTION_TEST_SUCCESS', }); }, () => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_CONNECTION_TEST_ERROR, + type: 'CLIENT_CONNECTION_TEST_ERROR', }); }, ), diff --git a/client/src/javascript/actions/FloodActions.js b/client/src/javascript/actions/FloodActions.js deleted file mode 100644 index 86fd87f1..00000000 --- a/client/src/javascript/actions/FloodActions.js +++ /dev/null @@ -1,305 +0,0 @@ -import axios from 'axios'; -import historySnapshotTypes from '@shared/constants/historySnapshotTypes'; -import serverEventTypes from '@shared/constants/serverEventTypes'; - -import AppDispatcher from '../dispatcher/AppDispatcher'; -import ActionTypes from '../constants/ActionTypes'; -import ConfigStore from '../stores/ConfigStore'; - -const baseURI = ConfigStore.getBaseURI(); - -let activityStreamEventSource = null; -let lastActivityStreamOptions; -let visibilityChangeTimeout = null; - -const FloodActions = { - clearNotifications: (options) => - axios - .delete(`${baseURI}api/notifications`) - .then((json = {}) => json.data) - .then( - (response = {}) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.FLOOD_CLEAR_NOTIFICATIONS_SUCCESS, - data: { - ...response, - ...options, - }, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.FLOOD_CLEAR_NOTIFICATIONS_ERROR, - data: { - error, - }, - }); - }, - ), - - closeActivityStream() { - if (activityStreamEventSource == null) { - return; - } - - activityStreamEventSource.close(); - - activityStreamEventSource.removeEventListener( - serverEventTypes.CLIENT_CONNECTIVITY_STATUS_CHANGE, - this.handleClientConnectivityStatusChange, - ); - - activityStreamEventSource.removeEventListener(serverEventTypes.DISK_USAGE_CHANGE, this.handleDiskUsageChange); - - activityStreamEventSource.removeEventListener( - serverEventTypes.NOTIFICATION_COUNT_CHANGE, - this.handleNotificationCountChange, - ); - - activityStreamEventSource.removeEventListener(serverEventTypes.TAXONOMY_DIFF_CHANGE, this.handleTaxonomyDiffChange); - - activityStreamEventSource.removeEventListener(serverEventTypes.TAXONOMY_FULL_UPDATE, this.handleTaxonomyFullUpdate); - - activityStreamEventSource.removeEventListener( - serverEventTypes.TORRENT_LIST_DIFF_CHANGE, - this.handleTorrentListDiffChange, - ); - - activityStreamEventSource.removeEventListener( - serverEventTypes.TORRENT_LIST_FULL_UPDATE, - this.handleTorrentListFullUpdate, - ); - - activityStreamEventSource.removeEventListener( - serverEventTypes.TRANSFER_SUMMARY_DIFF_CHANGE, - this.handleTransferSummaryDiffChange, - ); - - activityStreamEventSource.removeEventListener( - serverEventTypes.TRANSFER_SUMMARY_FULL_UPDATE, - this.handleTransferSummaryFullUpdate, - ); - - activityStreamEventSource.removeEventListener( - serverEventTypes.TRANSFER_HISTORY_FULL_UPDATE, - this.handleTransferHistoryFullUpdate, - ); - - activityStreamEventSource = null; - }, - - fetchDirectoryList: (options = {}) => - axios - .get(`${baseURI}api/directory-list`, { - params: options, - }) - .then((json = {}) => json.data) - .then((response) => { - return { - ...options, - ...response, - }; - }), - - fetchMediainfo: (options) => - axios - .get(`${baseURI}api/mediainfo`, { - params: { - hash: options.hash, - }, - }) - .then((json = {}) => json.data) - .then((response) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.FLOOD_FETCH_MEDIAINFO_SUCCESS, - data: { - ...response, - ...options, - }, - }); - }), - - fetchNotifications: (options) => - axios - .get(`${baseURI}api/notifications`, { - params: { - limit: options.limit, - start: options.start, - }, - }) - .then((json = {}) => json.data) - .then( - (response) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.FLOOD_FETCH_NOTIFICATIONS_SUCCESS, - data: { - ...response, - ...options, - }, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.FLOOD_FETCH_NOTIFICATIONS_ERROR, - data: { - error, - }, - }); - }, - ), - - handleClientConnectivityStatusChange(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_CONNECTIVITY_STATUS_CHANGE, - data: JSON.parse(event.data), - }); - }, - handleDiskUsageChange(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.DISK_USAGE_CHANGE, - data: JSON.parse(event.data), - }); - }, - handleNotificationCountChange(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.NOTIFICATION_COUNT_CHANGE, - data: JSON.parse(event.data), - }); - }, - - handleTorrentListDiffChange(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TORRENT_LIST_DIFF_CHANGE, - data: JSON.parse(event.data), - }); - }, - - handleTorrentListFullUpdate(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TORRENT_LIST_FULL_UPDATE, - data: JSON.parse(event.data), - }); - }, - - handleTaxonomyDiffChange(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TAXONOMY_DIFF_CHANGE, - data: JSON.parse(event.data), - }); - }, - - handleTaxonomyFullUpdate(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TAXONOMY_FULL_UPDATE, - data: JSON.parse(event.data), - }); - }, - - handleTransferSummaryDiffChange(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TRANSFER_SUMMARY_DIFF_CHANGE, - data: JSON.parse(event.data), - }); - }, - - handleTransferSummaryFullUpdate(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TRANSFER_SUMMARY_FULL_UPDATE, - data: JSON.parse(event.data), - }); - }, - - handleTransferHistoryFullUpdate(event) { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.TRANSFER_HISTORY_FULL_UPDATE, - data: JSON.parse(event.data), - }); - }, - - restartActivityStream() { - this.closeActivityStream(); - this.startActivityStream(lastActivityStreamOptions); - }, - - startActivityStream(options = {}) { - const {historySnapshot = historySnapshotTypes.FIVE_MINUTE} = options; - const didHistorySnapshotChange = - lastActivityStreamOptions && lastActivityStreamOptions.historySnapshot !== historySnapshot; - - lastActivityStreamOptions = options; - - // When the user requests a new history snapshot during an open session, - // we need to close and re-open the event stream. - if (didHistorySnapshotChange && activityStreamEventSource !== null) { - this.closeActivityStream(); - } - - // If the user requested a new history snapshot, or the event source has not - // alraedy been created, we open the event stream. - if (didHistorySnapshotChange || activityStreamEventSource === null) { - activityStreamEventSource = new EventSource(`${baseURI}api/activity-stream?historySnapshot=${historySnapshot}`); - - activityStreamEventSource.addEventListener( - serverEventTypes.CLIENT_CONNECTIVITY_STATUS_CHANGE, - this.handleClientConnectivityStatusChange, - ); - - activityStreamEventSource.addEventListener(serverEventTypes.DISK_USAGE_CHANGE, this.handleDiskUsageChange); - - activityStreamEventSource.addEventListener( - serverEventTypes.NOTIFICATION_COUNT_CHANGE, - this.handleNotificationCountChange, - ); - - activityStreamEventSource.addEventListener(serverEventTypes.TAXONOMY_DIFF_CHANGE, this.handleTaxonomyDiffChange); - - activityStreamEventSource.addEventListener(serverEventTypes.TAXONOMY_FULL_UPDATE, this.handleTaxonomyFullUpdate); - - activityStreamEventSource.addEventListener( - serverEventTypes.TORRENT_LIST_DIFF_CHANGE, - this.handleTorrentListDiffChange, - ); - - activityStreamEventSource.addEventListener( - serverEventTypes.TORRENT_LIST_FULL_UPDATE, - this.handleTorrentListFullUpdate, - ); - - activityStreamEventSource.addEventListener( - serverEventTypes.TRANSFER_SUMMARY_DIFF_CHANGE, - this.handleTransferSummaryDiffChange, - ); - - activityStreamEventSource.addEventListener( - serverEventTypes.TRANSFER_SUMMARY_FULL_UPDATE, - this.handleTransferSummaryFullUpdate, - ); - - activityStreamEventSource.addEventListener( - serverEventTypes.TRANSFER_HISTORY_FULL_UPDATE, - this.handleTransferHistoryFullUpdate, - ); - } - }, -}; - -const handleProlongedInactivity = () => { - FloodActions.closeActivityStream(); -}; - -const handleWindowVisibilityChange = () => { - if (global.document.hidden) { - // After 30 seconds of inactivity, we stop the event stream. - visibilityChangeTimeout = global.setTimeout(handleProlongedInactivity, 1000 * 30); - } else { - global.clearTimeout(visibilityChangeTimeout); - - if (activityStreamEventSource == null) { - FloodActions.startActivityStream(lastActivityStreamOptions); - } - } -}; - -global.document.addEventListener('visibilitychange', handleWindowVisibilityChange); - -export default FloodActions; diff --git a/client/src/javascript/actions/FloodActions.ts b/client/src/javascript/actions/FloodActions.ts new file mode 100644 index 00000000..00fa1c99 --- /dev/null +++ b/client/src/javascript/actions/FloodActions.ts @@ -0,0 +1,246 @@ +import axios from 'axios'; + +import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; +import type {NotificationFetchOptions} from '@shared/types/Notification'; +import type {ServerEvents} from '@shared/types/ServerEvents'; +import type {TorrentProperties} from '@shared/types/Torrent'; + +import AppDispatcher from '../dispatcher/AppDispatcher'; +import ConfigStore from '../stores/ConfigStore'; + +interface ActivityStreamOptions { + historySnapshot: HistorySnapshot; +} + +const baseURI = ConfigStore.getBaseURI(); + +let activityStreamEventSource: EventSource | null = null; +let lastActivityStreamOptions: ActivityStreamOptions; +let visibilityChangeTimeout: NodeJS.Timeout; + +// TODO: Use standard Event interfaces +const ServerEventHandlers: Record void> = { + CLIENT_CONNECTIVITY_STATUS_CHANGE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_CONNECTIVITY_STATUS_CHANGE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + DISK_USAGE_CHANGE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'DISK_USAGE_CHANGE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + NOTIFICATION_COUNT_CHANGE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'NOTIFICATION_COUNT_CHANGE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TORRENT_LIST_DIFF_CHANGE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TORRENT_LIST_DIFF_CHANGE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TORRENT_LIST_FULL_UPDATE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TORRENT_LIST_FULL_UPDATE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TAXONOMY_DIFF_CHANGE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TAXONOMY_DIFF_CHANGE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TAXONOMY_FULL_UPDATE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TAXONOMY_FULL_UPDATE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TRANSFER_SUMMARY_DIFF_CHANGE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TRANSFER_SUMMARY_DIFF_CHANGE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TRANSFER_SUMMARY_FULL_UPDATE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TRANSFER_SUMMARY_FULL_UPDATE', + data: JSON.parse((event as {data: string}).data), + }); + }, + + TRANSFER_HISTORY_FULL_UPDATE: (event: unknown) => { + AppDispatcher.dispatchServerAction({ + type: 'TRANSFER_HISTORY_FULL_UPDATE', + data: JSON.parse((event as {data: string}).data), + }); + }, +}; + +const FloodActions = { + clearNotifications: (options: NotificationFetchOptions) => + axios + .delete(`${baseURI}api/notifications`) + .then((json) => json.data) + .then( + (response = {}) => { + AppDispatcher.dispatchServerAction({ + type: 'FLOOD_CLEAR_NOTIFICATIONS_SUCCESS', + data: { + ...response, + ...options, + }, + }); + }, + (error) => { + AppDispatcher.dispatchServerAction({ + type: 'FLOOD_CLEAR_NOTIFICATIONS_ERROR', + data: { + error, + }, + }); + }, + ), + + closeActivityStream() { + if (activityStreamEventSource == null) { + return; + } + + activityStreamEventSource.close(); + + Object.entries(ServerEventHandlers).forEach(([event, handler]) => { + if (activityStreamEventSource != null) { + activityStreamEventSource.removeEventListener(event, handler); + } + }); + + activityStreamEventSource = null; + }, + + fetchDirectoryList: (options = {}) => + axios + .get(`${baseURI}api/directory-list`, { + params: options, + }) + .then((json) => json.data) + .then((response) => { + return { + ...options, + ...response, + }; + }), + + fetchMediainfo: (options: {hash: TorrentProperties['hash']}) => + axios + .get(`${baseURI}api/mediainfo`, { + params: { + hash: options.hash, + }, + }) + .then((json) => json.data) + .then((response) => { + AppDispatcher.dispatchServerAction({ + type: 'FLOOD_FETCH_MEDIAINFO_SUCCESS', + data: { + ...response, + ...options, + }, + }); + }), + + fetchNotifications: (options: NotificationFetchOptions) => + axios + .get(`${baseURI}api/notifications`, { + params: { + limit: options.limit, + start: options.start, + }, + }) + .then((json) => json.data) + .then( + (response) => { + AppDispatcher.dispatchServerAction({ + type: 'FLOOD_FETCH_NOTIFICATIONS_SUCCESS', + data: { + ...response, + ...options, + }, + }); + }, + (error) => { + AppDispatcher.dispatchServerAction({ + type: 'FLOOD_FETCH_NOTIFICATIONS_ERROR', + data: { + error, + }, + }); + }, + ), + + restartActivityStream() { + this.closeActivityStream(); + this.startActivityStream(lastActivityStreamOptions); + }, + + startActivityStream(options: ActivityStreamOptions = {historySnapshot: 'FIVE_MINUTE'}) { + const {historySnapshot} = options; + const didHistorySnapshotChange = + lastActivityStreamOptions && lastActivityStreamOptions.historySnapshot !== historySnapshot; + + lastActivityStreamOptions = options; + + // When the user requests a new history snapshot during an open session, + // we need to close and re-open the event stream. + if (didHistorySnapshotChange && activityStreamEventSource != null) { + this.closeActivityStream(); + } + + // If the user requested a new history snapshot, or the event source has not + // alraedy been created, we open the event stream. + if (didHistorySnapshotChange || activityStreamEventSource == null) { + activityStreamEventSource = new EventSource(`${baseURI}api/activity-stream?historySnapshot=${historySnapshot}`); + + Object.entries(ServerEventHandlers).forEach(([event, handler]) => { + if (activityStreamEventSource != null) { + activityStreamEventSource.addEventListener(event, handler); + } + }); + } + }, +}; + +const handleProlongedInactivity = () => { + FloodActions.closeActivityStream(); +}; + +const handleWindowVisibilityChange = () => { + if (global.document.hidden) { + // After 30 seconds of inactivity, we stop the event stream. + visibilityChangeTimeout = global.setTimeout(handleProlongedInactivity, 1000 * 30); + } else { + global.clearTimeout(visibilityChangeTimeout); + + if (activityStreamEventSource == null) { + FloodActions.startActivityStream(lastActivityStreamOptions); + } + } +}; + +global.document.addEventListener('visibilitychange', handleWindowVisibilityChange); + +export default FloodActions; diff --git a/client/src/javascript/actions/SettingsActions.js b/client/src/javascript/actions/SettingsActions.ts similarity index 57% rename from client/src/javascript/actions/SettingsActions.js rename to client/src/javascript/actions/SettingsActions.ts index 3d3b0502..d10f0fcf 100644 --- a/client/src/javascript/actions/SettingsActions.js +++ b/client/src/javascript/actions/SettingsActions.ts @@ -1,172 +1,172 @@ import axios from 'axios'; import AppDispatcher from '../dispatcher/AppDispatcher'; -import ActionTypes from '../constants/ActionTypes'; import ConfigStore from '../stores/ConfigStore'; +import type {Feed, Rule} from '../stores/FeedsStore'; +import type {SettingsSaveRequestSuccessAction} from '../constants/ServerActions'; +import type {SettingUpdatesFlood} from '../stores/SettingsStore'; + const baseURI = ConfigStore.getBaseURI(); const SettingsActions = { - addFeed: (feed) => + addFeed: (feed: Feed) => axios .put(`${baseURI}api/feed-monitor/feeds`, feed) - .then((json = {}) => json.data) + .then((json) => json.data) .then( - (data) => { + () => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS, - data, + type: 'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS', }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_FEED_ADD_ERROR, + type: 'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR', error, }); }, ), - modifyFeed: (id, feed) => + modifyFeed: (id: Feed['_id'], feed: Feed) => axios .put(`${baseURI}api/feed-monitor/feeds/${id}`, feed) - .then((json = {}) => json.data) + .then((json) => json.data) .then( - (data) => { + () => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS, - data, + type: 'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS', }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_FEED_MODiFY_ERROR, + type: 'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR', error, }); }, ), - addRule: (rule) => + addRule: (rule: Rule) => axios .put(`${baseURI}api/feed-monitor/rules`, rule) - .then((json = {}) => json.data) + .then((json) => json.data) .then( - (data) => { + () => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS, - data, + type: 'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS', }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_RULE_ADD_ERROR, + type: 'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR', error, }); }, ), - fetchFeedMonitors: (query) => + fetchFeedMonitors: () => axios - .get(`${baseURI}api/feed-monitor`, query) - .then((json = {}) => json.data) + .get(`${baseURI}api/feed-monitor`) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITORS_FETCH_SUCCESS, + type: 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITORS_FETCH_ERROR, + type: 'SETTINGS_FEED_MONITORS_FETCH_ERROR', error, }); }, ), - fetchFeeds: (query) => + fetchFeeds: (query: string) => axios - .get(`${baseURI}api/feed-monitor/feeds`, query) - .then((json = {}) => json.data) + .get(`${baseURI}api/feed-monitor/feeds`, {params: query}) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS, + type: 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR, + type: 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR', error, }); }, ), - fetchItems: (query) => + fetchItems: (query: {params: {id: string; search: string}}) => axios .get(`${baseURI}api/feed-monitor/items`, query) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS, + type: 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR, + type: 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR', error, }); }, ), - fetchRules: (query) => + fetchRules: (query: string) => axios - .get(`${baseURI}api/feed-monitor/rules`, query) - .then((json = {}) => json.data) + .get(`${baseURI}api/feed-monitor/rules`, {params: query}) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS, + type: 'SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR, + type: 'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR', error, }); }, ), - fetchSettings: (property) => + fetchSettings: (property?: Record) => axios .get(`${baseURI}api/settings`, {params: {property}}) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FETCH_REQUEST_SUCCESS, + type: 'SETTINGS_FETCH_REQUEST_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FETCH_REQUEST_ERROR, + type: 'SETTINGS_FETCH_REQUEST_ERROR', error, }); }, ), - removeFeedMonitor: (id) => + removeFeedMonitor: (id: string) => axios .delete(`${baseURI}api/feed-monitor/${id}`) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_REMOVE_SUCCESS, + type: 'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS', data: { ...data, id, @@ -175,7 +175,7 @@ const SettingsActions = { }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_FEED_MONITOR_REMOVE_ERROR, + type: 'SETTINGS_FEED_MONITOR_REMOVE_ERROR', error: { ...error, id, @@ -184,21 +184,21 @@ const SettingsActions = { }, ), - saveSettings: (settings, options = {}) => + saveSettings: (settings: SettingUpdatesFlood, options: SettingsSaveRequestSuccessAction['options']) => axios .patch(`${baseURI}api/settings`, settings) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_SAVE_REQUEST_SUCCESS, + type: 'SETTINGS_SAVE_REQUEST_SUCCESS', data, options, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.SETTINGS_SAVE_REQUEST_ERROR, + type: 'SETTINGS_SAVE_REQUEST_ERROR', error, }); }, diff --git a/client/src/javascript/actions/TorrentActions.js b/client/src/javascript/actions/TorrentActions.ts similarity index 62% rename from client/src/javascript/actions/TorrentActions.js rename to client/src/javascript/actions/TorrentActions.ts index 19d2c9de..4cf3bf3b 100644 --- a/client/src/javascript/actions/TorrentActions.js +++ b/client/src/javascript/actions/TorrentActions.ts @@ -1,20 +1,22 @@ import axios from 'axios'; +import type {AddTorrentByURLOptions, MoveTorrentsOptions} from '@shared/types/Action'; +import type {TorrentProperties} from '@shared/types/Torrent'; + import AppDispatcher from '../dispatcher/AppDispatcher'; -import ActionTypes from '../constants/ActionTypes'; import ConfigStore from '../stores/ConfigStore'; const baseURI = ConfigStore.getBaseURI(); const TorrentActions = { - addTorrentsByUrls: (options) => + addTorrentsByUrls: (options: AddTorrentByURLOptions) => axios .post(`${baseURI}api/client/add`, options) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (response) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_ADD_TORRENT_SUCCESS, + type: 'CLIENT_ADD_TORRENT_SUCCESS', data: { count: options.urls.length, destination: options.destination, @@ -24,7 +26,7 @@ const TorrentActions = { }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_ADD_TORRENT_ERROR, + type: 'CLIENT_ADD_TORRENT_ERROR', data: { error, }, @@ -32,14 +34,14 @@ const TorrentActions = { }, ), - addTorrentsByFiles: (formData, destination) => + addTorrentsByFiles: (formData: FormData, destination: string) => axios .post(`${baseURI}api/client/add-files`, formData) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (response) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_ADD_TORRENT_SUCCESS, + type: 'CLIENT_ADD_TORRENT_SUCCESS', data: { count: formData.getAll('torrents').length, destination, @@ -49,7 +51,7 @@ const TorrentActions = { }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_ADD_TORRENT_ERROR, + type: 'CLIENT_ADD_TORRENT_ERROR', data: { error, }, @@ -57,67 +59,67 @@ const TorrentActions = { }, ), - deleteTorrents: (hash, deleteData) => + deleteTorrents: (hashes: Array, deleteData: boolean) => axios - .post(`${baseURI}api/client/torrents/delete`, {hash, deleteData}) - .then((json = {}) => json.data) + .post(`${baseURI}api/client/torrents/delete`, {hashes, deleteData}) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_REMOVE_TORRENT_SUCCESS, + type: 'CLIENT_REMOVE_TORRENT_SUCCESS', data: { data, - count: hash.length, + count: hashes.length, deleteData, }, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_REMOVE_TORRENT_ERROR, + type: 'CLIENT_REMOVE_TORRENT_ERROR', error: { error, - count: hash.length, + count: hashes.length, }, }); }, ), - checkHash: (hash) => + checkHash: (hashes: Array) => axios - .post(`${baseURI}api/client/torrents/check-hash`, {hash}) - .then((json = {}) => json.data) + .post(`${baseURI}api/client/torrents/check-hash`, {hashes}) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_CHECK_HASH_SUCCESS, + type: 'CLIENT_CHECK_HASH_SUCCESS', data: { data, - count: hash.length, + count: hashes.length, }, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_CHECK_HASH_ERROR, + type: 'CLIENT_CHECK_HASH_ERROR', error: { error, - count: hash.length, + count: hashes.length, }, }); }, ), - fetchTorrentDetails: (hash) => + fetchTorrentDetails: (hash: TorrentProperties['hash']) => axios .post(`${baseURI}api/client/torrent-details`, { hash, }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (torrentDetails) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_FETCH_TORRENT_DETAILS_SUCCESS, + type: 'CLIENT_FETCH_TORRENT_DETAILS_SUCCESS', data: { hash, torrentDetails, @@ -126,7 +128,7 @@ const TorrentActions = { }, () => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_FETCH_TORRENT_DETAILS_ERROR, + type: 'CLIENT_FETCH_TORRENT_DETAILS_ERROR', data: { hash, }, @@ -134,7 +136,7 @@ const TorrentActions = { }, ), - moveTorrents: (hashes, options) => { + moveTorrents: (hashes: Array, options: MoveTorrentsOptions) => { const {destination, isBasePath, filenames, sourcePaths, moveFiles, isCheckHash} = options; return axios @@ -147,11 +149,11 @@ const TorrentActions = { moveFiles, isCheckHash, }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_MOVE_TORRENTS_SUCCESS, + type: 'CLIENT_MOVE_TORRENTS_SUCCESS', data: { data, count: hashes.length, @@ -160,97 +162,89 @@ const TorrentActions = { }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_MOVE_TORRENTS_ERROR, + type: 'CLIENT_MOVE_TORRENTS_ERROR', error, }); }, ); }, - startTorrents: (hashes) => + startTorrents: (hashes: Array) => axios .post(`${baseURI}api/client/start`, { hashes, }) - .then((json = {}) => json.data) - .then( - (response) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_START_TORRENT_SUCCESS, - data: { - response, - }, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_START_TORRENT_ERROR, - data: { - error, - }, - }); - }, - ), - - stopTorrents: (hashes) => - axios - .post(`${baseURI}api/client/stop`, { - hashes, - }) - .then((json = {}) => json.data) - .then( - (response) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_STOP_TORRENT_SUCCESS, - data: { - response, - }, - }); - }, - (error) => { - AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_STOP_TORRENT_ERROR, - data: { - error, - }, - }); - }, - ), - - setPriority: (hash, priority) => - axios - .patch(`${baseURI}api/client/torrents/${hash}/priority`, { - hash, - priority, - }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_TORRENT_PRIORITY_SUCCESS, + type: 'CLIENT_START_TORRENT_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_TORRENT_PRIORITY_ERROR, + type: 'CLIENT_START_TORRENT_ERROR', error, }); }, ), - setFilePriority: (hash, fileIndices, priority) => + stopTorrents: (hashes: Array) => + axios + .post(`${baseURI}api/client/stop`, { + hashes, + }) + .then((json) => json.data) + .then( + (data) => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_STOP_TORRENT_SUCCESS', + data, + }); + }, + (error) => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_STOP_TORRENT_ERROR', + error, + }); + }, + ), + + setPriority: (hash: TorrentProperties['hash'], priority: number) => + axios + .patch(`${baseURI}api/client/torrents/${hash}/priority`, { + hash, + priority, + }) + .then((json) => json.data) + .then( + (data) => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', + data, + }); + }, + (error) => { + AppDispatcher.dispatchServerAction({ + type: 'CLIENT_SET_TORRENT_PRIORITY_ERROR', + error, + }); + }, + ), + + setFilePriority: (hash: TorrentProperties['hash'], fileIndices: Array, priority: number) => axios .patch(`${baseURI}api/client/torrents/${hash}/file-priority`, { hash, fileIndices, priority, }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_FILE_PRIORITY_SUCCESS, + type: 'CLIENT_SET_FILE_PRIORITY_SUCCESS', data: { ...data, hash, @@ -261,53 +255,53 @@ const TorrentActions = { }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_FILE_PRIORITY_ERROR, + type: 'CLIENT_SET_FILE_PRIORITY_ERROR', error, }); }, ), - setTaxonomy: (hashes, tags, options = {}) => + setTaxonomy: (hashes: Array, tags: Array, options = {}) => axios .patch(`${baseURI}api/client/torrents/taxonomy`, { hashes, tags, options, }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_TAXONOMY_SUCCESS, + type: 'CLIENT_SET_TAXONOMY_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_TAXONOMY_ERROR, + type: 'CLIENT_SET_TAXONOMY_ERROR', error, }); }, ), - setTracker: (hashes, tracker, options = {}) => + setTracker: (hashes: Array, tracker: string, options = {}) => axios .patch(`${baseURI}api/client/torrents/tracker`, { hashes, tracker, options, }) - .then((json = {}) => json.data) + .then((json) => json.data) .then( (data) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_TRACKER_SUCCESS, + type: 'CLIENT_SET_TRACKER_SUCCESS', data, }); }, (error) => { AppDispatcher.dispatchServerAction({ - type: ActionTypes.CLIENT_SET_TRACKER_ERROR, + type: 'CLIENT_SET_TRACKER_ERROR', error, }); }, diff --git a/client/src/javascript/actions/UIActions.js b/client/src/javascript/actions/UIActions.js deleted file mode 100644 index 34d303c5..00000000 --- a/client/src/javascript/actions/UIActions.js +++ /dev/null @@ -1,96 +0,0 @@ -import debounce from 'lodash/debounce'; - -import AppDispatcher from '../dispatcher/AppDispatcher'; -import ActionTypes from '../constants/ActionTypes'; - -const UIActions = { - displayContextMenu: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_DISPLAY_CONTEXT_MENU, - data, - }); - }, - - displayDropdownMenu: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_DISPLAY_DROPDOWN_MENU, - data, - }); - }, - - displayModal: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_DISPLAY_MODAL, - data, - }); - }, - - dismissContextMenu: (contextMenuID) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_DISMISS_CONTEXT_MENU, - data: contextMenuID, - }); - }, - - dismissModal: () => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_DISPLAY_MODAL, - data: null, - }); - }, - - handleDetailsClick: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_CLICK_TORRENT_DETAILS, - data, - }); - }, - - handleTorrentClick: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_CLICK_TORRENT, - data, - }); - }, - - setTorrentStatusFilter: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_SET_TORRENT_STATUS_FILTER, - data, - }); - }, - - setTorrentTagFilter: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_SET_TORRENT_TAG_FILTER, - data, - }); - }, - - setTorrentTrackerFilter: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_SET_TORRENT_TRACKER_FILTER, - data, - }); - }, - - setTorrentsSearchFilter: debounce( - (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_SET_TORRENT_SEARCH_FILTER, - data, - }); - }, - 250, - {trailing: true}, - ), - - setTorrentsSort: (data) => { - AppDispatcher.dispatchUIAction({ - type: ActionTypes.UI_SET_TORRENT_SORT, - data, - }); - }, -}; - -export default UIActions; diff --git a/client/src/javascript/actions/UIActions.ts b/client/src/javascript/actions/UIActions.ts new file mode 100644 index 00000000..98ea0fb2 --- /dev/null +++ b/client/src/javascript/actions/UIActions.ts @@ -0,0 +1,169 @@ +import debounce from 'lodash/debounce'; +import React from 'react'; + +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; + +import AppDispatcher from '../dispatcher/AppDispatcher'; + +import type {ContextMenu, Modal} from '../stores/UIStore'; +import type {FloodSettings} from '../stores/SettingsStore'; + +export interface UIClickTorrentAction { + type: 'UI_CLICK_TORRENT'; + data: {event: React.MouseEvent; hash: string}; +} + +export interface UIClickTorrentDetailsAction { + type: 'UI_CLICK_TORRENT_DETAILS'; + data: {event: React.MouseEvent; hash: string}; +} + +export interface UIDismissContextMenuAction { + type: 'UI_DISMISS_CONTEXT_MENU'; + data: ContextMenu['id']; +} + +export interface UIDisplayContextMenuAction { + type: 'UI_DISPLAY_CONTEXT_MENU'; + data: ContextMenu; +} + +export interface UIDisplayDropdownMenuAction { + type: 'UI_DISPLAY_DROPDOWN_MENU'; + data: string; +} + +export interface UIDisplayModalAction { + type: 'UI_DISPLAY_MODAL'; + data: Modal | null; +} + +export interface UISetTorrentSortAction { + type: 'UI_SET_TORRENT_SORT'; + data: FloodSettings['sortTorrents']; +} + +export interface UISetTorrentSearchFilterAction { + type: 'UI_SET_TORRENT_SEARCH_FILTER'; + data: string; +} + +export interface UISetTorrentStatusFilterAction { + type: 'UI_SET_TORRENT_STATUS_FILTER'; + data: TorrentStatus; +} + +export interface UISetTorrentTagFilterAction { + type: 'UI_SET_TORRENT_TAG_FILTER'; + data: string; +} + +export interface UISetTorrentTrackerFilterAction { + type: 'UI_SET_TORRENT_TRACKER_FILTER'; + data: string; +} + +export type UIAction = + | UIClickTorrentAction + | UIClickTorrentDetailsAction + | UIDismissContextMenuAction + | UIDisplayContextMenuAction + | UIDisplayDropdownMenuAction + | UIDisplayModalAction + | UISetTorrentSortAction + | UISetTorrentSearchFilterAction + | UISetTorrentStatusFilterAction + | UISetTorrentTagFilterAction + | UISetTorrentTrackerFilterAction; + +const UIActions = { + displayContextMenu: (data: UIDisplayContextMenuAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_DISPLAY_CONTEXT_MENU', + data, + }); + }, + + displayDropdownMenu: (data: UIDisplayDropdownMenuAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_DISPLAY_DROPDOWN_MENU', + data, + }); + }, + + displayModal: (data: Exclude) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_DISPLAY_MODAL', + data, + }); + }, + + dismissContextMenu: (contextMenuID: UIDismissContextMenuAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_DISMISS_CONTEXT_MENU', + data: contextMenuID, + }); + }, + + dismissModal: () => { + AppDispatcher.dispatchUIAction({ + type: 'UI_DISPLAY_MODAL', + data: null, + }); + }, + + handleDetailsClick: (data: UIClickTorrentDetailsAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_CLICK_TORRENT_DETAILS', + data, + }); + }, + + handleTorrentClick: (data: UIClickTorrentAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_CLICK_TORRENT', + data, + }); + }, + + setTorrentStatusFilter: (data: UISetTorrentStatusFilterAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_SET_TORRENT_STATUS_FILTER', + data, + }); + }, + + setTorrentTagFilter: (data: UISetTorrentTagFilterAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_SET_TORRENT_TAG_FILTER', + data, + }); + }, + + setTorrentTrackerFilter: (data: UISetTorrentTrackerFilterAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_SET_TORRENT_TRACKER_FILTER', + data, + }); + }, + + setTorrentsSearchFilter: debounce( + (data: UISetTorrentSearchFilterAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_SET_TORRENT_SEARCH_FILTER', + data, + }); + }, + 250, + {trailing: true}, + ), + + setTorrentsSort: (data: UISetTorrentSortAction['data']) => { + AppDispatcher.dispatchUIAction({ + type: 'UI_SET_TORRENT_SORT', + data, + }); + }, +}; + +export default UIActions; diff --git a/client/src/javascript/components/general/WindowTitle.tsx b/client/src/javascript/components/general/WindowTitle.tsx index c2e11c19..0b103b2a 100644 --- a/client/src/javascript/components/general/WindowTitle.tsx +++ b/client/src/javascript/components/general/WindowTitle.tsx @@ -1,12 +1,12 @@ import {injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; +import type {TransferSummary} from '@shared/types/TransferData'; + import connectStores from '../../util/connectStores'; import {compute, getTranslationString} from '../../util/size'; import TransferDataStore from '../../stores/TransferDataStore'; -import type {TransferSummary} from '../../stores/TransferDataStore'; - interface WindowTitleProps extends WrappedComponentProps { summary?: TransferSummary; } diff --git a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx index ba84a18a..47423c56 100644 --- a/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx +++ b/client/src/javascript/components/modals/add-torrents-modal/AddTorrentsByURL.tsx @@ -74,11 +74,15 @@ class AddTorrentsByURL extends React.Component; this.setState({isAddingTorrents: true}); + if (formData.destination == null) { + return; + } + TorrentActions.addTorrentsByUrls({ urls: this.getURLsFromForm(), destination: formData.destination, - isBasePath: formData.useBasePath, - start: formData.start, + isBasePath: formData.useBasePath || false, + start: formData.start || false, tags: formData.tags != null ? formData.tags.split(',') : undefined, }); diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js b/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js index 1c29c551..1572f5e7 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentHeading.js @@ -2,7 +2,6 @@ import {FormattedMessage} from 'react-intl'; import classnames from 'classnames'; import React from 'react'; import stringUtil from '@shared/util/stringUtil'; -import torrentStatusMap from '@shared/constants/torrentStatusMap'; import ClockIcon from '../../icons/ClockIcon'; import DownloadThickIcon from '../../icons/DownloadThickIcon'; @@ -15,8 +14,8 @@ import Size from '../../general/Size'; import StartIcon from '../../icons/StartIcon'; import StopIcon from '../../icons/StopIcon'; import TorrentActions from '../../../actions/TorrentActions'; -import {torrentStatusClasses} from '../../../util/torrentStatusClasses'; -import {torrentStatusIcons} from '../../../util/torrentStatusIcons'; +import torrentStatusClasses from '../../../util/torrentStatusClasses'; +import torrentStatusIcons from '../../../util/torrentStatusIcons'; import UploadThickIcon from '../../icons/UploadThickIcon'; const METHODS_TO_BIND = ['getCurrentStatus', 'handleStart', 'handleStop']; @@ -35,7 +34,7 @@ export default class TorrentHeading extends React.Component { } getCurrentStatus(torrentStatus) { - if (torrentStatus.includes(torrentStatusMap.stopped)) { + if (torrentStatus.includes('stopped')) { return 'stop'; } return 'start'; diff --git a/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx b/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx index 17cff93b..c57db42b 100644 --- a/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx +++ b/client/src/javascript/components/modals/torrent-details-modal/TorrentPeers.tsx @@ -1,13 +1,13 @@ import {FormattedMessage} from 'react-intl'; import React from 'react'; +import type {TorrentPeer} from '@shared/types/Torrent'; + import Badge from '../../general/Badge'; import Size from '../../general/Size'; import Checkmark from '../../icons/Checkmark'; import SpinnerIcon from '../../icons/SpinnerIcon'; -import type {TorrentPeer} from '../../../stores/TorrentStore'; - interface TorrentPeersProps { peers: Array; } diff --git a/client/src/javascript/components/sidebar/DiskUsage.tsx b/client/src/javascript/components/sidebar/DiskUsage.tsx index 309e68ed..4fe2359f 100644 --- a/client/src/javascript/components/sidebar/DiskUsage.tsx +++ b/client/src/javascript/components/sidebar/DiskUsage.tsx @@ -1,6 +1,8 @@ import {FormattedMessage} from 'react-intl'; import React from 'react'; +import type {Disk, Disks} from '@shared/types/DiskUsage'; + import DiskUsageStore from '../../stores/DiskUsageStore'; import Size from '../general/Size'; import Tooltip from '../general/Tooltip'; @@ -8,8 +10,6 @@ import connectStores from '../../util/connectStores'; import ProgressBar from '../general/ProgressBar'; import SettingsStore from '../../stores/SettingsStore'; -import type {Disk, Disks} from '../../stores/DiskUsageStore'; - interface DiskUsageProps { disks?: Disks; mountPoints?: Array; diff --git a/client/src/javascript/components/sidebar/NotificationsButton.tsx b/client/src/javascript/components/sidebar/NotificationsButton.tsx index 5137ef00..11965002 100644 --- a/client/src/javascript/components/sidebar/NotificationsButton.tsx +++ b/client/src/javascript/components/sidebar/NotificationsButton.tsx @@ -2,6 +2,8 @@ import classnames from 'classnames'; import {defineMessages, FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; +import type {Notification, NotificationCount} from '@shared/types/Notification'; + import FloodActions from '../../actions/FloodActions'; import ChevronLeftIcon from '../icons/ChevronLeftIcon'; import ChevronRightIcon from '../icons/ChevronRightIcon'; @@ -12,8 +14,6 @@ import NotificationIcon from '../icons/NotificationIcon'; import NotificationStore from '../../stores/NotificationStore'; import Tooltip from '../general/Tooltip'; -import type {Notification, NotificationCount} from '../../stores/NotificationStore'; - interface NotificationsButtonProps extends WrappedComponentProps { count?: NotificationCount; notifications?: Array; @@ -272,6 +272,7 @@ class NotificationsButton extends React.Component; @@ -36,7 +37,9 @@ const MESSAGES = defineMessages({ class SpeedLimitDropdown extends React.Component { static handleItemSelect(item: DropdownItem) { - ClientActions.setThrottle(item.property, item.value); + if (item.value != null) { + ClientActions.setThrottle(item.property as TransferDirection, item.value); + } } tooltipRef: Tooltip | null = null; diff --git a/client/src/javascript/components/sidebar/StatusFilters.tsx b/client/src/javascript/components/sidebar/StatusFilters.tsx index 6eb2547a..11f2fb00 100644 --- a/client/src/javascript/components/sidebar/StatusFilters.tsx +++ b/client/src/javascript/components/sidebar/StatusFilters.tsx @@ -1,6 +1,8 @@ import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; import React from 'react'; +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; + import Active from '../icons/Active'; import All from '../icons/All'; import Completed from '../icons/Completed'; @@ -22,11 +24,15 @@ interface StatusFiltersProps extends WrappedComponentProps { class StatusFilters extends React.Component { static handleClick(filter: string) { - UIActions.setTorrentStatusFilter(filter); + UIActions.setTorrentStatusFilter(filter as TorrentStatus); } getFilters() { - const filters = [ + const filters: Array<{ + label: string; + slug: TorrentStatus; + icon: JSX.Element; + }> = [ { label: this.props.intl.formatMessage({ id: 'filter.all', diff --git a/client/src/javascript/components/sidebar/TransferRateDetails.tsx b/client/src/javascript/components/sidebar/TransferRateDetails.tsx index 9b5d1998..f3573aea 100644 --- a/client/src/javascript/components/sidebar/TransferRateDetails.tsx +++ b/client/src/javascript/components/sidebar/TransferRateDetails.tsx @@ -5,6 +5,8 @@ import duration from 'dayjs/plugin/duration'; import formatUtil from '@shared/util/formatUtil'; import React from 'react'; +import type {TransferDirection, TransferSummary} from '@shared/types/TransferData'; + import ClientStatusStore from '../../stores/ClientStatusStore'; import connectStores from '../../util/connectStores'; import Download from '../icons/Download'; @@ -14,7 +16,6 @@ import Size from '../general/Size'; import TransferDataStore from '../../stores/TransferDataStore'; import Upload from '../icons/Upload'; -import type {TransferDirection, TransferSummary} from '../../stores/TransferDataStore'; import type {TransferRateGraphInspectorPoint} from './TransferRateGraph'; interface TransferRateDetailsProps extends WrappedComponentProps { diff --git a/client/src/javascript/components/sidebar/TransferRateGraph.tsx b/client/src/javascript/components/sidebar/TransferRateGraph.tsx index e40f4c9d..9520fa41 100644 --- a/client/src/javascript/components/sidebar/TransferRateGraph.tsx +++ b/client/src/javascript/components/sidebar/TransferRateGraph.tsx @@ -1,9 +1,9 @@ import * as d3 from 'd3'; import React from 'react'; -import TransferDataStore, {TRANSFER_DIRECTIONS} from '../../stores/TransferDataStore'; +import type {TransferDirection} from '@shared/types/TransferData'; -import type {TransferDirection} from '../../stores/TransferDataStore'; +import TransferDataStore, {TRANSFER_DIRECTIONS} from '../../stores/TransferDataStore'; export interface TransferRateGraphInspectorPoint { uploadSpeed: number; diff --git a/client/src/javascript/components/torrent-list/Torrent.js b/client/src/javascript/components/torrent-list/Torrent.js index 8f4a3bd8..7b710bd2 100644 --- a/client/src/javascript/components/torrent-list/Torrent.js +++ b/client/src/javascript/components/torrent-list/Torrent.js @@ -1,8 +1,8 @@ import React from 'react'; import ProgressBar from '../general/ProgressBar'; -import {torrentStatusIcons} from '../../util/torrentStatusIcons'; -import {torrentStatusClasses} from '../../util/torrentStatusClasses'; +import torrentStatusIcons from '../../util/torrentStatusIcons'; +import torrentStatusClasses from '../../util/torrentStatusClasses'; import TorrentDetail from './TorrentDetail'; const condensedValueTransformers = { diff --git a/client/src/javascript/constants/ActionTypes.tsx b/client/src/javascript/constants/ActionTypes.tsx deleted file mode 100644 index 6e297e11..00000000 --- a/client/src/javascript/constants/ActionTypes.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import objectUtil from '@shared/util/objectUtil'; - -// TODO: Convert to type-checked Action interfaces -const actionTypes = [ - 'AUTH_CREATE_USER_SUCCESS', - 'AUTH_DELETE_USER_ERROR', - 'AUTH_DELETE_USER_SUCCESS', - 'AUTH_LIST_USERS_SUCCESS', - 'AUTH_LOGIN_ERROR', - 'AUTH_LOGOUT_ERROR', - 'AUTH_LOGOUT_SUCCESS', - 'AUTH_REGISTER_ERROR', - 'AUTH_VERIFY_ERROR', - 'CLIENT_ADD_TORRENT_ERROR', - 'CLIENT_ADD_TORRENT_SUCCESS', - 'CLIENT_CHECK_HASH_ERROR', - 'CLIENT_CHECK_HASH_SUCCESS', - 'DISK_USAGE_CHANGE', - 'FLOOD_CLEAR_NOTIFICATIONS_ERROR', - 'FLOOD_CLEAR_NOTIFICATIONS_SUCCESS', - 'CLIENT_CONNECTION_TEST_ERROR', - 'CLIENT_CONNECTION_TEST_SUCCESS', - 'CLIENT_CONNECTIVITY_STATUS_CHANGE', - 'CLIENT_FETCH_TORRENT_TAXONOMY_ERROR', - 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS', - 'CLIENT_FETCH_TORRENT_DETAILS_ERROR', - 'CLIENT_FETCH_TORRENT_DETAILS_SUCCESS', - 'CLIENT_FETCH_TRANSFER_HISTORY_ERROR', - 'CLIENT_FETCH_TRANSFER_HISTORY_SUCCESS', - 'CLIENT_MOVE_TORRENTS_SUCCESS', - 'CLIENT_MOVE_TORRENTS_ERROR', - 'CLIENT_REMOVE_TORRENT_ERROR', - 'CLIENT_REMOVE_TORRENT_SUCCESS', - 'CLIENT_SET_FILE_PRIORITY_ERROR', - 'CLIENT_SET_FILE_PRIORITY_SUCCESS', - 'CLIENT_SET_TAXONOMY_ERROR', - 'CLIENT_SET_TAXONOMY_SUCCESS', - 'CLIENT_SET_THROTTLE_ERROR', - 'CLIENT_SET_THROTTLE_SUCCESS', - 'CLIENT_SET_TORRENT_PRIORITY_ERROR', - 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', - 'CLIENT_SET_TRACKER_ERROR', - 'CLIENT_SET_TRACKER_SUCCESS', - 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR', - 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS', - 'CLIENT_SETTINGS_SAVE_ERROR', - 'CLIENT_SETTINGS_SAVE_SUCCESS', - 'CLIENT_START_TORRENT_ERROR', - 'CLIENT_START_TORRENT_SUCCESS', - 'CLIENT_STOP_TORRENT_ERROR', - 'CLIENT_STOP_TORRENT_SUCCESS', - 'FLOOD_FETCH_NOTIFICATIONS_ERROR', - 'FLOOD_FETCH_NOTIFICATIONS_SUCCESS', - 'FLOOD_FETCH_MEDIAINFO_SUCCESS', - 'NOTIFICATION_COUNT_CHANGE', - 'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR', - 'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS', - 'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR', - 'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS', - 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR', - 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS', - 'SETTINGS_FEED_MONITORS_FETCH_ERROR', - 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS', - 'SETTINGS_FEED_MONITOR_REMOVE_ERROR', - 'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS', - 'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR', - 'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS', - 'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR', - 'SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS', - 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR', - 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS', - 'SETTINGS_FETCH_REQUEST_SUCCESS', - 'SETTINGS_FETCH_REQUEST_ERROR', - 'SETTINGS_SAVE_REQUEST_SUCCESS', - 'SETTINGS_SAVE_REQUEST_ERROR', - 'TAXONOMY_DIFF_CHANGE', - 'TAXONOMY_FULL_UPDATE', - 'TORRENT_LIST_DIFF_CHANGE', - 'TORRENT_LIST_FULL_UPDATE', - 'TRANSFER_HISTORY_FULL_UPDATE', - 'TRANSFER_SUMMARY_DIFF_CHANGE', - 'TRANSFER_SUMMARY_FULL_UPDATE', - 'UI_CLICK_TORRENT', - 'UI_CLICK_TORRENT_DETAILS', - 'UI_DISPLAY_MODAL', - 'UI_DISMISS_CONTEXT_MENU', - 'UI_DISPLAY_CONTEXT_MENU', - 'UI_DISPLAY_DROPDOWN_MENU', - 'UI_LATEST_TORRENT_LOCATION_REQUEST_ERROR', - 'UI_LATEST_TORRENT_LOCATION_REQUEST_SUCCESS', - 'UI_SET_TORRENT_SEARCH_FILTER', - 'UI_SET_TORRENT_SORT', - 'UI_SET_TORRENT_STATUS_FILTER', - 'UI_SET_TORRENT_TAG_FILTER', - 'UI_SET_TORRENT_TRACKER_FILTER', - 'UI_SORT_PROPS_REQUEST_SUCCESS', - 'UI_SORT_PROPS_REQUEST_ERROR', -] as const; - -export default objectUtil.createStringMapFromArray(actionTypes); -export type ActionType = typeof actionTypes[number]; diff --git a/client/src/javascript/constants/EventTypes.tsx b/client/src/javascript/constants/EventTypes.ts similarity index 91% rename from client/src/javascript/constants/EventTypes.tsx rename to client/src/javascript/constants/EventTypes.ts index 4e39761b..45eb3245 100644 --- a/client/src/javascript/constants/EventTypes.tsx +++ b/client/src/javascript/constants/EventTypes.ts @@ -15,7 +15,6 @@ const eventTypes = [ 'CLIENT_CONNECTION_STATUS_CHANGE', 'CLIENT_ADD_TORRENT_ERROR', 'CLIENT_ADD_TORRENT_SUCCESS', - 'CLIENT_FETCH_TORRENT_TAXONOMY_ERROR', 'CLIENT_FETCH_TORRENT_TAXONOMY_SUCCESS', 'CLIENT_SET_FILE_PRIORITY_ERROR', 'CLIENT_SET_FILE_PRIORITY_SUCCESS', @@ -46,6 +45,7 @@ const eventTypes = [ 'NOTIFICATIONS_FETCH_ERROR', 'NOTIFICATIONS_FETCH_SUCCESS', 'NOTIFICATIONS_COUNT_CHANGE', + 'NOTIFICATIONS_CLEAR_SUCCESS', 'SETTINGS_CHANGE', 'SETTINGS_SAVE_REQUEST_ERROR', 'SETTINGS_SAVE_REQUEST_SUCCESS', @@ -88,4 +88,11 @@ const eventTypes = [ ] as const; export default objectUtil.createStringMapFromArray(eventTypes); -export type EventType = typeof eventTypes[number] | 'uncaughtException'; +export type EventType = typeof eventTypes[number]; + +// TODO: Convert all events to type-checked interfaces +export type BaseEvents = { + [type in EventType]: (payload?: unknown) => void; +} & { + uncaughtException: (error?: string) => void; +}; diff --git a/client/src/javascript/constants/Languages.tsx b/client/src/javascript/constants/Languages.ts similarity index 100% rename from client/src/javascript/constants/Languages.tsx rename to client/src/javascript/constants/Languages.ts diff --git a/client/src/javascript/constants/PriorityLevels.tsx b/client/src/javascript/constants/PriorityLevels.ts similarity index 100% rename from client/src/javascript/constants/PriorityLevels.tsx rename to client/src/javascript/constants/PriorityLevels.ts diff --git a/client/src/javascript/constants/ServerActions.ts b/client/src/javascript/constants/ServerActions.ts new file mode 100644 index 00000000..b1f867ec --- /dev/null +++ b/client/src/javascript/constants/ServerActions.ts @@ -0,0 +1,267 @@ +import type {AuthAuthenticationResponse, AuthVerificationResponse, Credentials} from '@shared/types/Auth'; +import type {ClientSettings} from '@shared/constants/clientSettingsMap'; +import type {NotificationFetchOptions, NotificationState} from '@shared/types/Notification'; +import type {ServerEvents} from '@shared/types/ServerEvents'; +import type {TorrentDetails} from '@shared/types/Torrent'; + +import type {FloodSettings, SettingsSaveOptions} from '../stores/SettingsStore'; +import type {Feeds, Items, Rules} from '../stores/FeedsStore'; + +const errorTypes = [ + 'AUTH_LOGIN_ERROR', + 'AUTH_LOGOUT_ERROR', + 'AUTH_REGISTER_ERROR', + 'AUTH_VERIFY_ERROR', + 'CLIENT_ADD_TORRENT_ERROR', + 'FLOOD_CLEAR_NOTIFICATIONS_ERROR', + 'CLIENT_CONNECTION_TEST_ERROR', + 'CLIENT_FETCH_TORRENT_DETAILS_ERROR', + 'CLIENT_SET_FILE_PRIORITY_ERROR', + 'CLIENT_SET_TAXONOMY_ERROR', + 'CLIENT_SET_THROTTLE_ERROR', + 'CLIENT_SET_TORRENT_PRIORITY_ERROR', + 'CLIENT_SET_TRACKER_ERROR', + 'CLIENT_SETTINGS_FETCH_REQUEST_ERROR', + 'CLIENT_SETTINGS_SAVE_ERROR', + 'CLIENT_START_TORRENT_ERROR', + 'CLIENT_STOP_TORRENT_ERROR', + 'FLOOD_FETCH_NOTIFICATIONS_ERROR', + 'SETTINGS_FEED_MONITOR_FEED_ADD_ERROR', + 'SETTINGS_FEED_MONITOR_FEED_MODIFY_ERROR', + 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR', + 'SETTINGS_FEED_MONITORS_FETCH_ERROR', + 'SETTINGS_FEED_MONITOR_RULE_ADD_ERROR', + 'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR', + 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR', + 'SETTINGS_FETCH_REQUEST_ERROR', + 'SETTINGS_SAVE_REQUEST_ERROR', +] as const; + +const successTypes = [ + 'AUTH_LOGOUT_SUCCESS', + 'CLIENT_CHECK_HASH_SUCCESS', + 'CLIENT_CONNECTION_TEST_SUCCESS', + 'CLIENT_SET_FILE_PRIORITY_SUCCESS', + 'CLIENT_SET_TORRENT_PRIORITY_SUCCESS', + 'CLIENT_SET_TAXONOMY_SUCCESS', + 'CLIENT_SET_THROTTLE_SUCCESS', + 'CLIENT_SET_TRACKER_SUCCESS', + 'CLIENT_START_TORRENT_SUCCESS', + 'CLIENT_STOP_TORRENT_SUCCESS', + 'SETTINGS_FEED_MONITOR_FEED_ADD_SUCCESS', + 'SETTINGS_FEED_MONITOR_FEED_MODIFY_SUCCESS', + 'SETTINGS_FEED_MONITOR_RULE_ADD_SUCCESS', +] as const; + +interface BaseErrorAction { + type: typeof errorTypes[number]; + error?: Error; +} + +interface BaseSuccessAction { + type: typeof successTypes[number]; +} + +// AuthActions +interface AuthCreateUserSuccessAction { + type: 'AUTH_CREATE_USER_SUCCESS'; + data: Pick; +} + +interface AuthDeleteUserSuccessAction { + type: 'AUTH_DELETE_USER_SUCCESS'; + data: Pick; +} + +interface AuthDeleteUserErrorAction { + type: 'AUTH_DELETE_USER_ERROR'; + error: Pick; +} + +interface AuthListUsersSuccessAction { + type: 'AUTH_LIST_USERS_SUCCESS'; + data: Array; +} + +interface AuthLoginSuccessAction { + type: 'AUTH_LOGIN_SUCCESS'; + data: AuthAuthenticationResponse; +} + +interface AuthVerifySuccessAction { + type: 'AUTH_VERIFY_SUCCESS'; + data: AuthVerificationResponse; +} + +interface AuthRegisterSuccessAction { + type: 'AUTH_REGISTER_SUCCESS'; + data: AuthAuthenticationResponse; +} + +type AuthAction = + | AuthCreateUserSuccessAction + | AuthDeleteUserSuccessAction + | AuthDeleteUserErrorAction + | AuthListUsersSuccessAction + | AuthLoginSuccessAction + | AuthVerifySuccessAction + | AuthRegisterSuccessAction; + +// ClientActions +interface ClientCheckHashErrorAction { + type: 'CLIENT_CHECK_HASH_ERROR'; + error: { + error: Error; + count: number; + }; +} + +interface ClientSettingsFetchRequestSuccessAction { + type: 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS'; + data: ClientSettings; +} + +export interface ClientSettingsSaveSuccessAction { + type: 'CLIENT_SETTINGS_SAVE_SUCCESS'; + options: SettingsSaveOptions; +} + +type ClientAction = + | ClientCheckHashErrorAction + | ClientSettingsFetchRequestSuccessAction + | ClientSettingsSaveSuccessAction; + +// FloodActions +type ServerEventAction = { + type: T; + data: ServerEvents[T]; +}; + +interface FloodFetchMediainfoSuccessAction { + type: 'FLOOD_FETCH_MEDIAINFO_SUCCESS'; + data: {hash: string; output: string}; +} + +interface FloodClearNotificationsSuccessAction { + type: 'FLOOD_CLEAR_NOTIFICATIONS_SUCCESS'; + data: NotificationFetchOptions; +} + +interface FloodFetchNotificationsSuccessAction { + type: 'FLOOD_FETCH_NOTIFICATIONS_SUCCESS'; + data: NotificationState; +} + +type FloodAction = + | ServerEventAction<'CLIENT_CONNECTIVITY_STATUS_CHANGE'> + | ServerEventAction<'DISK_USAGE_CHANGE'> + | ServerEventAction<'NOTIFICATION_COUNT_CHANGE'> + | ServerEventAction<'TAXONOMY_FULL_UPDATE'> + | ServerEventAction<'TAXONOMY_DIFF_CHANGE'> + | ServerEventAction<'TORRENT_LIST_FULL_UPDATE'> + | ServerEventAction<'TORRENT_LIST_DIFF_CHANGE'> + | ServerEventAction<'TRANSFER_HISTORY_FULL_UPDATE'> + | ServerEventAction<'TRANSFER_SUMMARY_FULL_UPDATE'> + | ServerEventAction<'TRANSFER_SUMMARY_DIFF_CHANGE'> + | FloodFetchMediainfoSuccessAction + | FloodClearNotificationsSuccessAction + | FloodFetchNotificationsSuccessAction; + +// SettingsActions +interface SettingsFeedMonitorRemoveErrorAction { + type: 'SETTINGS_FEED_MONITOR_REMOVE_ERROR'; + error: {id: string}; +} + +interface SettingsFeedMonitorRemoveSuccessAction { + type: 'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS'; + data: {id: string}; +} + +interface SettingsFeedMonitorFeedsFetchSuccessAction { + type: 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS'; + data: Feeds; +} + +interface SettingsFeedMonitorRulesFetchSuccessAction { + type: 'SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS'; + data: Rules; +} + +interface SettingsFeedMonitorsFetchSuccessAction { + type: 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS'; + data: {feeds: Feeds; rules: Rules}; +} + +interface SettingsFeedMonitorItemsFetchSuccessAction { + type: 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS'; + data: Items; +} + +interface SettingsFetchRequestSuccessAction { + type: 'SETTINGS_FETCH_REQUEST_SUCCESS'; + data: Partial; +} + +export interface SettingsSaveRequestSuccessAction { + type: 'SETTINGS_SAVE_REQUEST_SUCCESS'; + options: SettingsSaveOptions; +} + +type SettingsAction = + | SettingsFeedMonitorRemoveErrorAction + | SettingsFeedMonitorRemoveSuccessAction + | SettingsFeedMonitorFeedsFetchSuccessAction + | SettingsFeedMonitorRulesFetchSuccessAction + | SettingsFeedMonitorsFetchSuccessAction + | SettingsFeedMonitorItemsFetchSuccessAction + | SettingsFetchRequestSuccessAction + | SettingsSaveRequestSuccessAction; + +// TorrentActions +interface ClientFetchTorrentDetailsSuccessAction { + type: 'CLIENT_FETCH_TORRENT_DETAILS_SUCCESS'; + data: {hash: string; torrentDetails: TorrentDetails}; +} + +interface ClientAddTorrentSuccessAction { + type: 'CLIENT_ADD_TORRENT_SUCCESS'; + data: {count: number; destination: string}; +} + +interface ClientMoveTorrentsSuccessAction { + type: 'CLIENT_MOVE_TORRENTS_SUCCESS'; + data: {count: number}; +} + +interface ClientMoveTorrentsErrorAction { + type: 'CLIENT_MOVE_TORRENTS_ERROR'; + error: {count: number}; +} + +interface ClientRemoveTorrentSuccessAction { + type: 'CLIENT_REMOVE_TORRENT_SUCCESS'; + data: {count: number; deleteData: boolean}; +} + +interface ClientRemoveTorrentErrorAction { + type: 'CLIENT_REMOVE_TORRENT_ERROR'; + error: {count: number}; +} + +type TorrentAction = + | ClientFetchTorrentDetailsSuccessAction + | ClientAddTorrentSuccessAction + | ClientMoveTorrentsSuccessAction + | ClientMoveTorrentsErrorAction + | ClientRemoveTorrentSuccessAction + | ClientRemoveTorrentErrorAction; + +export type ServerAction = + | BaseErrorAction + | BaseSuccessAction + | AuthAction + | ClientAction + | FloodAction + | SettingsAction + | TorrentAction; diff --git a/client/src/javascript/constants/TorrentContextMenuItems.tsx b/client/src/javascript/constants/TorrentContextMenuItems.ts similarity index 100% rename from client/src/javascript/constants/TorrentContextMenuItems.tsx rename to client/src/javascript/constants/TorrentContextMenuItems.ts diff --git a/client/src/javascript/constants/TorrentProperties.tsx b/client/src/javascript/constants/TorrentProperties.ts similarity index 100% rename from client/src/javascript/constants/TorrentProperties.tsx rename to client/src/javascript/constants/TorrentProperties.ts diff --git a/client/src/javascript/dispatcher/AppDispatcher.ts b/client/src/javascript/dispatcher/AppDispatcher.ts new file mode 100644 index 00000000..09f4cc43 --- /dev/null +++ b/client/src/javascript/dispatcher/AppDispatcher.ts @@ -0,0 +1,26 @@ +import {Dispatcher} from 'flux'; + +import type {UIAction} from '../actions/UIActions'; +import type {ServerAction} from '../constants/ServerActions'; + +type Action = UIAction | ServerAction; + +class FloodDispatcher extends Dispatcher<{source: string; action: Action}> { + dispatchUIAction(action: T) { + if (action.type == null) { + console.warn('Undefined action.type', action); + } + this.dispatch({source: 'UI_ACTION', action}); + } + + dispatchServerAction(action: T) { + if (action.type == null) { + console.warn('Undefined action.type', action); + } + this.dispatch({source: 'SERVER_ACTION', action}); + } +} + +const AppDispatcher = new FloodDispatcher(); + +export default AppDispatcher; diff --git a/client/src/javascript/dispatcher/AppDispatcher.tsx b/client/src/javascript/dispatcher/AppDispatcher.tsx deleted file mode 100644 index b5569d2b..00000000 --- a/client/src/javascript/dispatcher/AppDispatcher.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import {Dispatcher} from 'flux'; - -import type {AuthAuthenticationResponse, AuthVerificationResponse} from '@shared/types/Auth'; - -import type {ActionType} from '../constants/ActionTypes'; - -export interface Action { - type: ActionType; - data?: unknown; - error?: Error; - options?: unknown; -} - -interface AuthLoginSuccessAction { - type: 'AUTH_LOGIN_SUCCESS'; - data: AuthAuthenticationResponse; -} - -interface AuthVerifySuccessAction { - type: 'AUTH_VERIFY_SUCCESS'; - data: AuthVerificationResponse; -} - -interface AuthRegisterSuccessAction { - type: 'AUTH_REGISTER_SUCCESS'; - data: AuthAuthenticationResponse; -} - -type Actions = Action | AuthLoginSuccessAction | AuthVerifySuccessAction | AuthRegisterSuccessAction; - -class FloodDispatcher extends Dispatcher<{source: string; action: Actions}> { - dispatchUIAction(action: T) { - if (action.type == null) { - console.warn('Undefined action.type', action); - } - this.dispatch({source: 'UI_ACTION', action}); - } - - dispatchServerAction(action: T) { - if (action.type == null) { - console.warn('Undefined action.type', action); - } - this.dispatch({source: 'SERVER_ACTION', action}); - } -} - -const AppDispatcher = new FloodDispatcher(); - -export default AppDispatcher; diff --git a/client/src/javascript/stores/AlertStore.tsx b/client/src/javascript/stores/AlertStore.ts similarity index 100% rename from client/src/javascript/stores/AlertStore.tsx rename to client/src/javascript/stores/AlertStore.ts diff --git a/client/src/javascript/stores/AuthStore.tsx b/client/src/javascript/stores/AuthStore.ts similarity index 88% rename from client/src/javascript/stores/AuthStore.tsx rename to client/src/javascript/stores/AuthStore.ts index 11a95ef8..e5edc51a 100644 --- a/client/src/javascript/stores/AuthStore.tsx +++ b/client/src/javascript/stores/AuthStore.ts @@ -21,11 +21,6 @@ class AuthStoreClass extends BaseStore { username: null, }; - addOptimisticUser(credentials: Credentials): void { - this.optimisticUsers.push({username: credentials.username}); - this.emit('AUTH_LIST_USERS_SUCCESS'); - } - getCurrentUsername(): this['currentUser']['username'] { return this.currentUser.username; } @@ -54,13 +49,14 @@ class AuthStoreClass extends BaseStore { return this.users; } - handleCreateUserSuccess(credentials: Credentials): void { - this.addOptimisticUser(credentials); + handleCreateUserSuccess({username}: {username: Credentials['username']}): void { + this.optimisticUsers.push({username}); + this.emit('AUTH_LIST_USERS_SUCCESS'); this.emit('AUTH_CREATE_USER_SUCCESS'); } - handleDeleteUserError(error?: Error & Partial>): void { - this.emit('AUTH_DELETE_USER_ERROR', error != null ? error.username : error); + handleDeleteUserError({username}: {username: Credentials['username']}): void { + this.emit('AUTH_DELETE_USER_ERROR', username); } handleDeleteUserSuccess(credentials: Credentials): void { @@ -144,13 +140,13 @@ AuthStore.dispatcherID = AppDispatcher.register((payload) => { AuthStore.handleLoginError(action.error); break; case 'AUTH_LIST_USERS_SUCCESS': - AuthStore.handleListUsersSuccess(action.data as Array); + AuthStore.handleListUsersSuccess(action.data); break; case 'AUTH_CREATE_USER_SUCCESS': - AuthStore.handleCreateUserSuccess(action.data as Credentials); + AuthStore.handleCreateUserSuccess(action.data); break; case 'AUTH_DELETE_USER_SUCCESS': - AuthStore.handleDeleteUserSuccess(action.data as Credentials); + AuthStore.handleDeleteUserSuccess(action.data); break; case 'AUTH_DELETE_USER_ERROR': AuthStore.handleDeleteUserError(action.error); diff --git a/client/src/javascript/stores/BaseStore.tsx b/client/src/javascript/stores/BaseStore.ts similarity index 58% rename from client/src/javascript/stores/BaseStore.tsx rename to client/src/javascript/stores/BaseStore.ts index c933ae6b..de089f80 100644 --- a/client/src/javascript/stores/BaseStore.tsx +++ b/client/src/javascript/stores/BaseStore.ts @@ -1,20 +1,19 @@ import {EventEmitter} from 'events'; import type TypedEmitter from 'typed-emitter'; -import type {EventType} from '../constants/EventTypes'; +import type {BaseEvents} from '../constants/EventTypes'; -export default class BaseStore extends (EventEmitter as new () => TypedEmitter< - Record void> ->) { +export default class BaseStore extends (EventEmitter as { + new (): TypedEmitter; +}) { dispatcherID: string | null = null; requests: Record = {}; constructor() { - // eslint-disable-next-line constructor-super super(); this.on('uncaughtException', (error) => { - throw new Error(error as string | undefined); + throw new Error(error); }); this.setMaxListeners(20); @@ -36,11 +35,11 @@ export default class BaseStore extends (EventEmitter as new () => TypedEmitter< return true; } - listen(event: EventType, eventHandler: (payload?: unknown) => void): void { + listen(event: T, eventHandler: H): void { this.on(event, eventHandler); } - unlisten(event: EventType, eventHandler: (payload?: unknown) => void): void { + unlisten(event: T, eventHandler: H): void { this.removeListener(event, eventHandler); } } diff --git a/client/src/javascript/stores/ClientStatusStore.tsx b/client/src/javascript/stores/ClientStatusStore.ts similarity index 71% rename from client/src/javascript/stores/ClientStatusStore.tsx rename to client/src/javascript/stores/ClientStatusStore.ts index b0110329..47be6b23 100644 --- a/client/src/javascript/stores/ClientStatusStore.tsx +++ b/client/src/javascript/stores/ClientStatusStore.ts @@ -1,18 +1,14 @@ import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; -interface ClientStatus { - isConnected: boolean; -} - class ClientStatusStoreClass extends BaseStore { - isConnected: ClientStatus['isConnected'] = true; + isConnected = true; - getIsConnected(): ClientStatus['isConnected'] { + getIsConnected(): boolean { return this.isConnected; } - handleConnectivityStatusChange({isConnected}: {isConnected: ClientStatus['isConnected']}) { + handleConnectivityStatusChange({isConnected}: {isConnected: boolean}) { if (this.isConnected !== isConnected) { this.isConnected = isConnected === true; this.emit('CLIENT_CONNECTION_STATUS_CHANGE'); @@ -26,7 +22,7 @@ ClientStatusStore.dispatcherID = AppDispatcher.register((payload) => { const {action} = payload; if (action.type === 'CLIENT_CONNECTIVITY_STATUS_CHANGE') { - ClientStatusStore.handleConnectivityStatusChange(action.data as ClientStatus); + ClientStatusStore.handleConnectivityStatusChange(action.data); } }); diff --git a/client/src/javascript/stores/ConfigStore.tsx b/client/src/javascript/stores/ConfigStore.ts similarity index 100% rename from client/src/javascript/stores/ConfigStore.tsx rename to client/src/javascript/stores/ConfigStore.ts diff --git a/client/src/javascript/stores/DiskUsageStore.tsx b/client/src/javascript/stores/DiskUsageStore.ts similarity index 74% rename from client/src/javascript/stores/DiskUsageStore.tsx rename to client/src/javascript/stores/DiskUsageStore.ts index 7a1e5177..15d06614 100644 --- a/client/src/javascript/stores/DiskUsageStore.tsx +++ b/client/src/javascript/stores/DiskUsageStore.ts @@ -1,15 +1,8 @@ +import type {Disks} from '@shared/types/DiskUsage'; + import BaseStore from './BaseStore'; import AppDispatcher from '../dispatcher/AppDispatcher'; -export interface Disk { - target: string; - size: number; - avail: number; - used: number; -} - -export type Disks = Array; - class DiskUsageStoreClass extends BaseStore { disks: Disks = []; @@ -29,7 +22,7 @@ DiskUsageStore.dispatcherID = AppDispatcher.register((payload) => { const {action} = payload; if (action.type === 'DISK_USAGE_CHANGE') { - DiskUsageStore.setDiskUsage(action.data as Disks); + DiskUsageStore.setDiskUsage(action.data); } }); diff --git a/client/src/javascript/stores/FeedsStore.tsx b/client/src/javascript/stores/FeedsStore.ts similarity index 88% rename from client/src/javascript/stores/FeedsStore.tsx rename to client/src/javascript/stores/FeedsStore.ts index f6636d2c..de126608 100644 --- a/client/src/javascript/stores/FeedsStore.tsx +++ b/client/src/javascript/stores/FeedsStore.ts @@ -52,8 +52,8 @@ export class FeedsStoreClass extends BaseStore { SettingsActions.addRule(rule); } - static fetchFeedMonitors(query?: string) { - SettingsActions.fetchFeedMonitors(query); + static fetchFeedMonitors() { + SettingsActions.fetchFeedMonitors(); } static fetchFeeds(query: string) { @@ -69,11 +69,15 @@ export class FeedsStoreClass extends BaseStore { } static removeFeed(id: Feed['_id']) { - SettingsActions.removeFeedMonitor(id); + if (id != null) { + SettingsActions.removeFeedMonitor(id); + } } static removeRule(id: Rule['_id']) { - SettingsActions.removeFeedMonitor(id); + if (id != null) { + SettingsActions.removeFeedMonitor(id); + } } getFeeds() { @@ -167,7 +171,7 @@ export class FeedsStoreClass extends BaseStore { return; } - this.feeds = feeds.sort((a, b) => { + this.feeds = [...feeds].sort((a, b) => { return a.label.localeCompare(b.label); }); } @@ -178,7 +182,7 @@ export class FeedsStoreClass extends BaseStore { return; } - this.rules = rules.sort((a, b) => { + this.rules = [...rules].sort((a, b) => { return a.label.localeCompare(b.label); }); } @@ -209,34 +213,34 @@ FeedsStore.dispatcherID = AppDispatcher.register((payload) => { FeedsStore.handleRuleAddSuccess(); break; case 'SETTINGS_FEED_MONITOR_REMOVE_ERROR': - FeedsStore.handleFeedMonitorRemoveError((action.error as Error & {id: string}).id); + FeedsStore.handleFeedMonitorRemoveError(action.error.id); break; case 'SETTINGS_FEED_MONITOR_REMOVE_SUCCESS': - FeedsStore.handleFeedMonitorRemoveSuccess((action.data as {id: string}).id); + FeedsStore.handleFeedMonitorRemoveSuccess(action.data.id); break; case 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_ERROR': FeedsStore.handleFeedsFetchError(action.error); break; case 'SETTINGS_FEED_MONITOR_FEEDS_FETCH_SUCCESS': - FeedsStore.handleFeedsFetchSuccess(action.data as Feeds); + FeedsStore.handleFeedsFetchSuccess(action.data); break; case 'SETTINGS_FEED_MONITOR_RULES_FETCH_ERROR': FeedsStore.handleRulesFetchError(action.error); break; case 'SETTINGS_FEED_MONITOR_RULES_FETCH_SUCCESS': - FeedsStore.handleRulesFetchSuccess(action.data as Rules); + FeedsStore.handleRulesFetchSuccess(action.data); break; case 'SETTINGS_FEED_MONITORS_FETCH_ERROR': FeedsStore.handleFeedMonitorsFetchError(action.error); break; case 'SETTINGS_FEED_MONITORS_FETCH_SUCCESS': - FeedsStore.handleFeedMonitorsFetchSuccess(action.data as {feeds: Feeds; rules: Rules}); + FeedsStore.handleFeedMonitorsFetchSuccess(action.data); break; case 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_ERROR': FeedsStore.handleItemsFetchError(action.error); break; case 'SETTINGS_FEED_MONITOR_ITEMS_FETCH_SUCCESS': - FeedsStore.handleItemsFetchSuccess(action.data as Items); + FeedsStore.handleItemsFetchSuccess(action.data); break; default: break; diff --git a/client/src/javascript/stores/NotificationStore.tsx b/client/src/javascript/stores/NotificationStore.ts similarity index 64% rename from client/src/javascript/stores/NotificationStore.tsx rename to client/src/javascript/stores/NotificationStore.ts index 9598f668..a5192b7c 100644 --- a/client/src/javascript/stores/NotificationStore.tsx +++ b/client/src/javascript/stores/NotificationStore.ts @@ -1,39 +1,9 @@ +import type {NotificationCount, NotificationFetchOptions, NotificationState} from '@shared/types/Notification'; + import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; import FloodActions from '../actions/FloodActions'; -export interface Notification { - _id: string; - id: 'notification.torrent.finished' | 'notification.torrent.errored' | 'notification.feed.downloaded.torrent'; - read: boolean; - ts: number; // timestamp - data: { - name: string; - ruleLabel?: string; - feedLabel?: string; - title?: string; - }; -} - -export interface NotificationCount { - total: number; - unread: number; - read: number; -} - -export interface NotificationState { - id: string; - count: NotificationCount; - limit: number; - start: number; - notifications: Array; -} - -interface NotificationClearOptions { - id: string; - limit: number; -} - const INITIAL_COUNT_STATE: NotificationCount = {total: 0, unread: 0, read: 0}; class NotificationStoreClass extends BaseStore { @@ -41,7 +11,7 @@ class NotificationStoreClass extends BaseStore { notificationCount: NotificationCount = INITIAL_COUNT_STATE; ongoingPolls = {}; - clearAll(options: NotificationClearOptions) { + clearAll(options: NotificationFetchOptions) { this.notifications = {}; FloodActions.clearNotifications(options); } @@ -59,11 +29,9 @@ class NotificationStoreClass extends BaseStore { this.emit('NOTIFICATIONS_COUNT_CHANGE', notificationCount); } - static handleNotificationsClearSuccess(options: NotificationClearOptions) { - FloodActions.fetchNotifications({ - ...options, - start: 0, - }); + handleNotificationsClearSuccess(options: NotificationFetchOptions) { + FloodActions.fetchNotifications(options); + this.emit('NOTIFICATIONS_CLEAR_SUCCESS'); } handleNotificationsFetchError() { @@ -83,16 +51,16 @@ NotificationStore.dispatcherID = AppDispatcher.register((payload) => { switch (action.type) { case 'FLOOD_CLEAR_NOTIFICATIONS_SUCCESS': - NotificationStoreClass.handleNotificationsClearSuccess(action.data as NotificationClearOptions); + NotificationStore.handleNotificationsClearSuccess(action.data); break; case 'FLOOD_FETCH_NOTIFICATIONS_ERROR': NotificationStore.handleNotificationsFetchError(); break; case 'FLOOD_FETCH_NOTIFICATIONS_SUCCESS': - NotificationStore.handleNotificationsFetchSuccess(action.data as NotificationState); + NotificationStore.handleNotificationsFetchSuccess(action.data); break; case 'NOTIFICATION_COUNT_CHANGE': - NotificationStore.handleNotificationCountChange(action.data as NotificationCount); + NotificationStore.handleNotificationCountChange(action.data); break; default: break; diff --git a/client/src/javascript/stores/SettingsStore.tsx b/client/src/javascript/stores/SettingsStore.ts similarity index 91% rename from client/src/javascript/stores/SettingsStore.tsx rename to client/src/javascript/stores/SettingsStore.ts index 026cc643..4a6b7956 100644 --- a/client/src/javascript/stores/SettingsStore.tsx +++ b/client/src/javascript/stores/SettingsStore.ts @@ -1,15 +1,19 @@ +import type {ClientSetting, ClientSettings} from '@shared/constants/clientSettingsMap'; + import AlertStore from './AlertStore'; import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; import ClientActions from '../actions/ClientActions'; -import SettingsActions from '../actions/SettingsActions'; -import UIStore from './UIStore'; - import Languages from '../constants/Languages'; +import SettingsActions from '../actions/SettingsActions'; import TorrentContextMenuItems from '../constants/TorrentContextMenuItems'; import TorrentProperties from '../constants/TorrentProperties'; +import UIStore from './UIStore'; -import type {ClientSetting, ClientSettings} from '../../../../shared/types/ClientSettings'; +export interface SettingsSaveOptions { + alert?: boolean; + dismissModal?: boolean; +} type FloodSetting = keyof FloodSettings; export interface FloodSettings { @@ -43,13 +47,13 @@ export interface FloodSettings { deleteTorrentData?: boolean; } -type SettingUpdate = { +export type SettingUpdate = { id: Prop; data: Type[Prop]; }; -type SettingUpdatesClient = Array>; -type SettingUpdatesFlood = Array>; +export type SettingUpdatesClient = Array>; +export type SettingUpdatesFlood = Array>; class SettingsStoreClass extends BaseStore { fetchStatus = { @@ -199,7 +203,7 @@ class SettingsStoreClass extends BaseStore { } } - saveFloodSettings(settings: SettingUpdatesFlood, options: Record = {}) { + saveFloodSettings(settings: SettingUpdatesFlood, options: SettingsSaveOptions = {}) { SettingsActions.saveSettings(settings, options); settings.forEach(

({id, data}: {id: P; data: V}) => { this.floodSettings[id] = data; @@ -207,7 +211,7 @@ class SettingsStoreClass extends BaseStore { this.emit('SETTINGS_CHANGE'); } - saveClientSettings(settings: SettingUpdatesClient, options: Record = {}) { + saveClientSettings(settings: SettingUpdatesClient, options: SettingsSaveOptions = {}) { ClientActions.saveSettings(settings, options); settings.forEach(

({id, data}: {id: P; data: V}) => { if (id === 'dht') { @@ -242,7 +246,7 @@ SettingsStore.dispatcherID = AppDispatcher.register((payload) => { SettingsStore.handleClientSettingsFetchError(); break; case 'CLIENT_SETTINGS_FETCH_REQUEST_SUCCESS': - SettingsStore.handleClientSettingsFetchSuccess(action.data as ClientSettings); + SettingsStore.handleClientSettingsFetchSuccess(action.data); break; case 'CLIENT_SET_THROTTLE_SUCCESS': ClientActions.fetchSettings(); @@ -251,19 +255,19 @@ SettingsStore.dispatcherID = AppDispatcher.register((payload) => { SettingsStore.handleSettingsFetchError(); break; case 'SETTINGS_FETCH_REQUEST_SUCCESS': - SettingsStore.handleSettingsFetchSuccess(action.data as Partial); + SettingsStore.handleSettingsFetchSuccess(action.data); break; case 'SETTINGS_SAVE_REQUEST_ERROR': SettingsStore.handleSettingsSaveRequestError(); break; case 'SETTINGS_SAVE_REQUEST_SUCCESS': - SettingsStore.handleSettingsSaveRequestSuccess(action.options as {alert?: boolean; dismissModal?: boolean}); + SettingsStore.handleSettingsSaveRequestSuccess(action.options); break; case 'CLIENT_SETTINGS_SAVE_ERROR': SettingsStore.handleClientSettingsSaveRequestError(); break; case 'CLIENT_SETTINGS_SAVE_SUCCESS': - SettingsStore.handleClientSettingsSaveRequestSuccess(action.options as {alert?: boolean; dismissModal?: boolean}); + SettingsStore.handleClientSettingsSaveRequestSuccess(action.options); break; default: break; diff --git a/client/src/javascript/stores/TorrentFilterStore.tsx b/client/src/javascript/stores/TorrentFilterStore.ts similarity index 76% rename from client/src/javascript/stores/TorrentFilterStore.tsx rename to client/src/javascript/stores/TorrentFilterStore.ts index 331f87c4..4d762ab8 100644 --- a/client/src/javascript/stores/TorrentFilterStore.tsx +++ b/client/src/javascript/stores/TorrentFilterStore.ts @@ -1,35 +1,12 @@ +import type {Taxonomy, TaxonomyDiffs} from '@shared/types/Taxonomy'; +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; + import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; -interface Taxonomy { - statusCounts: Record; // TODO: Use string literals when torrentStatusMap is TS. - tagCounts: Record; - trackerCounts: Record; -} - -// TODO: Import from diffActionTypes when it is TS. -type TaxonomyDiffAction = 'ITEM_ADDED' | 'ITEM_CHANGED' | 'ITEM_REMOVED'; - -type TaxonomyDiff = Array< - | { - action: Exclude; - data: Taxonomy[T]; - } - | { - action: 'ITEM_REMOVED'; - data: keyof Taxonomy[T]; - } -> | null; - -interface TaxonomyDiffs { - statusCounts: TaxonomyDiff<'statusCounts'>; - tagCounts: TaxonomyDiff<'tagCounts'>; - trackerCounts: TaxonomyDiff<'trackerCounts'>; -} - class TorrentFilterStoreClass extends BaseStore { searchFilter = ''; - statusFilter = 'all'; + statusFilter: TorrentStatus | 'all' = 'all'; tagFilter = 'all'; trackerFilter = 'all'; @@ -80,9 +57,9 @@ class TorrentFilterStoreClass extends BaseStore { } handleTorrentTaxonomyDiffChange(diff: TaxonomyDiffs) { - Object.keys(diff).forEach((key) => { + Object.entries(diff).forEach(([key, value]) => { const taxonomyKey = key as keyof TaxonomyDiffs; - const changes = diff[taxonomyKey]; + const changes = value as TaxonomyDiffs[keyof TaxonomyDiffs]; if (changes == null) { return; @@ -136,7 +113,7 @@ class TorrentFilterStoreClass extends BaseStore { this.emit('UI_TORRENTS_FILTER_SEARCH_CHANGE'); } - setStatusFilter(filter: string) { + setStatusFilter(filter: TorrentStatus) { this.statusFilter = filter; this.emit('UI_TORRENTS_FILTER_CHANGE'); this.emit('UI_TORRENTS_FILTER_STATUS_CHANGE'); @@ -162,22 +139,22 @@ TorrentFilterStore.dispatcherID = AppDispatcher.register((payload) => { switch (action.type) { case 'UI_SET_TORRENT_SEARCH_FILTER': - TorrentFilterStore.setSearchFilter(action.data as string); + TorrentFilterStore.setSearchFilter(action.data); break; case 'UI_SET_TORRENT_STATUS_FILTER': - TorrentFilterStore.setStatusFilter(action.data as string); + TorrentFilterStore.setStatusFilter(action.data); break; case 'UI_SET_TORRENT_TAG_FILTER': - TorrentFilterStore.setTagFilter(action.data as string); + TorrentFilterStore.setTagFilter(action.data); break; case 'UI_SET_TORRENT_TRACKER_FILTER': - TorrentFilterStore.setTrackerFilter(action.data as string); + TorrentFilterStore.setTrackerFilter(action.data); break; case 'TAXONOMY_FULL_UPDATE': - TorrentFilterStore.handleTorrentTaxonomyFullUpdate(action.data as Taxonomy); + TorrentFilterStore.handleTorrentTaxonomyFullUpdate(action.data); break; case 'TAXONOMY_DIFF_CHANGE': - TorrentFilterStore.handleTorrentTaxonomyDiffChange(action.data as TaxonomyDiffs); + TorrentFilterStore.handleTorrentTaxonomyDiffChange(action.data); break; default: break; diff --git a/client/src/javascript/stores/TorrentStore.tsx b/client/src/javascript/stores/TorrentStore.ts similarity index 68% rename from client/src/javascript/stores/TorrentStore.tsx rename to client/src/javascript/stores/TorrentStore.ts index 630b08d3..a5113a10 100644 --- a/client/src/javascript/stores/TorrentStore.tsx +++ b/client/src/javascript/stores/TorrentStore.ts @@ -1,10 +1,10 @@ -import serverEventTypes from '@shared/constants/serverEventTypes'; +import type {TorrentDetails, TorrentProperties, TorrentListDiff, Torrents} from '@shared/types/Torrent'; import AlertStore from './AlertStore'; import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; import ConfigStore from './ConfigStore'; -import {filterTorrents} from '../util/filterTorrents'; +import filterTorrents from '../util/filterTorrents'; import searchTorrents from '../util/searchTorrents'; import selectTorrents from '../util/selectTorrents'; import SettingsStore from './SettingsStore'; @@ -15,114 +15,6 @@ import UIStore from './UIStore'; import type {FloodSettings} from './SettingsStore'; -export interface Duration { - weeks?: number; - days?: number; - hours?: number; - minutes?: number; - seconds?: number; - cumSeconds: number; -} - -interface TorrentDetails { - fileTree: { - files: Array<{ - index: number; - filename: string; - path: string; - percentComplete: number; - priority: number; - sizeBytes: number; - }>; - peers: Array; - trackers: Array; - }; -} - -// TODO: Unite with torrentPeerPropsMap when it is TS. -export interface TorrentPeer { - index: number; - country: string; - address: string; - completedPercent: number; - clientVersion: string; - downloadRate: number; - downloadTotal: number; - uploadRate: number; - uploadTotal: number; - id: string; - peerRate: number; - peerTotal: number; - isEncrypted: boolean; - isIncoming: boolean; -} - -// TODO: Unite with torrentTrackerPropsMap when it is TS. -export interface TorrentTracker { - index: number; - id: string; - url: string; - type: number; - group: number; - minInterval: number; - normalInterval: number; -} - -// TODO: Rampant over-fetching of torrent properties. Need to remove unused items. -// TODO: Unite with torrentListPropMap when it is TS. -export interface TorrentProperties { - baseDirectory: string; - baseFilename: string; - basePath: string; - bytesDone: number; - comment: string; - dateAdded: string; - dateCreated: string; - details: TorrentDetails; - directory: string; - downRate: number; - downTotal: number; - eta: 'Infinity' | Duration; - hash: string; - ignoreScheduler: boolean; - isActive: boolean; - isComplete: boolean; - isHashing: string; - isMultiFile: boolean; - isOpen: boolean; - isPrivate: boolean; - isStateChanged: boolean; - message: string; - name: string; - peersConnected: number; - peersTotal: number; - percentComplete: number; - priority: string; - ratio: number; - seedingTime: string; - seedsConnected: number; - seedsTotal: number; - sizeBytes: number; - state: string; - status: Array; - tags: Array; - throttleName: string; - trackerURIs: Array; - upRate: number; - upTotal: number; -} - -interface TorrentPropertiesDiff { - [hash: string]: { - action: string; - data?: Partial; - }; -} - -export interface Torrents { - [hash: string]: TorrentProperties; -} - const pollInterval: number = ConfigStore.getPollInterval(); class TorrentStoreClass extends BaseStore { @@ -137,7 +29,11 @@ class TorrentStoreClass extends BaseStore { fetchTorrentDetails(options: {forceUpdate?: boolean} = {}) { if (!this.isRequestPending('fetch-torrent-details') || options.forceUpdate) { this.beginRequest('fetch-torrent-details'); - TorrentActions.fetchTorrentDetails(UIStore.getTorrentDetailsHash()); + + const hash = UIStore.getTorrentDetailsHash(); + if (hash != null) { + TorrentActions.fetchTorrentDetails(hash); + } } if (this.pollTorrentDetailsIntervalID === null) { @@ -269,7 +165,7 @@ class TorrentStoreClass extends BaseStore { }); } - handleMoveTorrentsError(error: Error & {count: number}) { + handleMoveTorrentsError(error: {count: number}) { this.emit('CLIENT_MOVE_TORRENTS_REQUEST_ERROR'); AlertStore.add({ @@ -293,7 +189,7 @@ class TorrentStoreClass extends BaseStore { }); } - static handleRemoveTorrentsError(error: Error & {count: number}) { + static handleRemoveTorrentsError(error: {count: number}) { AlertStore.add({ accumulation: { id: 'alert.torrent.remove.failed', @@ -318,26 +214,29 @@ class TorrentStoreClass extends BaseStore { this.fetchTorrentDetails({forceUpdate: true}); } - handleTorrentListDiffChange(torrentListDiff: TorrentPropertiesDiff) { + handleTorrentListDiffChange(torrentListDiff: TorrentListDiff) { Object.keys(torrentListDiff).forEach((torrentHash) => { - const {action, data} = torrentListDiff[torrentHash]; + const diff = torrentListDiff[torrentHash]; - switch (action) { - case serverEventTypes.TORRENT_LIST_ACTION_TORRENT_ADDED: - this.torrents[torrentHash] = data as TorrentProperties; + switch (diff.action) { + case 'TORRENT_LIST_ACTION_TORRENT_ADDED': + this.torrents[torrentHash] = diff.data as TorrentProperties; break; - case serverEventTypes.TORRENT_LIST_ACTION_TORRENT_DELETED: + case 'TORRENT_LIST_ACTION_TORRENT_DELETED': if (this.selectedTorrents.includes(torrentHash)) { this.selectedTorrents = this.selectedTorrents.filter((hash: string) => hash !== torrentHash); } delete this.torrents[torrentHash]; break; - case serverEventTypes.TORRENT_LIST_ACTION_TORRENT_DETAIL_UPDATED: - if (data == null) { + case 'TORRENT_LIST_ACTION_TORRENT_DETAIL_UPDATED': + if (diff.data == null || this.torrents[torrentHash] == null) { break; } - Object.assign(this.torrents[torrentHash], data); + this.torrents[torrentHash] = { + ...this.torrents[torrentHash], + ...diff.data, + }; break; default: break; @@ -401,31 +300,31 @@ TorrentStore.dispatcherID = AppDispatcher.register((payload) => { switch (action.type) { case 'CLIENT_FETCH_TORRENT_DETAILS_SUCCESS': - TorrentStore.handleFetchTorrentDetails(action.data as {hash: string; torrentDetails: TorrentDetails}); + TorrentStore.handleFetchTorrentDetails(action.data); break; case 'CLIENT_ADD_TORRENT_ERROR': TorrentStore.handleAddTorrentError(); break; case 'CLIENT_ADD_TORRENT_SUCCESS': - TorrentStore.handleAddTorrentSuccess(action.data as {count: number; destination: string}); + TorrentStore.handleAddTorrentSuccess(action.data); break; case 'TORRENT_LIST_DIFF_CHANGE': - TorrentStore.handleTorrentListDiffChange(action.data as TorrentPropertiesDiff); + TorrentStore.handleTorrentListDiffChange(action.data); break; case 'TORRENT_LIST_FULL_UPDATE': - TorrentStore.handleTorrentListFullUpdate(action.data as Torrents); + TorrentStore.handleTorrentListFullUpdate(action.data); break; case 'CLIENT_MOVE_TORRENTS_SUCCESS': - TorrentStore.handleMoveTorrentsSuccess(action.data as {count: number}); + TorrentStore.handleMoveTorrentsSuccess(action.data); break; case 'CLIENT_MOVE_TORRENTS_ERROR': - TorrentStore.handleMoveTorrentsError(action.error as Error & {count: number}); + TorrentStore.handleMoveTorrentsError(action.error); break; case 'CLIENT_REMOVE_TORRENT_SUCCESS': - TorrentStoreClass.handleRemoveTorrentsSuccess(action.data as {count: number; deleteData: boolean}); + TorrentStoreClass.handleRemoveTorrentsSuccess(action.data); break; case 'CLIENT_REMOVE_TORRENT_ERROR': - TorrentStoreClass.handleRemoveTorrentsError(action.error as Error & {count: number}); + TorrentStoreClass.handleRemoveTorrentsError(action.error); break; case 'CLIENT_SET_FILE_PRIORITY_SUCCESS': TorrentStore.handleSetFilePrioritySuccess(); @@ -437,13 +336,13 @@ TorrentStore.dispatcherID = AppDispatcher.register((payload) => { // TODO: popup set tracker failed message here break; case 'FLOOD_FETCH_MEDIAINFO_SUCCESS': - TorrentStore.handleFetchMediainfoSuccess(action.data as {hash: string; output: string}); + TorrentStore.handleFetchMediainfoSuccess(action.data); break; case 'UI_CLICK_TORRENT': - TorrentStore.setSelectedTorrents(action.data as {event: React.MouseEvent; hash: string}); + TorrentStore.setSelectedTorrents(action.data); break; case 'UI_SET_TORRENT_SORT': - TorrentStore.triggerTorrentsSort(action.data as FloodSettings['sortTorrents']); + TorrentStore.triggerTorrentsSort(action.data); break; case 'UI_SET_TORRENT_SEARCH_FILTER': case 'UI_SET_TORRENT_STATUS_FILTER': diff --git a/client/src/javascript/stores/TransferDataStore.tsx b/client/src/javascript/stores/TransferDataStore.ts similarity index 78% rename from client/src/javascript/stores/TransferDataStore.tsx rename to client/src/javascript/stores/TransferDataStore.ts index 3a8c5ec8..6a750a28 100644 --- a/client/src/javascript/stores/TransferDataStore.tsx +++ b/client/src/javascript/stores/TransferDataStore.ts @@ -1,33 +1,14 @@ +import type { + TransferDirection, + TransferHistory, + TransferSummary, + TransferSummaryDiff, +} from '@shared/types/TransferData'; + import AppDispatcher from '../dispatcher/AppDispatcher'; import BaseStore from './BaseStore'; -// TODO: Import from diffActionTypes when it is TS. -type TransferSummaryDiffAction = 'ITEM_ADDED' | 'ITEM_CHANGED' | 'ITEM_REMOVED'; - -export interface TransferSummary { - downRate: number; - downThrottle: number; - downTotal: number; - upRate: number; - upThrottle: number; - upTotal: number; -} - -type TransferSummaryDiff = Array< - | { - action: Exclude; - data: Partial; - } - | { - action: 'ITEM_REMOVED'; - data: keyof TransferSummary; - } ->; - -export const TRANSFER_DIRECTIONS = ['download', 'upload'] as const; -export type TransferDirection = typeof TRANSFER_DIRECTIONS[number]; - -type TransferHistory = Record>; +export const TRANSFER_DIRECTIONS: Readonly> = ['download', 'upload'] as const; class TransferDataStoreClass extends BaseStore { transferRates: TransferHistory = {download: [], upload: [], timestamps: []}; @@ -108,10 +89,10 @@ TransferDataStore.dispatcherID = AppDispatcher.register((payload) => { switch (action.type) { case 'TRANSFER_SUMMARY_DIFF_CHANGE': - TransferDataStore.handleTransferSummaryDiffChange(action.data as TransferSummaryDiff); + TransferDataStore.handleTransferSummaryDiffChange(action.data); break; case 'TRANSFER_SUMMARY_FULL_UPDATE': - TransferDataStore.handleTransferSummaryFullUpdate(action.data as TransferSummary); + TransferDataStore.handleTransferSummaryFullUpdate(action.data); break; case 'CLIENT_SET_THROTTLE_SUCCESS': TransferDataStore.handleSetThrottleSuccess(); @@ -120,7 +101,7 @@ TransferDataStore.dispatcherID = AppDispatcher.register((payload) => { TransferDataStore.handleSetThrottleError(); break; case 'TRANSFER_HISTORY_FULL_UPDATE': - TransferDataStore.handleFetchTransferHistorySuccess(action.data as TransferHistory); + TransferDataStore.handleFetchTransferHistorySuccess(action.data); break; default: break; diff --git a/client/src/javascript/stores/UIStore.tsx b/client/src/javascript/stores/UIStore.ts similarity index 94% rename from client/src/javascript/stores/UIStore.tsx rename to client/src/javascript/stores/UIStore.ts index df266920..be1a0a15 100644 --- a/client/src/javascript/stores/UIStore.tsx +++ b/client/src/javascript/stores/UIStore.ts @@ -188,13 +188,13 @@ UIStore.dispatcherID = AppDispatcher.register((payload) => { switch (action.type) { case 'UI_CLICK_TORRENT': - UIStore.handleTorrentClick(action.data as {hash: string}); + UIStore.handleTorrentClick(action.data); break; case 'UI_DISPLAY_DROPDOWN_MENU': - UIStore.setActiveDropdownMenu(action.data as string); + UIStore.setActiveDropdownMenu(action.data); break; case 'UI_DISPLAY_MODAL': - UIStore.setActiveModal(action.data as {id: string} | null); + UIStore.setActiveModal(action.data); break; case 'CLIENT_SET_TAXONOMY_SUCCESS': UIStore.handleSetTaxonomySuccess(); @@ -205,10 +205,10 @@ UIStore.dispatcherID = AppDispatcher.register((payload) => { UIStore.dismissModal(); break; case 'UI_DISMISS_CONTEXT_MENU': - UIStore.dismissContextMenu(action.data as ContextMenu['id']); + UIStore.dismissContextMenu(action.data); break; case 'UI_DISPLAY_CONTEXT_MENU': - UIStore.setActiveContextMenu(action.data as ContextMenu); + UIStore.setActiveContextMenu(action.data); break; case 'NOTIFICATION_COUNT_CHANGE': UIStore.satisfyDependency('notifications'); diff --git a/client/src/javascript/util/detectLocale.tsx b/client/src/javascript/util/detectLocale.ts similarity index 100% rename from client/src/javascript/util/detectLocale.tsx rename to client/src/javascript/util/detectLocale.ts diff --git a/client/src/javascript/util/filterTorrents.js b/client/src/javascript/util/filterTorrents.ts similarity index 51% rename from client/src/javascript/util/filterTorrents.js rename to client/src/javascript/util/filterTorrents.ts index 67e29068..3227c5d0 100644 --- a/client/src/javascript/util/filterTorrents.js +++ b/client/src/javascript/util/filterTorrents.ts @@ -1,12 +1,27 @@ -import torrentStatusMap from '@shared/constants/torrentStatusMap'; +import type {TorrentProperties} from '@shared/types/Torrent'; +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; -export function filterTorrents(torrentList, opts) { +interface StatusFilter { + type: 'status'; + filter: TorrentStatus; +} + +interface TrackerFilter { + type: 'tracker'; + filter: string; +} + +interface TagFilter { + type: 'tag'; + filter: string; +} + +function filterTorrents(torrentList: Array, opts: StatusFilter | TrackerFilter | TagFilter) { const {type, filter} = opts; if (filter !== 'all') { if (type === 'status') { - const statusFilter = torrentStatusMap[filter]; - return torrentList.filter((torrent) => torrent.status.includes(statusFilter)); + return torrentList.filter((torrent) => torrent.status.includes(filter as TorrentStatus)); } if (type === 'tracker') { return torrentList.filter((torrent) => torrent.trackerURIs.includes(filter)); @@ -24,3 +39,5 @@ export function filterTorrents(torrentList, opts) { return torrentList; } + +export default filterTorrents; diff --git a/client/src/javascript/util/history.tsx b/client/src/javascript/util/history.ts similarity index 100% rename from client/src/javascript/util/history.tsx rename to client/src/javascript/util/history.ts diff --git a/client/src/javascript/util/searchTorrents.tsx b/client/src/javascript/util/searchTorrents.ts similarity index 91% rename from client/src/javascript/util/searchTorrents.tsx rename to client/src/javascript/util/searchTorrents.ts index 545d1511..f0e5325b 100644 --- a/client/src/javascript/util/searchTorrents.tsx +++ b/client/src/javascript/util/searchTorrents.ts @@ -1,4 +1,4 @@ -import type {TorrentProperties} from '../stores/TorrentStore'; +import type {TorrentProperties} from '@shared/types/Torrent'; function searchTorrents(torrents: Array, searchString: string): Array { if (searchString !== '') { diff --git a/client/src/javascript/util/selectTorrents.tsx b/client/src/javascript/util/selectTorrents.ts similarity index 97% rename from client/src/javascript/util/selectTorrents.tsx rename to client/src/javascript/util/selectTorrents.ts index 91a89743..4d75b8f4 100644 --- a/client/src/javascript/util/selectTorrents.tsx +++ b/client/src/javascript/util/selectTorrents.ts @@ -1,6 +1,6 @@ import React from 'react'; -import type {TorrentProperties} from '../stores/TorrentStore'; +import type {TorrentProperties} from '@shared/types/Torrent'; interface SelectTorrentOptions { event: React.MouseEvent; diff --git a/client/src/javascript/util/size.tsx b/client/src/javascript/util/size.ts similarity index 100% rename from client/src/javascript/util/size.tsx rename to client/src/javascript/util/size.ts diff --git a/client/src/javascript/util/sortTorrents.tsx b/client/src/javascript/util/sortTorrents.ts similarity index 96% rename from client/src/javascript/util/sortTorrents.tsx rename to client/src/javascript/util/sortTorrents.ts index 1fd93a6f..aa6b4623 100644 --- a/client/src/javascript/util/sortTorrents.tsx +++ b/client/src/javascript/util/sortTorrents.ts @@ -1,9 +1,10 @@ -// TODO: Split up this garbage. +import type {Duration, TorrentProperties, Torrents} from '@shared/types/Torrent'; + import type {FloodSettings} from '../stores/SettingsStore'; -import type {Duration, Torrents, TorrentProperties} from '../stores/TorrentStore'; const stringProps = ['basePath', 'comment', 'hash', 'message', 'name']; +// TODO: Split up this garbage. function sortTorrents(torrentsHash: Torrents, sortBy: FloodSettings['sortTorrents']) { const torrents = Object.keys(torrentsHash).map((hash) => ({...torrentsHash[hash]})); diff --git a/client/src/javascript/util/torrentStatusClasses.js b/client/src/javascript/util/torrentStatusClasses.js deleted file mode 100644 index 1a72dbe5..00000000 --- a/client/src/javascript/util/torrentStatusClasses.js +++ /dev/null @@ -1,16 +0,0 @@ -import classnames from 'classnames'; -import torrentStatusMap from '@shared/constants/torrentStatusMap'; - -export function torrentStatusClasses(torrent, ...classes) { - return classnames(classes, { - 'torrent--has-error': torrent.status.includes(torrentStatusMap.error), - 'torrent--is-stopped': torrent.status.includes(torrentStatusMap.stopped), - 'torrent--is-downloading': torrent.status.includes(torrentStatusMap.downloading), - 'torrent--is-downloading--actively': torrent.status.includes(torrentStatusMap.activelyDownloading), - 'torrent--is-uploading--actively': torrent.status.includes(torrentStatusMap.activelyUploading), - 'torrent--is-seeding': torrent.status.includes(torrentStatusMap.seeding), - 'torrent--is-completed': torrent.status.includes(torrentStatusMap.complete), - 'torrent--is-checking': torrent.status.includes(torrentStatusMap.checking), - 'torrent--is-inactive': torrent.status.includes(torrentStatusMap.inactive), - }); -} diff --git a/client/src/javascript/util/torrentStatusClasses.ts b/client/src/javascript/util/torrentStatusClasses.ts new file mode 100644 index 00000000..2c0f6301 --- /dev/null +++ b/client/src/javascript/util/torrentStatusClasses.ts @@ -0,0 +1,18 @@ +import classnames from 'classnames'; +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; + +function torrentStatusClasses(torrent: {status: Array}, ...classes: Array) { + return classnames(classes, { + 'torrent--has-error': torrent.status.includes('error'), + 'torrent--is-stopped': torrent.status.includes('stopped'), + 'torrent--is-downloading': torrent.status.includes('downloading'), + 'torrent--is-downloading--actively': torrent.status.includes('activelyDownloading'), + 'torrent--is-uploading--actively': torrent.status.includes('activelyUploading'), + 'torrent--is-seeding': torrent.status.includes('seeding'), + 'torrent--is-completed': torrent.status.includes('complete'), + 'torrent--is-checking': torrent.status.includes('checking'), + 'torrent--is-inactive': torrent.status.includes('inactive'), + }); +} + +export default torrentStatusClasses; diff --git a/client/src/javascript/util/torrentStatusIcons.js b/client/src/javascript/util/torrentStatusIcons.js deleted file mode 100644 index 7e477f51..00000000 --- a/client/src/javascript/util/torrentStatusIcons.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import torrentStatusMap from '@shared/constants/torrentStatusMap'; - -import ErrorIcon from '../components/icons/ErrorIcon'; -import SpinnerIcon from '../components/icons/SpinnerIcon'; -import StartIcon from '../components/icons/StartIcon'; -import StopIcon from '../components/icons/StopIcon'; - -const STATUS_ICON_MAP = { - error: , - hashChecking: , - stopped: , - running: , -}; - -export function torrentStatusIcons(status) { - let statusString; - const statusConditions = { - hashChecking: [status.includes(torrentStatusMap.checking)], - error: [status.includes(torrentStatusMap.error)], - stopped: [status.includes(torrentStatusMap.stopped)], - running: [status.includes(torrentStatusMap.downloading), status.includes(torrentStatusMap.seeding)], - }; - - Object.keys(statusConditions).some((conditionName) => { - const conditions = statusConditions[conditionName]; - - conditions.some((condition) => { - if (condition) { - statusString = conditionName; - } - - return condition; - }); - - return statusString != null; - }); - - return STATUS_ICON_MAP[statusString]; -} diff --git a/client/src/javascript/util/torrentStatusIcons.tsx b/client/src/javascript/util/torrentStatusIcons.tsx new file mode 100644 index 00000000..c9a51d76 --- /dev/null +++ b/client/src/javascript/util/torrentStatusIcons.tsx @@ -0,0 +1,30 @@ +import React from 'react'; + +import type {TorrentStatus} from '@shared/constants/torrentStatusMap'; + +import ErrorIcon from '../components/icons/ErrorIcon'; +import SpinnerIcon from '../components/icons/SpinnerIcon'; +import StartIcon from '../components/icons/StartIcon'; +import StopIcon from '../components/icons/StopIcon'; + +const STATUS_ICON_MAP: Partial> = { + error: , + checking: , + stopped: , + downloading: , + seeding: , +} as const; + +function torrentStatusIcons(statuses: Array) { + let resultIcon: React.ReactNode = null; + Object.entries(STATUS_ICON_MAP).some(([status, icon]) => { + if (statuses.includes(status as TorrentStatus)) { + resultIcon = icon; + return true; + } + return false; + }); + return resultIcon; +} + +export default torrentStatusIcons; diff --git a/client/src/javascript/util/validators.tsx b/client/src/javascript/util/validators.ts similarity index 100% rename from client/src/javascript/util/validators.tsx rename to client/src/javascript/util/validators.ts diff --git a/config.d.ts b/config.d.ts index 57545602..933688b5 100644 --- a/config.d.ts +++ b/config.d.ts @@ -19,6 +19,9 @@ declare const CONFIG: { ssl: boolean; sslKey: string; sslCert: string; + diskUsageService: { + watchMountPoints: Array; + }; allowedPaths: Array | null; }; diff --git a/package-lock.json b/package-lock.json index ff811ad4..ec633626 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1228,6 +1228,14 @@ "lodash": "^4.17.15", "loud-rejection": "^2.2.0", "typescript": "^4.0" + }, + "dependencies": { + "typescript": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz", + "integrity": "sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==", + "dev": true + } } }, "@formatjs/ecma402-abstract": { @@ -1286,6 +1294,14 @@ "requires": { "intl-messageformat-parser": "^6.0.7", "typescript": "^4.0" + }, + "dependencies": { + "typescript": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz", + "integrity": "sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==", + "dev": true + } } }, "@hapi/address": { @@ -1422,6 +1438,12 @@ "integrity": "sha512-ZPpKOoLuyXf+dKUJKcbUAzAajnJ1+ABXqSyHtsjfDaKhdHh19wXNWC3/hpDdzIO8ykiSONnGCWy38juDZVocvg==", "dev": true }, + "@types/async": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/async/-/async-3.2.3.tgz", + "integrity": "sha512-deXFjLZc1h6SOh3hicVgD+S2EAkhSBGX/vdlD4nTzCjjOFQ+bfNiXocQ21xJjFAUwqaCeyvOQMgrnbg4QEV63A==", + "dev": true + }, "@types/bencode": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/bencode/-/bencode-2.0.0.tgz", @@ -1805,6 +1827,12 @@ "integrity": "sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==", "dev": true }, + "@types/deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha512-mMUu4nWHLBlHtxXY17Fg6+ucS/MnndyOWyOe7MmwkoMYxvfQU2ajtRaEvqSUv+aVkMqH/C0NCI8UoVfRNQ10yg==", + "dev": true + }, "@types/express": { "version": "4.17.8", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.8.tgz", @@ -1844,6 +1872,15 @@ "@types/react": "*" } }, + "@types/fs-extra": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.1.tgz", + "integrity": "sha512-B42Sxuaz09MhC3DDeW5kubRcQ5by4iuVQ0cRRWM2lggLzAa/KVom0Aft/208NgMvNQQZ86s5rVcqDdn/SH0/mg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/geojson": { "version": "7946.0.7", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.7.tgz", @@ -1906,6 +1943,15 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/jsonwebtoken": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.0.tgz", + "integrity": "sha512-9bVao7LvyorRGZCw0VmH/dr7Og+NdjYSsKAxB43OQoComFbBgsEpoR9JW6+qSq/ogwVBg8GI2MfAlk4SYI4OLg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/lodash": { "version": "4.14.161", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.161.tgz", @@ -1942,6 +1988,15 @@ "@types/node": "*" } }, + "@types/multer": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.4.tgz", + "integrity": "sha512-wdfkiKBBEMTODNbuF3J+qDDSqJxt50yB9pgDiTcFew7f97Gcc7/sM4HR66ofGgpJPOALWOqKAch4gPyqEXSkeQ==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/nedb": { "version": "1.8.11", "resolved": "https://registry.npmjs.org/@types/nedb/-/nedb-1.8.11.tgz", @@ -1972,6 +2027,27 @@ "@types/express": "*" } }, + "@types/passport-jwt": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.3.tgz", + "integrity": "sha512-RlOCXiTitE8kazj9jZc6/BfGCSqnv2w/eYPDm3+3iNsquHn7ratu7oIUskZx9ZtnwMdpvdpy+Z/QYClocH5NvQ==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "@types/passport-strategy": { + "version": "0.2.35", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", + "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", + "dev": true, + "requires": { + "@types/express": "*", + "@types/passport": "*" + } + }, "@types/prop-types": { "version": "15.7.3", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", @@ -2097,6 +2173,15 @@ "integrity": "sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA==", "dev": true }, + "@types/tar-stream": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/tar-stream/-/tar-stream-2.1.0.tgz", + "integrity": "sha512-s1UQxQUVMHbSkCC0X4qdoiWgHF8DoyY1JjQouFsnk/8ysoTdBaiCHud/exoAZzKDbzAXVc+ah6sczxGVMAohFw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/uglify-js": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.9.3.tgz", @@ -3024,12 +3109,10 @@ "dev": true }, "async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.2.tgz", - "integrity": "sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==", - "requires": { - "lodash": "^4.17.11" - } + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.0.tgz", + "integrity": "sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==", + "dev": true }, "async-each": { "version": "1.0.3", @@ -3074,17 +3157,43 @@ "dev": true }, "autoprefixer": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.0.0.tgz", - "integrity": "sha512-rFlVYthz6Iw0LhEYryiGGyjTGofebWie3ydvtqTCJiwWe+z6y8H35b4cadYbOUcYlP495TNeVktW+ZZqxbPW4Q==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.0.1.tgz", + "integrity": "sha512-aQo2BDIsoOdemXUAOBpFv4ZQa2DrOtEufarYhtFsK1088Ca0TUwu/aQWf0M3mrILXZ3mTIVn1lR3hPW8acacsw==", "dev": true, "requires": { - "browserslist": "^4.14.2", - "caniuse-lite": "^1.0.30001131", + "browserslist": "^4.14.5", + "caniuse-lite": "^1.0.30001137", "colorette": "^1.2.1", "normalize-range": "^0.1.2", "num2fraction": "^1.2.2", "postcss-value-parser": "^4.1.0" + }, + "dependencies": { + "browserslist": { + "version": "4.14.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.14.5.tgz", + "integrity": "sha512-Z+vsCZIvCBvqLoYkBFTwEYH3v5MCQbsAjp50ERycpOjnPmolg1Gjy4+KaWWpm8QOJt9GHkhdqAl14NpCX73CWA==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001135", + "electron-to-chromium": "^1.3.571", + "escalade": "^3.1.0", + "node-releases": "^1.1.61" + } + }, + "caniuse-lite": { + "version": "1.0.30001137", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001137.tgz", + "integrity": "sha512-54xKQZTqZrKVHmVz0+UvdZR6kQc7pJDgfhsMYDG19ID1BWoNnDMFm5Q3uSBSU401pBvKYMsHAt9qhEDcxmk8aw==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.3.573", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.573.tgz", + "integrity": "sha512-oypaNmexr8w0m2GX67fGLQ0Xgsd7uXz7GcwaHZ9eW3ZdQ8uA2+V/wXmLdMTk3gcacbqQGAN7CXWG3fOkfKYftw==", + "dev": true + } } }, "available-typed-arrays": { @@ -6577,9 +6686,9 @@ "dev": true }, "eslint": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.9.0.tgz", - "integrity": "sha512-V6QyhX21+uXp4T+3nrNfI3hQNBDa/P8ga7LoQOenwrlEFXrEnUEE+ok1dMtaS3b6rmLXhT1TkTIsG75HMLbknA==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.10.0.tgz", + "integrity": "sha512-BDVffmqWl7JJXqCjAK6lWtcQThZB/aP1HXSH1JKwGwv0LQEdvpR7qzNrUT487RM39B5goWuboFad5ovMBmD8yA==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -6590,7 +6699,7 @@ "debug": "^4.0.1", "doctrine": "^3.0.0", "enquirer": "^2.3.5", - "eslint-scope": "^5.1.0", + "eslint-scope": "^5.1.1", "eslint-utils": "^2.1.0", "eslint-visitor-keys": "^1.3.0", "espree": "^7.3.0", @@ -8219,6 +8328,14 @@ "yauzl": "^2.10.0" }, "dependencies": { + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, "iconv-lite": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz", @@ -11116,41 +11233,6 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, - "mv": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", - "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", - "dev": true, - "requires": { - "mkdirp": "~0.5.1", - "ncp": "~2.0.0", - "rimraf": "~2.4.0" - }, - "dependencies": { - "glob": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", - "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", - "dev": true, - "requires": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - } - }, - "rimraf": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", - "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", - "dev": true, - "requires": { - "glob": "^6.0.1" - } - } - } - }, "nan": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz", @@ -11188,12 +11270,6 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, - "ncp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", - "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", - "dev": true - }, "nedb": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/nedb/-/nedb-1.8.0.tgz", @@ -12295,6 +12371,15 @@ "mkdirp": "^0.5.5" }, "dependencies": { + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "dev": true, + "requires": { + "lodash": "^4.17.14" + } + }, "debug": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", @@ -12313,9 +12398,9 @@ "dev": true }, "postcss": { - "version": "8.0.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.0.9.tgz", - "integrity": "sha512-9Ikq03Hvb/L6dgnOtNOUbcgg9Rsff5uKrI1TyNTQ2ALpa6psZk1Ar3/Hhxv2Q0rECRGDxtcMUTZIQglXozlrDQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.1.0.tgz", + "integrity": "sha512-d3RppIo1DI66oHxA1vdckr5qciQbMIrHvyzuvp2cLJHOLwJHg7X9ncrfw2Ri6Sgiwv/GoXtOwEHJ9E9VSRxXWQ==", "dev": true, "requires": { "colorette": "^1.2.1", @@ -15909,12 +15994,6 @@ "aproba": "^1.1.1" } }, - "run-series": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/run-series/-/run-series-1.1.8.tgz", - "integrity": "sha512-+GztYEPRpIsQoCSraWHDBs9WVy4eVME16zhOtDB4H9J4xN0XRhknnmLOl+4gRgZtu8dpp9N/utSPjKH/xmDzXg==", - "dev": true - }, "rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", diff --git a/package.json b/package.json index afd85bf6..4d2c2d48 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@babel/preset-typescript": "^7.10.4", "@formatjs/cli": "^2.12.0", "@types/argon2-browser": "^1.12.0", + "@types/async": "^3.2.3", "@types/bencode": "^2.0.0", "@types/body-parser": "^1.19.0", "@types/classnames": "^2.2.10", @@ -52,23 +53,30 @@ "@types/cookie-parser": "^1.4.2", "@types/d3": "^5.7.2", "@types/debug": "^4.1.5", + "@types/deep-equal": "^1.0.1", "@types/express": "^4.17.8", "@types/flux": "^3.1.9", + "@types/fs-extra": "^9.0.1", "@types/http-errors": "^1.8.0", + "@types/jsonwebtoken": "^8.5.0", "@types/morgan": "^1.9.1", + "@types/multer": "^1.4.4", "@types/nedb": "^1.8.11", "@types/node": "^14.11.2", "@types/passport": "^1.0.4", + "@types/passport-jwt": "^3.0.3", "@types/react": "^16.9.49", "@types/react-dom": "^16.9.8", "@types/react-measure": "^2.0.6", "@types/react-router-dom": "^5.1.5", "@types/react-transition-group": "^4.4.0", "@types/spdy": "^3.4.4", + "@types/tar-stream": "^2.1.0", "@typescript-eslint/eslint-plugin": "^4.2.0", "@typescript-eslint/parser": "^4.2.0", "@vercel/ncc": "^0.24.1", - "autoprefixer": "^10.0.0", + "async": "^3.2.0", + "autoprefixer": "^10.0.1", "axios": "^0.20.0", "babel-eslint": "^10.1.0", "babel-loader": "^8.0.6", @@ -85,7 +93,7 @@ "dayjs": "^1.8.36", "debug": "^4.2.0", "deep-equal": "^2.0.3", - "eslint": "^7.9.0", + "eslint": "^7.10.0", "eslint-config-airbnb": "^18.2.0", "eslint-config-airbnb-typescript": "^10.0.0", "eslint-config-prettier": "^6.12.0", @@ -115,7 +123,6 @@ "mini-css-extract-plugin": "^0.11.2", "morgan": "^1.10.0", "multer": "^1.4.2", - "mv": "^2.1.1", "nedb": "^1.8.0", "node-sass": "^4.13.0", "nodemon": "^2.0.4", @@ -124,7 +131,7 @@ "pascal-case": "^3.1.1", "passport": "^0.4.1", "passport-jwt": "^4.0.0", - "postcss": "^8.0.9", + "postcss": "^8.1.0", "postcss-loader": "^4.0.2", "prettier": "^2.1.2", "promise": "^8.1.0", @@ -144,7 +151,6 @@ "react-router-dom": "^5.2.0", "react-transition-group": "^4.4.1", "ress": "^3.0.0", - "run-series": "^1.1.8", "sanitize-filename": "^1.6.3", "sass-loader": "^10.0.2", "saxen": "^8.1.2", @@ -156,7 +162,7 @@ "ts-node-dev": "^1.0.0-pre.63", "typed-css-modules": "^0.6.4", "typed-emitter": "^1.3.1", - "typescript": "^4.0.2", + "typescript": "^4.0.3", "url-loader": "^4.1.0", "webpack": "^4.44.1", "webpack-dev-server": "^3.11.0", diff --git a/server/app.ts b/server/app.ts index 64066f32..00d41d08 100644 --- a/server/app.ts +++ b/server/app.ts @@ -7,12 +7,22 @@ import morgan from 'morgan'; import passport from 'passport'; import path from 'path'; +import type {UserInDatabase} from '@shared/types/Auth'; + import apiRoutes from './routes/api'; import authRoutes from './routes/auth'; import passportConfig from './config/passport'; import paths from '../shared/config/paths'; import Users from './models/Users'; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface User extends UserInDatabase {} + } +} + const app = express(); Users.bootstrapServicesForAllUsers(); diff --git a/server/bin/enforce-prerequisites.ts b/server/bin/enforce-prerequisites.ts index 43f5f2be..702f445d 100644 --- a/server/bin/enforce-prerequisites.ts +++ b/server/bin/enforce-prerequisites.ts @@ -34,7 +34,7 @@ const grepRecursive = (folder: string, match: string) => { }; const enforcePrerequisites = () => - new Promise((resolve, reject: (error: Error) => void) => { + new Promise((resolve, reject: (error: Error) => void) => { if (!doFilesExist(staticAssets)) { reject( new Error( diff --git a/server/bin/migrations/fix-is-admin-flag.js b/server/bin/migrations/fix-is-admin-flag.js deleted file mode 100644 index 7492280c..00000000 --- a/server/bin/migrations/fix-is-admin-flag.js +++ /dev/null @@ -1,49 +0,0 @@ -import chalk from 'chalk'; -import Users from '../../models/Users'; - -const log = (data) => { - if (process.env.DEBUG) { - console.log(data); - } -}; - -const migrate = () => { - log(chalk.green('Migrating data: resolving unset isAdmin flag')); - - return new Promise((migrateResolve, migrateReject) => { - Users.listUsers((users, migrateError) => { - if (migrateError) return migrateReject(migrateError); - - migrateResolve( - Promise.all( - users.map((user) => { - let userPatch = null; - - if (user.isAdmin == null) { - userPatch = {isAdmin: true}; - } - - if (userPatch != null) { - log(chalk.yellow(`Migrating user ${user.username}`)); - - return new Promise((updateUserResolve, updateUserReject) => { - Users.updateUser(user.username, userPatch, (response, updateUserError) => { - if (updateUserError) { - updateUserReject(updateUserError); - return; - } - - updateUserResolve(response); - }); - }); - } - - return Promise.resolve(); - }), - ), - ); - }); - }); -}; - -export default migrate; diff --git a/server/bin/migrations/per-user-rtorrent-instances.js b/server/bin/migrations/per-user-rtorrent-instances.js deleted file mode 100644 index 1c6b6c84..00000000 --- a/server/bin/migrations/per-user-rtorrent-instances.js +++ /dev/null @@ -1,76 +0,0 @@ -import chalk from 'chalk'; -import config from '../../../config'; -import Users from '../../models/Users'; - -const log = (data) => { - if (process.env.DEBUG) { - console.log(data); - } -}; - -const migrate = () => { - log(chalk.green('Migrating data: moving rTorrent connection information to users database')); - - return new Promise((migrateResolve, migrateReject) => { - Users.listUsers((users, listUsersError) => { - if (listUsersError) return migrateReject(listUsersError); - const {scgi = {}} = config; - const existingConfig = { - host: scgi.host, - port: scgi.port, - socketPath: scgi.socketPath, - }; - - migrateResolve( - Promise.all( - users.map((user) => { - let userPatch = null; - - // A bug in this script caused all of these xmlrpc values to be defined in the user db. - // If they are all defined, set all to null to prompt the user to provide new connection - // details. - if (user.host != null && user.port != null && user.socketPath != null) { - userPatch = { - host: null, - port: null, - socketPath: null, - }; - } - - // If none of the xmlrpc fields are on the user object, try to infer this from the legacy - // configuration file. - if (user.host == null && user.port == null && user.socketPath == null) { - userPatch = {isAdmin: true}; - - if (existingConfig.socketPath && existingConfig.socketPath.trim().length > 0) { - userPatch.socketPath = existingConfig.socketPath; - } else { - userPatch.host = existingConfig.host; - userPatch.port = existingConfig.port; - } - } - - if (userPatch != null) { - log(chalk.yellow(`Migrating user ${user.username}`)); - - return new Promise((updateUserResolve, updateUserReject) => { - Users.updateUser(user.username, userPatch, (response, updateUserError) => { - if (updateUserError) { - updateUserReject(updateUserError); - return; - } - - updateUserResolve(response); - }); - }); - } - - return Promise.resolve(); - }), - ), - ); - }); - }); -}; - -export default migrate; diff --git a/server/bin/migrations/run.ts b/server/bin/migrations/run.ts index 086b7406..2f3c8e46 100644 --- a/server/bin/migrations/run.ts +++ b/server/bin/migrations/run.ts @@ -1,7 +1,8 @@ -import perUserRtorrentInstances from './per-user-rtorrent-instances'; -import fixIsAdminFlag from './fix-is-admin-flag'; - -const migrations = [perUserRtorrentInstances, fixIsAdminFlag]; +const migrations = [ + () => { + // do nothing. there is no migration at the moment. + }, +]; const migrate = () => Promise.all(migrations.map((migration) => migration())); diff --git a/server/config/passport.js b/server/config/passport.ts similarity index 69% rename from server/config/passport.js rename to server/config/passport.ts index f2ab617c..88e84448 100644 --- a/server/config/passport.js +++ b/server/config/passport.ts @@ -1,12 +1,15 @@ -import {Strategy as JwtStrategy} from 'passport-jwt'; +import {Strategy} from 'passport-jwt'; + +import type {PassportStatic} from 'passport'; +import type {Request} from 'express'; import config from '../../config'; import Users from '../models/Users'; // Setup work and export for the JWT passport strategy. -export default (passport) => { +export default (passport: PassportStatic) => { const options = { - jwtFromRequest: (req) => { + jwtFromRequest: (req: Request) => { let token = null; if (req && req.cookies) { @@ -19,7 +22,7 @@ export default (passport) => { }; passport.use( - new JwtStrategy(options, (jwtPayload, callback) => { + new Strategy(options, (jwtPayload, callback) => { Users.lookupUser({username: jwtPayload.username}, (err, user) => { if (err) { return callback(err, false); diff --git a/server/constants/clientGatewayServiceEvents.js b/server/constants/clientGatewayServiceEvents.js deleted file mode 100644 index aaf672db..00000000 --- a/server/constants/clientGatewayServiceEvents.js +++ /dev/null @@ -1,12 +0,0 @@ -import objectUtil from '../../shared/util/objectUtil'; - -const clientGatewayServiceEvents = [ - 'CLIENT_CONNECTION_STATE_CHANGE', - 'PROCESS_TORRENT', - 'PROCESS_TORRENT_LIST_END', - 'PROCESS_TORRENT_LIST_START', - 'PROCESS_TRANSFER_RATE_START', - 'TORRENTS_REMOVED', -]; - -export default objectUtil.createSymbolMapFromArray(clientGatewayServiceEvents); diff --git a/server/constants/diskUsageServiceEvents.js b/server/constants/diskUsageServiceEvents.js deleted file mode 100644 index f5830ce1..00000000 --- a/server/constants/diskUsageServiceEvents.js +++ /dev/null @@ -1,5 +0,0 @@ -import objectUtil from '../../shared/util/objectUtil'; - -const diskUsageServiceEvents = ['DISK_USAGE_CHANGE']; - -export default objectUtil.createSymbolMapFromArray(diskUsageServiceEvents); diff --git a/server/constants/fileListPropMap.js b/server/constants/fileListPropMap.ts similarity index 92% rename from server/constants/fileListPropMap.js rename to server/constants/fileListPropMap.ts index ebb0cc62..cfc6cc92 100644 --- a/server/constants/fileListPropMap.js +++ b/server/constants/fileListPropMap.ts @@ -1,5 +1,5 @@ const fileListPropMap = new Map(); -const defaultTransformer = (value) => value; +const defaultTransformer = (value: unknown) => value; fileListPropMap.set('path', { methodCall: 'f.path=', diff --git a/server/constants/historyServiceEvents.js b/server/constants/historyServiceEvents.js deleted file mode 100644 index 49ff3065..00000000 --- a/server/constants/historyServiceEvents.js +++ /dev/null @@ -1,17 +0,0 @@ -import historySnapshotTypes from '../../shared/constants/historySnapshotTypes'; -import objectUtil from '../../shared/util/objectUtil'; - -const torrentServiceEvents = [ - 'FETCH_TRANSFER_SUMMARY_ERROR', - 'FETCH_TRANSFER_SUMMARY_SUCCESS', - 'TRANSFER_SUMMARY_DIFF_CHANGE', -].concat( - // Create an array of event types based on the available snapshots. - Object.keys(historySnapshotTypes).reduce((accumulator, snapshotType) => { - accumulator.push(`${snapshotType}_SNAPSHOT_FULL_UPDATE`, `${snapshotType}_SNAPSHOT_DIFF_CHANGE`); - - return accumulator; - }, []), -); - -export default objectUtil.createSymbolMapFromArray(torrentServiceEvents); diff --git a/server/constants/notificationServiceEvents.js b/server/constants/notificationServiceEvents.js deleted file mode 100644 index 32b58841..00000000 --- a/server/constants/notificationServiceEvents.js +++ /dev/null @@ -1,5 +0,0 @@ -import objectUtil from '../../shared/util/objectUtil'; - -const notificationServiceEvents = ['NOTIFICATION_COUNT_CHANGE']; - -export default objectUtil.createSymbolMapFromArray(notificationServiceEvents); diff --git a/server/constants/taxonomyServiceEvents.js b/server/constants/taxonomyServiceEvents.js deleted file mode 100644 index 0e1bb3be..00000000 --- a/server/constants/taxonomyServiceEvents.js +++ /dev/null @@ -1,5 +0,0 @@ -import objectUtil from '../../shared/util/objectUtil'; - -const taxonomyServiceEvents = ['TAXONOMY_DIFF_CHANGE']; - -export default objectUtil.createSymbolMapFromArray(taxonomyServiceEvents); diff --git a/server/constants/torrentListPropMap.js b/server/constants/torrentListPropMap.ts similarity index 87% rename from server/constants/torrentListPropMap.js rename to server/constants/torrentListPropMap.ts index 8078f61a..cf60faf9 100644 --- a/server/constants/torrentListPropMap.js +++ b/server/constants/torrentListPropMap.ts @@ -2,8 +2,8 @@ import regEx from '../../shared/util/regEx'; const torrentListPropMap = new Map(); -const booleanTransformer = (value) => value === '1'; -const dateTransformer = (dirtyDate) => { +const booleanTransformer = (value: string) => value === '1'; +const dateTransformer = (dirtyDate: string) => { if (!dirtyDate) { return ''; } @@ -16,7 +16,7 @@ const dateTransformer = (dirtyDate) => { return date; }; -const defaultTransformer = (value) => value; +const defaultTransformer = (value: unknown) => value; torrentListPropMap.set('hash', { methodCall: 'd.hash=', @@ -155,7 +155,7 @@ torrentListPropMap.set('isPrivate', { torrentListPropMap.set('tags', { methodCall: 'd.custom1=', - transformValue: (value) => { + transformValue: (value: string) => { if (value === '') { return []; } @@ -169,7 +169,7 @@ torrentListPropMap.set('tags', { torrentListPropMap.set('comment', { methodCall: 'd.custom2=', - transformValue: (value) => { + transformValue: (value: string) => { let comment = decodeURIComponent(value); if (comment.match(/^VRS24mrker/)) { @@ -187,9 +187,9 @@ torrentListPropMap.set('ignoreScheduler', { torrentListPropMap.set('trackerURIs', { methodCall: 'cat="$t.multicall=d.hash=,t.is_enabled=,t.url=,cat={|||}"', - transformValue: (value) => { + transformValue: (value: string) => { const trackers = value.split('|||'); - const trackerDomains = []; + const trackerDomains: Array = []; trackers.forEach((tracker) => { // Only count enabled trackers @@ -197,10 +197,10 @@ torrentListPropMap.set('trackerURIs', { return; } - let domain = regEx.domainName.exec(tracker.substr(1)); + const regexMatched = regEx.domainName.exec(tracker.substr(1)); - if (domain && domain[1]) { - domain = domain[1]; + if (regexMatched != null && regexMatched[1]) { + let domain = regexMatched[1]; const minSubsetLength = 3; const domainSubsets = domain.split('.'); @@ -209,7 +209,7 @@ torrentListPropMap.set('trackerURIs', { if (domainSubsets.length > desiredSubsets) { const lastDesiredSubset = domainSubsets[domainSubsets.length - desiredSubsets]; if (lastDesiredSubset.length <= minSubsetLength) { - desiredSubsets++; + desiredSubsets += 1; } } @@ -231,7 +231,7 @@ torrentListPropMap.set('seedsConnected', { torrentListPropMap.set('seedsTotal', { methodCall: 'cat="$t.multicall=d.hash=,t.scrape_complete=,cat={|||}"', - transformValue: (value) => Number(value.substr(0, value.indexOf('|||'))), + transformValue: (value: string) => Number(value.substr(0, value.indexOf('|||'))), }); torrentListPropMap.set('peersConnected', { @@ -241,7 +241,7 @@ torrentListPropMap.set('peersConnected', { torrentListPropMap.set('peersTotal', { methodCall: 'cat="$t.multicall=d.hash=,t.scrape_incomplete=,cat={|||}"', - transformValue: (value) => Number(value.substr(0, value.indexOf('|||'))), + transformValue: (value: string) => Number(value.substr(0, value.indexOf('|||'))), }); export default torrentListPropMap; diff --git a/server/constants/torrentServiceEvents.js b/server/constants/torrentServiceEvents.js deleted file mode 100644 index a6525681..00000000 --- a/server/constants/torrentServiceEvents.js +++ /dev/null @@ -1,5 +0,0 @@ -import objectUtil from '../../shared/util/objectUtil'; - -const torrentServiceEvents = ['FETCH_TORRENT_LIST_ERROR', 'FETCH_TORRENT_LIST_SUCCESS', 'TORRENT_LIST_DIFF_CHANGE']; - -export default objectUtil.createSymbolMapFromArray(torrentServiceEvents); diff --git a/server/constants/transferSummaryPropMap.js b/server/constants/transferSummaryPropMap.ts similarity index 100% rename from server/constants/transferSummaryPropMap.js rename to server/constants/transferSummaryPropMap.ts diff --git a/server/middleware/appendUserServices.js b/server/middleware/appendUserServices.js deleted file mode 100644 index f82ff89c..00000000 --- a/server/middleware/appendUserServices.js +++ /dev/null @@ -1,6 +0,0 @@ -import services from '../services'; - -export default (req, res, next) => { - req.services = services.getAllServices(req.user); - next(); -}; diff --git a/server/middleware/appendUserServices.ts b/server/middleware/appendUserServices.ts new file mode 100644 index 00000000..6ec6ac8a --- /dev/null +++ b/server/middleware/appendUserServices.ts @@ -0,0 +1,19 @@ +import type {Request, Response, NextFunction} from 'express'; + +import services from '../services'; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + services?: ReturnType; + } + } +} + +export default (req: Request, _res: Response, next: NextFunction) => { + if (req.user != null) { + req.services = services.getAllServices(req.user); + } + next(); +}; diff --git a/server/middleware/booleanCoerce.js b/server/middleware/booleanCoerce.js deleted file mode 100644 index c8edf7c8..00000000 --- a/server/middleware/booleanCoerce.js +++ /dev/null @@ -1,9 +0,0 @@ -export default (key) => (req, res, next) => { - const value = req.body && req.body[key]; - - if (value && typeof value === 'string') { - req.body[key] = value === 'true'; - } - - next(); -}; diff --git a/server/middleware/booleanCoerce.ts b/server/middleware/booleanCoerce.ts new file mode 100644 index 00000000..3bef0a6c --- /dev/null +++ b/server/middleware/booleanCoerce.ts @@ -0,0 +1,11 @@ +import type {Request, Response, NextFunction} from 'express'; + +export default (key: string) => (req: Request, _res: Response, next: NextFunction) => { + const value = req.body && req.body[key]; + + if (value && typeof value === 'string') { + req.body[key] = value === 'true'; + } + + next(); +}; diff --git a/server/middleware/clientActivityStream.js b/server/middleware/clientActivityStream.js deleted file mode 100644 index 1d5c7f6c..00000000 --- a/server/middleware/clientActivityStream.js +++ /dev/null @@ -1,150 +0,0 @@ -import clientGatewayServiceEvents from '../constants/clientGatewayServiceEvents'; -import historyServiceEvents from '../constants/historyServiceEvents'; -import historySnapshotTypes from '../../shared/constants/historySnapshotTypes'; -import notificationServiceEvents from '../constants/notificationServiceEvents'; -import ServerEvent from '../models/ServerEvent'; -import serverEventTypes from '../../shared/constants/serverEventTypes'; -import services from '../services'; -import taxonomyServiceEvents from '../constants/taxonomyServiceEvents'; -import torrentServiceEvents from '../constants/torrentServiceEvents'; -import diskUsageServiceEvents from '../constants/diskUsageServiceEvents'; -import DiskUsageService from '../services/diskUsageService'; - -export default (req, res) => { - const { - query: {historySnapshot = historySnapshotTypes.FIVE_MINUTE}, - user, - } = req; - - const serviceInstances = services.getAllServices(user); - const serverEvent = new ServerEvent(res); - const taxonomy = serviceInstances.taxonomyService.getTaxonomy(); - const torrentList = serviceInstances.torrentService.getTorrentList(); - const transferSummary = serviceInstances.historyService.getTransferSummary(); - - // Hook into events and stop listening when connection is closed - const handleEvents = (emitter, event, handler) => { - emitter.on(event, handler); - res.on('close', () => { - emitter.removeListener(event, handler); - }); - }; - - // Emit current state immediately on connection. - serverEvent.setID(Date.now()); - serverEvent.setType(serverEventTypes.CLIENT_CONNECTIVITY_STATUS_CHANGE); - serverEvent.addData({isConnected: !serviceInstances.clientGatewayService.hasError}); - serverEvent.emit(); - - const handleDiskUsageChange = (diskUsageChange) => { - serverEvent.setID(diskUsageChange.id); - serverEvent.setType(serverEventTypes.DISK_USAGE_CHANGE); - serverEvent.addData(diskUsageChange.disks); - serverEvent.emit(); - }; - - DiskUsageService.updateDisks().then(() => { - const diskUsage = DiskUsageService.getDiskUsage(); - serverEvent.setID(diskUsage.id); - serverEvent.setType(serverEventTypes.DISK_USAGE_CHANGE); - serverEvent.addData(diskUsage.disks); - serverEvent.emit(); - handleEvents(DiskUsageService, diskUsageServiceEvents.DISK_USAGE_CHANGE, handleDiskUsageChange); - }); - - serverEvent.setID(torrentList.id); - serverEvent.setType(serverEventTypes.TORRENT_LIST_FULL_UPDATE); - serverEvent.addData(torrentList.torrents); - serverEvent.emit(); - - serverEvent.setID(taxonomy.id); - serverEvent.setType(serverEventTypes.TAXONOMY_FULL_UPDATE); - serverEvent.addData(taxonomy.taxonomy); - serverEvent.emit(); - - serverEvent.setID(transferSummary.id); - serverEvent.setType(serverEventTypes.TRANSFER_SUMMARY_FULL_UPDATE); - serverEvent.addData(transferSummary.transferSummary); - serverEvent.emit(); - - serverEvent.setID(Date.now()); - serverEvent.setType(serverEventTypes.NOTIFICATION_COUNT_CHANGE); - serverEvent.addData(serviceInstances.notificationService.getNotificationCount()); - serverEvent.emit(); - - handleEvents(serviceInstances.clientGatewayService, clientGatewayServiceEvents.CLIENT_CONNECTION_STATE_CHANGE, () => { - serverEvent.setID(Date.now()); - serverEvent.setType(serverEventTypes.CLIENT_CONNECTIVITY_STATUS_CHANGE); - serverEvent.addData({isConnected: !serviceInstances.clientGatewayService.hasError}); - serverEvent.emit(); - }); - - if (serviceInstances.clientGatewayService.hasError) { - serviceInstances.clientGatewayService.testGateway().catch(console.error); - } - - // TODO: Handle empty or sub-optimal history states. - // Get user's specified history snapshot current history. - serviceInstances.historyService.getHistory({snapshot: historySnapshot}, (snapshot, error) => { - const {timestamps: lastTimestamps = []} = snapshot; - const lastTimestamp = lastTimestamps[lastTimestamps.length - 1]; - - if (error == null) { - serverEvent.setID(lastTimestamp); - serverEvent.setType(serverEventTypes.TRANSFER_HISTORY_FULL_UPDATE); - serverEvent.addData(snapshot); - serverEvent.emit(); - } - }); - - // Add user's specified history snapshot change event listener. - handleEvents( - serviceInstances.historyService, - historyServiceEvents[`${historySnapshotTypes[historySnapshot]}_SNAPSHOT_FULL_UPDATE`], - (payload) => { - const {data, id} = payload; - - serverEvent.setID(id); - serverEvent.setType(serverEventTypes.TRANSFER_HISTORY_FULL_UPDATE); - serverEvent.addData(data); - serverEvent.emit(); - }, - ); - - handleEvents(serviceInstances.notificationService, notificationServiceEvents.NOTIFICATION_COUNT_CHANGE, (payload) => { - const {data, id} = payload; - - serverEvent.setID(id); - serverEvent.setType(serverEventTypes.NOTIFICATION_COUNT_CHANGE); - serverEvent.addData(data); - serverEvent.emit(); - }); - - // Add diff event listeners. - handleEvents(serviceInstances.historyService, historyServiceEvents.TRANSFER_SUMMARY_DIFF_CHANGE, (payload) => { - const {diff, id} = payload; - - serverEvent.setID(id); - serverEvent.setType(serverEventTypes.TRANSFER_SUMMARY_DIFF_CHANGE); - serverEvent.addData(diff); - serverEvent.emit(); - }); - - handleEvents(serviceInstances.taxonomyService, taxonomyServiceEvents.TAXONOMY_DIFF_CHANGE, (payload) => { - const {diff, id} = payload; - - serverEvent.setID(id); - serverEvent.setType(serverEventTypes.TAXONOMY_DIFF_CHANGE); - serverEvent.addData(diff); - serverEvent.emit(); - }); - - handleEvents(serviceInstances.torrentService, torrentServiceEvents.TORRENT_LIST_DIFF_CHANGE, (payload) => { - const {diff, id} = payload; - - serverEvent.setID(id); - serverEvent.setType(serverEventTypes.TORRENT_LIST_DIFF_CHANGE); - serverEvent.addData(diff); - serverEvent.emit(); - }); -}; diff --git a/server/middleware/clientActivityStream.ts b/server/middleware/clientActivityStream.ts new file mode 100644 index 00000000..ed7727ab --- /dev/null +++ b/server/middleware/clientActivityStream.ts @@ -0,0 +1,127 @@ +import type {Request, Response} from 'express'; +import type TypedEmitter from 'typed-emitter'; + +import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; +import type {TransferHistory, TransferSummaryDiff} from '@shared/types/TransferData'; + +import ServerEvent from '../models/ServerEvent'; +import services from '../services'; +import DiskUsageService from '../services/diskUsageService'; + +import type {DiskUsage} from '../services/diskUsageService'; + +export default (req: Request, res: Response) => { + const { + query: {historySnapshot = 'FIVE_MINUTE'}, + user, + }: { + query: {historySnapshot: HistorySnapshot}; + user?: Express.User; + } = req; + + if (user == null) { + return; + } + + const serviceInstances = services.getAllServices(user); + const serverEvent = new ServerEvent(res); + const taxonomy = serviceInstances.taxonomyService.getTaxonomy(); + const torrentList = serviceInstances.torrentService.getTorrentList(); + const transferSummary = serviceInstances.historyService.getTransferSummary(); + + // Hook into events and stop listening when connection is closed + const handleEvents = >>( + emitter: T, + event: Parameters[0], + handler: Parameters[1], + ) => { + emitter.on(event, handler); + res.on('close', () => { + emitter.removeListener(event, handler); + }); + }; + + // Emit current state immediately on connection. + serverEvent.emit(Date.now(), 'CLIENT_CONNECTIVITY_STATUS_CHANGE', { + isConnected: !serviceInstances.clientGatewayService.hasError, + }); + + const handleDiskUsageChange = (diskUsageChange: DiskUsage) => { + serverEvent.emit(diskUsageChange.id, 'DISK_USAGE_CHANGE', diskUsageChange.disks); + }; + + DiskUsageService.updateDisks() + .then(() => { + const diskUsage = DiskUsageService.getDiskUsage(); + serverEvent.emit(diskUsage.id, 'DISK_USAGE_CHANGE', diskUsage.disks); + handleEvents(DiskUsageService, 'DISK_USAGE_CHANGE', handleDiskUsageChange); + }) + .catch(() => { + // do nothing. + }); + + serverEvent.emit(torrentList.id, 'TORRENT_LIST_FULL_UPDATE', torrentList.torrents); + serverEvent.emit(taxonomy.id, 'TAXONOMY_FULL_UPDATE', taxonomy.taxonomy); + serverEvent.emit(transferSummary.id, 'TRANSFER_SUMMARY_FULL_UPDATE', transferSummary.transferSummary); + serverEvent.emit( + Date.now(), + 'NOTIFICATION_COUNT_CHANGE', + serviceInstances.notificationService.getNotificationCount(), + ); + + handleEvents(serviceInstances.clientGatewayService, 'CLIENT_CONNECTION_STATE_CHANGE', () => { + serverEvent.emit(Date.now(), 'CLIENT_CONNECTIVITY_STATUS_CHANGE', { + isConnected: !serviceInstances.clientGatewayService.hasError, + }); + }); + + if (serviceInstances.clientGatewayService.hasError) { + serviceInstances.clientGatewayService.testGateway().catch(console.error); + } + + // TODO: Handle empty or sub-optimal history states. + // Get user's specified history snapshot current history. + serviceInstances.historyService.getHistory({snapshot: historySnapshot}, (snapshot, error) => { + const {timestamps: lastTimestamps} = snapshot || {timestamps: []}; + const lastTimestamp = lastTimestamps[lastTimestamps.length - 1]; + + if (error == null && snapshot != null) { + serverEvent.emit(lastTimestamp, 'TRANSFER_HISTORY_FULL_UPDATE', snapshot); + } + }); + + // Add user's specified history snapshot change event listener. + handleEvents( + serviceInstances.historyService, + `${historySnapshot}_SNAPSHOT_FULL_UPDATE` as 'FIVE_MINUTE_SNAPSHOT_FULL_UPDATE', + (payload: {id: number; data: TransferHistory}) => { + const {data, id} = payload; + serverEvent.emit(id, 'TRANSFER_HISTORY_FULL_UPDATE', data); + }, + ); + + handleEvents(serviceInstances.notificationService, 'NOTIFICATION_COUNT_CHANGE', (payload) => { + const {data, id} = payload; + serverEvent.emit(id, 'NOTIFICATION_COUNT_CHANGE', data); + }); + + // Add diff event listeners. + handleEvents( + serviceInstances.historyService, + 'TRANSFER_SUMMARY_DIFF_CHANGE', + (payload: {id: number; diff: TransferSummaryDiff}) => { + const {diff, id} = payload; + serverEvent.emit(id, 'TRANSFER_SUMMARY_DIFF_CHANGE', diff); + }, + ); + + handleEvents(serviceInstances.taxonomyService, 'TAXONOMY_DIFF_CHANGE', (payload) => { + const {diff, id} = payload; + serverEvent.emit(id, 'TAXONOMY_DIFF_CHANGE', diff); + }); + + handleEvents(serviceInstances.torrentService, 'TORRENT_LIST_DIFF_CHANGE', (payload) => { + const {diff, id} = payload; + serverEvent.emit(id, 'TORRENT_LIST_DIFF_CHANGE', diff); + }); +}; diff --git a/server/middleware/eventStream.js b/server/middleware/eventStream.ts similarity index 81% rename from server/middleware/eventStream.js rename to server/middleware/eventStream.ts index f8bc9574..6fabb718 100644 --- a/server/middleware/eventStream.js +++ b/server/middleware/eventStream.ts @@ -1,4 +1,6 @@ -export default (req, res, next) => { +import type {Request, Response, NextFunction} from 'express'; + +export default (req: Request, res: Response, next: NextFunction) => { req.socket.setKeepAlive(true); req.socket.setTimeout(0); diff --git a/server/middleware/requireAdmin.js b/server/middleware/requireAdmin.js deleted file mode 100644 index 1c8f698f..00000000 --- a/server/middleware/requireAdmin.js +++ /dev/null @@ -1,6 +0,0 @@ -export default (req, res, next) => { - if (req.user == null || !req.user.isAdmin) { - return res.status(403).json({message: 'User is not admin.'}).send(); - } - next(); -}; diff --git a/server/middleware/requireAdmin.ts b/server/middleware/requireAdmin.ts new file mode 100644 index 00000000..60246203 --- /dev/null +++ b/server/middleware/requireAdmin.ts @@ -0,0 +1,9 @@ +import type {Request, Response, NextFunction} from 'express'; + +export default (req: Request, res: Response, next: NextFunction) => { + if (req.user == null || !req.user.isAdmin) { + res.status(403).json({message: 'User is not admin.'}).send(); + return; + } + next(); +}; diff --git a/server/models/ClientRequest.js b/server/models/ClientRequest.js index ff09e08e..20d0c84b 100644 --- a/server/models/ClientRequest.js +++ b/server/models/ClientRequest.js @@ -1,13 +1,12 @@ /** * This file is deprecated in favor of clientGatewayService. */ -import mv from 'mv'; +import {move} from 'fs-extra'; import path from 'path'; import util from 'util'; -import {clientSettings, clientSettingsMap} from '../../shared/constants/clientSettingsMap'; +import {clientSettingsMap} from '../../shared/constants/clientSettingsMap'; import rTorrentPropMap from '../util/rTorrentPropMap'; -import torrentStatusMap from '../../shared/constants/torrentStatusMap'; const addTagsToRequest = (tagsArr, requestParameters) => { if (tagsArr && tagsArr.length) { @@ -180,9 +179,7 @@ class ClientRequest { checkHash(options) { const {torrentService} = this.services; const hashes = getEnsuredArray(options.hashes); - const stoppedHashes = hashes.filter((hash) => - torrentService.getTorrent(hash).status.includes(torrentStatusMap.stopped), - ); + const stoppedHashes = hashes.filter((hash) => torrentService.getTorrent(hash).status.includes('stopped')); const hashesToStart = []; @@ -205,7 +202,7 @@ class ClientRequest { let {requestedSettings} = options; if (requestedSettings == null) { - requestedSettings = Object.keys(clientSettings).map((settingsKey) => clientSettingsMap[settingsKey]); + requestedSettings = Object.values(clientSettingsMap); } // Ensure client's response gets mapped to the correct requested keys. @@ -260,7 +257,13 @@ class ClientRequest { } if (source !== destination) { - mv(source, destination, {mkdirp: true}, callback); + move(source, destination, {overwrite: true}, (err) => { + if (err) { + console.error(`Failed to move files to ${destinationPath}.`); + console.error(err); + } + callback(); + }); } else if (isLastRequest) { callback(); } diff --git a/server/models/Feed.js b/server/models/Feed.js index 7b712ca2..9181bd96 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -1,5 +1,6 @@ import FeedSub from 'feedsub'; +// TODO: Use a type-checked Feed parser class Feed { constructor(options) { this.options = options || {}; diff --git a/server/models/HistoryEra.js b/server/models/HistoryEra.js deleted file mode 100644 index 0bf3baba..00000000 --- a/server/models/HistoryEra.js +++ /dev/null @@ -1,205 +0,0 @@ -import Datastore from 'nedb'; -import path from 'path'; - -import config from '../../config'; - -const MAX_NEXT_ERA_UPDATE_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours -const CUMULATIVE_DATA_BUFFER_DIFF = 500; // 500 miliseconds -const REQUIRED_FIELDS = ['interval', 'maxTime', 'name']; - -const hasRequiredFields = (opts) => { - let requirementsMet = true; - - REQUIRED_FIELDS.forEach((field) => { - if (opts[field] == null) { - console.error(`HistoryEra requires ${field}`); - requirementsMet = false; - } - }); - - return requirementsMet; -}; - -class HistoryEra { - constructor(user, opts) { - opts = opts || {}; - - if (!hasRequiredFields(opts)) { - return; - } - - this.data = []; - this.opts = opts; - this.ready = false; - this.user = user; - this.startedAt = Date.now(); - this.db = this.loadDatabase(this.opts.name); - - this.setLastUpdate(this.db); - this.removeOutdatedData(this.db); - - let cleanupInterval = this.opts.maxTime; - let {nextEraUpdateInterval} = this.opts; - - if (cleanupInterval === 0 || cleanupInterval > config.dbCleanInterval) { - cleanupInterval = config.dbCleanInterval; - } - - if (nextEraUpdateInterval && nextEraUpdateInterval > MAX_NEXT_ERA_UPDATE_INTERVAL) { - nextEraUpdateInterval = MAX_NEXT_ERA_UPDATE_INTERVAL; - } - - if (nextEraUpdateInterval) { - this.startNextEraUpdate(nextEraUpdateInterval, this.db); - } - - this.startAutoCleanup(cleanupInterval, this.db); - } - - loadDatabase(dbName) { - const db = new Datastore({ - autoload: true, - filename: path.join(config.dbPath, this.user._id, 'history', `${dbName}.db`), - }); - - this.ready = true; - return db; - } - - addData(data) { - if (!this.ready) { - console.error('database is not ready'); - return; - } - - const currentTime = Date.now(); - - if (currentTime - this.lastUpdate >= this.opts.interval - CUMULATIVE_DATA_BUFFER_DIFF) { - this.lastUpdate = currentTime; - - this.db.insert({ - ts: currentTime, - up: Number(data.upload), - dn: Number(data.download), - }); - } else { - this.db.find({ts: this.lastUpdate}, (err, docs) => { - if (docs.length !== 0) { - const doc = docs[0]; - const numUpdates = Number(doc.num || 1); - const currentDownAvg = Number(doc.dn); - const currentUpAvg = Number(doc.up); - - const downAvg = ((currentDownAvg * numUpdates + Number(data.download)) / (numUpdates + 1)).toFixed(1); - const upAvg = ((currentUpAvg * numUpdates + Number(data.upload)) / (numUpdates + 1)).toFixed(1); - - // TODO: Remove this nonsense, I think this bug is resolved. - if (downAvg == null || upAvg == null) { - console.error('\n\n'); - console.error('Warning: null values set in database!'); - console.error(`DB: ${this.opts.name}`); - console.error( - `numUpdates: ${numUpdates} -currentDownAvg: ${currentDownAvg} -currentUpAvg: ${currentUpAvg} -downAvg: ${downAvg} -upAvg: ${upAvg}`, - ); - console.error('\n\n'); - } - - this.db.update( - { - ts: this.lastUpdate, - }, - { - ts: this.lastUpdate, - up: Number(upAvg), - dn: Number(downAvg), - num: numUpdates + 1, - }, - ); - } - }); - } - } - - cleanup(db) { - this.removeOutdatedData(db); - db.persistence.compactDatafile(); - } - - getData(opts, callback) { - const minTimestamp = Date.now() - this.opts.maxTime; - - this.db - .find({ts: {$gte: minTimestamp}}) - .sort({ts: 1}) - .exec((err, docs) => { - if (err) { - callback(null, err); - return; - } - - callback(docs); - }); - } - - removeOutdatedData(db) { - if (this.opts.maxTime > 0) { - const minTimestamp = Date.now() - this.opts.maxTime; - db.remove({ts: {$lt: minTimestamp}}, {multi: true}); - } - } - - setLastUpdate(db) { - let lastUpdate = 0; - - db.find({}, (err, docs) => { - docs.forEach((doc) => { - if (doc.ts > lastUpdate) { - lastUpdate = doc.ts; - } - }); - this.lastUpdate = lastUpdate; - }); - } - - startAutoCleanup(interval, db) { - this.autoCleanupInterval = setInterval(this.cleanup.bind(this, db), interval); - } - - startNextEraUpdate(interval, currentDB, nextDB) { - this.nextEraUpdateInterval = setInterval(this.updateNextEra.bind(this, currentDB, nextDB), interval); - } - - stopAutoCleanup() { - clearInterval(this.autoCleanupInterval); - this.autoCleanupInterval = null; - } - - stopNextEraUpdate() { - clearInterval(this.nextEraUpdateInterval); - this.nextEraUpdateInterval = null; - } - - updateNextEra(currentDB) { - const minTimestamp = Date.now() - this.opts.nextEraUpdateInterval; - currentDB.find({ts: {$gte: minTimestamp}}, (err, docs) => { - let downTotal = 0; - let upTotal = 0; - - docs.forEach((doc) => { - downTotal += Number(doc.dn); - upTotal += Number(doc.up); - }); - - this.opts.nextEra.addData({ - download: Number(downTotal / docs.length).toFixed(1), - upload: Number(upTotal / docs.length).toFixed(1), - }); - }); - } -} - -export default HistoryEra; diff --git a/server/models/HistoryEra.ts b/server/models/HistoryEra.ts new file mode 100644 index 00000000..f5b75535 --- /dev/null +++ b/server/models/HistoryEra.ts @@ -0,0 +1,204 @@ +import type {UserInDatabase} from '@shared/types/Auth'; +import type {TransferData, TransferSnapshot} from '@shared/types/TransferData'; + +import Datastore from 'nedb'; +import path from 'path'; + +import config from '../../config'; + +interface HistoryEraOpts { + interval: number; + maxTime: number; + name: string; + nextEraUpdateInterval?: number; + nextEra?: HistoryEra; +} + +const MAX_NEXT_ERA_UPDATE_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours +const CUMULATIVE_DATA_BUFFER_DIFF = 500; // 500 milliseconds + +class HistoryEra { + data = []; + ready = false; + lastUpdate = 0; + startedAt = Date.now(); + opts: HistoryEraOpts; + db: Datastore; + autoCleanupInterval?: NodeJS.Timeout; + nextEraUpdateInterval?: NodeJS.Timeout; + + constructor(user: UserInDatabase, opts: HistoryEraOpts) { + this.opts = opts; + this.db = this.loadDatabase(user._id, opts.name); + + this.setLastUpdate(this.db); + this.removeOutdatedData(this.db); + + let cleanupInterval = this.opts.maxTime; + + if (cleanupInterval === 0 || cleanupInterval > config.dbCleanInterval) { + cleanupInterval = config.dbCleanInterval; + } + + this.startAutoCleanup(cleanupInterval, this.db); + } + + loadDatabase(userId: UserInDatabase['_id'], dbName: string) { + const db = new Datastore({ + autoload: true, + filename: path.join(config.dbPath, userId, 'history', `${dbName}.db`), + }); + + this.ready = true; + return db; + } + + addData(data: TransferData) { + if (!this.ready) { + console.error('database is not ready'); + return; + } + + const currentTime = Date.now(); + + if (currentTime - this.lastUpdate >= this.opts.interval - CUMULATIVE_DATA_BUFFER_DIFF) { + this.lastUpdate = currentTime; + this.db.insert({ + timestamp: currentTime, + ...data, + }); + } else { + this.db.find({timestamp: this.lastUpdate}, (err: Error, snapshots: Array) => { + if (err) { + return; + } + + if (snapshots.length !== 0) { + const snapshot = snapshots[0]; + const numUpdates = snapshot.numUpdates || 1; + + // calculate average and update + const updatedSnapshot: TransferSnapshot = { + timestamp: this.lastUpdate, + upload: Number(((snapshot.upload * numUpdates + data.upload) / (numUpdates + 1)).toFixed(1)), + download: Number(((snapshot.download * numUpdates + data.download) / (numUpdates + 1)).toFixed(1)), + numUpdates: numUpdates + 1, + }; + + this.db.update({timestamp: this.lastUpdate}, updatedSnapshot); + } + }); + } + } + + cleanup(db: this['db']) { + this.removeOutdatedData(db); + db.persistence.compactDatafile(); + } + + getData(callback: (snapshots: Array | null, error?: Error) => void) { + const minTimestamp = Date.now() - this.opts.maxTime; + + this.db + .find({timestamp: {$gte: minTimestamp}}) + .sort({timestamp: 1}) + .exec((err, snapshots: Array) => { + if (err) { + callback(null, err); + return; + } + + callback(snapshots.slice(snapshots.length - config.maxHistoryStates)); + }); + } + + removeOutdatedData(db: this['db']) { + if (this.opts.maxTime > 0) { + const minTimestamp = Date.now() - this.opts.maxTime; + db.remove({timestamp: {$lt: minTimestamp}}, {multi: true}); + } + } + + setLastUpdate(db: this['db']): void { + let lastUpdate = 0; + + db.find({}, (err: Error, snapshots: Array) => { + if (err) { + return; + } + + snapshots.forEach((snapshot) => { + if (snapshot.timestamp > lastUpdate) { + lastUpdate = snapshot.timestamp; + } + }); + + this.lastUpdate = lastUpdate; + }); + } + + setNextEra(nextEra: HistoryEra): void { + this.opts.nextEra = nextEra; + + let {nextEraUpdateInterval} = this.opts; + + if (nextEraUpdateInterval && nextEraUpdateInterval > MAX_NEXT_ERA_UPDATE_INTERVAL) { + nextEraUpdateInterval = MAX_NEXT_ERA_UPDATE_INTERVAL; + } + + if (nextEraUpdateInterval) { + this.startNextEraUpdate(nextEraUpdateInterval, this.db); + } + } + + startAutoCleanup(interval: number, db: this['db']): void { + this.autoCleanupInterval = setInterval(this.cleanup.bind(this, db), interval); + } + + startNextEraUpdate(interval: number, currentDB: this['db']): void { + this.nextEraUpdateInterval = setInterval(this.updateNextEra.bind(this, currentDB), interval); + } + + stopAutoCleanup(): void { + if (this.autoCleanupInterval != null) { + clearInterval(this.autoCleanupInterval); + delete this.autoCleanupInterval; + } + } + + stopNextEraUpdate(): void { + if (this.nextEraUpdateInterval != null) { + clearInterval(this.nextEraUpdateInterval); + delete this.nextEraUpdateInterval; + } + } + + updateNextEra(currentDB: this['db']): void { + if (this.opts.nextEraUpdateInterval == null) { + return; + } + + const minTimestamp = Date.now() - this.opts.nextEraUpdateInterval; + + currentDB.find({timestamp: {$gte: minTimestamp}}, (err: Error, snapshots: Array) => { + if (err || this.opts.nextEra == null) { + return; + } + + let downTotal = 0; + let upTotal = 0; + + snapshots.forEach((snapshot) => { + downTotal += Number(snapshot.download); + upTotal += Number(snapshot.upload); + }); + + this.opts.nextEra.addData({ + download: Number(Number(downTotal / snapshots.length).toFixed(1)), + upload: Number(Number(upTotal / snapshots.length).toFixed(1)), + }); + }); + } +} + +export default HistoryEra; diff --git a/server/models/ServerEvent.js b/server/models/ServerEvent.js deleted file mode 100644 index 9545a1dc..00000000 --- a/server/models/ServerEvent.js +++ /dev/null @@ -1,32 +0,0 @@ -class ServerEvent { - constructor(res) { - this.data = ''; - this.res = res; - - // Add 2kb padding for IE. - const padding = new Array(2049); - res.write(`:${padding.join(' ')}\n`); - } - - addData(data) { - const lines = JSON.stringify(data).split(/\n/); - - this.data = lines.reduce((accumulator, datum) => `${this.data}data:${datum}\n`, this.data); - } - - emit() { - this.res.write(`${this.data}\n`); - this.res.flush(); - this.data = ''; - } - - setID(id) { - this.res.write(`id:${id}\n`); - } - - setType(eventType) { - this.res.write(`event:${eventType}\n`); - } -} - -export default ServerEvent; diff --git a/server/models/ServerEvent.ts b/server/models/ServerEvent.ts new file mode 100644 index 00000000..59e85950 --- /dev/null +++ b/server/models/ServerEvent.ts @@ -0,0 +1,24 @@ +import type {Response} from 'express'; + +import type {ServerEvents} from '@shared/types/ServerEvents'; + +class ServerEvent { + res: Response; + + constructor(res: Response) { + this.res = res; + + // Add 2kb padding for IE. + const padding = new Array(2049); + res.write(`:${padding.join(' ')}\n`); + } + + emit(id: number, eventType: T, data: ServerEvents[T]) { + this.res.write(`id:${id}\n`); + this.res.write(`event:${eventType}\n`); + this.res.write(`data:${JSON.stringify(data)}\n\n`); + this.res.flush(); + } +} + +export default ServerEvent; diff --git a/server/models/Users.ts b/server/models/Users.ts index aadbcce6..c8242144 100644 --- a/server/models/Users.ts +++ b/server/models/Users.ts @@ -4,13 +4,11 @@ import Datastore from 'nedb'; import fs from 'fs'; import path from 'path'; -import type {Credentials} from '@shared/types/Auth'; +import type {Credentials, UserInDatabase} from '@shared/types/Auth'; import config from '../../config'; import services from '../services'; -type UserInDatabase = Required & {_id: string}; - class Users { db = Users.loadDatabase(); configUser: UserInDatabase = { @@ -77,7 +75,7 @@ class Users { createUser( credentials: Credentials, - callback: (data: {username: Credentials['username']} | null, error?: Error | null) => void, + callback: (data: {username: Credentials['username']} | null, error?: Error) => void, ): void { const {password, username, host, port, socketPath, isAdmin} = credentials; @@ -117,7 +115,7 @@ class Users { return callback(null, error); } - services.bootstrapServicesForUser(user); + services.bootstrapServicesForUser(user as UserInDatabase); return callback({username}); }); }) @@ -128,10 +126,7 @@ class Users { return undefined; } - removeUser( - username: Credentials['username'], - callback: (data: Credentials | null, error?: Error | null) => void, - ): void { + removeUser(username: Credentials['username'], callback: (data: Credentials | null, error?: Error) => void): void { this.db.findOne({username}, (findError: Error | null, user: UserInDatabase): void => { if (findError) { return callback(null, findError); diff --git a/server/models/client.js b/server/models/client.js index 93cffc09..c0561785 100644 --- a/server/models/client.js +++ b/server/models/client.js @@ -1,18 +1,17 @@ import fs from 'fs'; import path from 'path'; import sanitize from 'sanitize-filename'; -import series from 'run-series'; +import {series} from 'async'; import tar from 'tar-stream'; import ClientRequest from './ClientRequest'; import clientResponseUtil from '../util/clientResponseUtil'; -import {clientSettingsMap} from '../../shared/constants/clientSettingsMap'; +import {clientSettingsBiMap} from '../../shared/constants/clientSettingsMap'; import fileUtil from '../util/fileUtil'; import settings from './settings'; import torrentFilePropsMap from '../../shared/constants/torrentFilePropsMap'; import torrentPeerPropsMap from '../../shared/constants/torrentPeerPropsMap'; import torrentFileUtil from '../util/torrentFileUtil'; -import torrentStatusMap from '../../shared/constants/torrentStatusMap'; import torrentTrackerPropsMap from '../../shared/constants/torrentTrackerPropsMap'; const client = { @@ -86,10 +85,10 @@ const client = { settings.set(user, {id: 'startTorrentsOnLoad', data: start}); }, - checkHash(user, services, hashes, callback) { + checkHash(user, services, {hashes}, callback) { const request = new ClientRequest(user, services); - request.checkHash({hashes}); + request.checkHash(hashes); request.onComplete((response, error) => { services.torrentService.fetchTorrentList(); callback(response, error); @@ -150,7 +149,7 @@ const client = { }); series(tasks, (error) => { - if (error) return res.status(500).json(error); + if (error) res.status(500).json(error); pack.finalize(); }); @@ -202,7 +201,7 @@ const client = { data.forEach((datum, index) => { let value = datum[0]; - const settingsKey = clientSettingsMap[requestedSettingsKeys[index]]; + const settingsKey = clientSettingsBiMap[requestedSettingsKeys[index]]; if (outboundTransformation[settingsKey]) { value = outboundTransformation[settingsKey](value); @@ -251,7 +250,7 @@ const client = { } const hashesToRestart = hashes.filter( - (hash) => !services.torrentService.getTorrent(hash).status.includes(torrentStatusMap.stopped), + (hash) => !services.torrentService.getTorrent(hash).status.includes('stopped'), ); let afterCheckHash; diff --git a/server/models/settings.js b/server/models/settings.js deleted file mode 100644 index e828f977..00000000 --- a/server/models/settings.js +++ /dev/null @@ -1,138 +0,0 @@ -import Datastore from 'nedb'; -import noop from 'lodash/noop'; -import path from 'path'; - -import config from '../../config'; - -const databases = new Map(); - -const changedKeys = { - downloadRate: 'downRate', - downloadTotal: 'downTotal', - uploadRate: 'upRate', - uploadTotal: 'upTotal', - connectedPeers: 'peersConnected', - totalPeers: 'peersTotal', - connectedSeeds: 'seedsConnected', - totalSeeds: 'seedsTotal', - added: 'dateAdded', - creationDate: 'dateCreated', - trackers: 'trackerURIs', -}; - -const removedKeys = ['freeDiskSpace']; - -/** - * Check settings for old torrent propery keys. If the old keys exist and have - * been assigned values, then check that the new key doesn't also exist. When - * the new key does not exist, add the new key and assign it the old key's - * value. - * - * @param {Object} settings - the stored settings object. - * @return {Object} - the settings object, altered if legacy keys exist. - */ -const transformLegacyKeys = (settings) => { - if (settings.sortTorrents && settings.sortTorrents.property in changedKeys) { - settings.sortTorrents.property = changedKeys[settings.sortTorrents.property]; - } - - if (settings.torrentDetails) { - settings.torrentDetails = settings.torrentDetails.reduce((accumulator, detailItem) => { - if ( - detailItem && - detailItem.id in changedKeys && - !settings.torrentDetails.some((subDetailItem) => subDetailItem.id === changedKeys[detailItem.id]) - ) { - detailItem.id = changedKeys[detailItem.id]; - } - - if (!removedKeys.includes(detailItem.id)) { - accumulator.push(detailItem); - } - - return accumulator; - }, []); - } - - if (settings.torrentListColumnWidths) { - Object.keys(settings.torrentListColumnWidths).forEach((columnID) => { - if (columnID in changedKeys && !(changedKeys[columnID] in settings.torrentListColumnWidths)) { - settings.torrentListColumnWidths[changedKeys[columnID]] = settings.torrentListColumnWidths[columnID]; - } - }); - } - - return settings; -}; - -function getDb(user) { - const userId = user._id; - - if (databases.has(userId)) { - return databases.get(userId); - } - - const database = new Datastore({ - autoload: true, - filename: path.join(config.dbPath, userId, 'settings', 'settings.db'), - }); - - databases.set(userId, database); - - return database; -} - -const settings = { - get: (user, opts, callback) => { - const query = {}; - const settingsToReturn = {}; - - if (opts.property) { - query.id = opts.property; - } - - getDb(user) - .find(query) - .exec((err, docs) => { - if (err) { - callback(null, err); - return; - } - - docs.forEach((doc) => { - settingsToReturn[doc.id] = doc.data; - }); - - callback(transformLegacyKeys(settingsToReturn)); - }); - }, - - set: (user, payloads, callback = noop) => { - const docsResponse = []; - - if (!Array.isArray(payloads)) { - payloads = [payloads]; - } - - if (payloads && payloads.length) { - let error; - payloads.forEach((payload) => { - getDb(user).update({id: payload.id}, {$set: {data: payload.data}}, {upsert: true}, (err, docs) => { - docsResponse.push(docs); - if (err) { - error = err; - } - }); - }); - if (error) { - callback(null, error); - } else { - callback(docsResponse); - } - } else { - callback(); - } - }, -}; - -export default settings; diff --git a/server/models/settings.ts b/server/models/settings.ts new file mode 100644 index 00000000..e26a4d7d --- /dev/null +++ b/server/models/settings.ts @@ -0,0 +1,95 @@ +import Datastore from 'nedb'; +import noop from 'lodash/noop'; +import path from 'path'; + +import type {UserInDatabase} from '@shared/types/Auth'; + +import config from '../../config'; + +interface SettingsRecord { + id: string; + data: unknown; +} + +const databases = new Map(); + +function getDb(user: UserInDatabase): Datastore { + const userId = user._id; + + if (databases.has(userId)) { + return databases.get(userId); + } + + const database = new Datastore({ + autoload: true, + filename: path.join(config.dbPath, userId, 'settings', 'settings.db'), + }); + + databases.set(userId, database); + + return database; +} + +const settings = { + get: ( + user: UserInDatabase, + opts: {property?: string}, + callback: (data: Record | null, error?: Error) => void, + ) => { + const query: {id?: string} = {}; + const settingsToReturn: Record = {}; + + if (opts.property) { + query.id = opts.property; + } + + getDb(user) + .find(query) + .exec((err, docs) => { + if (err) { + callback(null, err); + return; + } + + docs.forEach((doc) => { + settingsToReturn[doc.id] = doc.data; + }); + + callback(settingsToReturn); + }); + }, + + set: ( + user: UserInDatabase, + payloads: Array, + callback: (data?: Array> | null, error?: Error) => void = noop, + ) => { + const docsResponse: Array> = []; + + if (payloads && payloads.length) { + let error; + payloads.forEach((payload) => { + getDb(user).update( + {id: payload.id}, + {$set: {data: payload.data}}, + {upsert: true}, + (err: Error | null, _numberOfUpdated: number, docs: Array, _upsert: boolean) => { + docsResponse.push(docs); + if (err) { + error = err; + } + }, + ); + }); + if (error) { + callback(null, error); + } else { + callback(docsResponse); + } + } else { + callback(); + } + }, +}; + +export default settings; diff --git a/server/routes/api.js b/server/routes/api.ts similarity index 55% rename from server/routes/api.js rename to server/routes/api.ts index 4febf6ff..3b908077 100644 --- a/server/routes/api.js +++ b/server/routes/api.ts @@ -1,6 +1,11 @@ import express from 'express'; import passport from 'passport'; +import type {Request} from 'express'; + +import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; +import type {NotificationFetchOptions} from '@shared/types/Notification'; + import appendUserServices from '../middleware/appendUserServices'; import ajaxUtil from '../util/ajaxUtil'; import client from '../models/client'; @@ -24,62 +29,70 @@ router.get('/download', (req, res) => { }); router.delete('/feed-monitor/:id', (req, res) => { - req.services.feedService.removeItem(req.params.id, ajaxUtil.getResponseFn(res)); + req.services?.feedService.removeItem(req.params.id, ajaxUtil.getResponseFn(res)); }); router.get('/feed-monitor', (req, res) => { - req.services.feedService.getAll(ajaxUtil.getResponseFn(res)); + req.services?.feedService.getAll(ajaxUtil.getResponseFn(res)); }); router.get('/feed-monitor/feeds', (req, res) => { - req.services.feedService.getFeeds(req.body.query, ajaxUtil.getResponseFn(res)); + req.services?.feedService.getFeeds(req.params.query, ajaxUtil.getResponseFn(res)); }); router.put('/feed-monitor/feeds', (req, res) => { - req.services.feedService.addFeed(req.body, ajaxUtil.getResponseFn(res)); + req.services?.feedService.addFeed(req.body, ajaxUtil.getResponseFn(res)); }); router.put('/feed-monitor/feeds/:id', (req, res) => { - req.services.feedService.modifyFeed(req.params.id, req.body, ajaxUtil.getResponseFn(res)); + req.services?.feedService.modifyFeed(req.params.id, req.body, ajaxUtil.getResponseFn(res)); }); router.get('/feed-monitor/rules', (req, res) => { - req.services.feedService.getRules(req.body.query, ajaxUtil.getResponseFn(res)); + req.services?.feedService.getRules(req.params.query, ajaxUtil.getResponseFn(res)); }); router.put('/feed-monitor/rules', (req, res) => { - req.services.feedService.addRule(req.body, ajaxUtil.getResponseFn(res)); + req.services?.feedService.addRule(req.body, ajaxUtil.getResponseFn(res)); }); router.get('/feed-monitor/items', (req, res) => { - req.services.feedService.getItems(req.query, ajaxUtil.getResponseFn(res)); + req.services?.feedService.getItems(req.query, ajaxUtil.getResponseFn(res)); }); router.get('/directory-list', (req, res) => { Filesystem.getDirectoryList(req.query, ajaxUtil.getResponseFn(res)); }); -router.get('/history', (req, res) => { - req.services.historyService.getHistory(req.query, ajaxUtil.getResponseFn(res)); +router.get('/history', (req: Request, res) => { + req.services?.historyService.getHistory(req.query, ajaxUtil.getResponseFn(res)); }); router.get('/mediainfo', (req, res) => { mediainfo.getMediainfo(req.user, req.query, ajaxUtil.getResponseFn(res)); }); -router.get('/notifications', (req, res) => { - req.services.notificationService.getNotifications(req.query, ajaxUtil.getResponseFn(res)); +router.get('/notifications', (req: Request, res) => { + req.services?.notificationService.getNotifications(req.query, ajaxUtil.getResponseFn(res)); }); router.delete('/notifications', (req, res) => { - req.services.notificationService.clearNotifications(req.query, ajaxUtil.getResponseFn(res)); + req.services?.notificationService.clearNotifications(ajaxUtil.getResponseFn(res)); }); router.get('/settings', (req, res) => { + if (req.user == null) { + res.status(500).json(Error('Unauthenticated')); + return; + } settings.get(req.user, req.query, ajaxUtil.getResponseFn(res)); }); router.patch('/settings', (req, res) => { + if (req.user == null) { + res.status(500).json(Error('Unauthenticated')); + return; + } settings.set(req.user, req.body, ajaxUtil.getResponseFn(res)); }); diff --git a/server/routes/auth.js b/server/routes/auth.ts similarity index 82% rename from server/routes/auth.js rename to server/routes/auth.ts index 180fa0ae..5acb4bc9 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.ts @@ -2,19 +2,30 @@ import express from 'express'; import joi from 'joi'; import jwt from 'jsonwebtoken'; import passport from 'passport'; + +import type {Response} from 'express'; +import type {Credentials} from '@shared/types/Auth'; + import ajaxUtil from '../util/ajaxUtil'; - -import requireAdmin from '../middleware/requireAdmin'; import config from '../../config'; - +import requireAdmin from '../middleware/requireAdmin'; import services from '../services'; import Users from '../models/Users'; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + initialUser?: boolean; + } + } +} + const router = express.Router(); const failedLoginResponse = 'Failed login.'; -const setAuthToken = (res, username, isAdmin) => { +const setAuthToken = (res: Response, username: Credentials['username'], isAdmin: Credentials['isAdmin']): void => { const expirationSeconds = 60 * 60 * 24 * 7; // one week const cookieExpiration = Date.now() + expirationSeconds * 1000; @@ -23,9 +34,9 @@ const setAuthToken = (res, username, isAdmin) => { expiresIn: expirationSeconds, }); - res.cookie('jwt', token, {expires: new Date(cookieExpiration), httpOnly: true, sameSite: 'Strict'}); + res.cookie('jwt', token, {expires: new Date(cookieExpiration), httpOnly: true, sameSite: 'strict'}); - return res.json({ + res.json({ success: true, token: `JWT ${token}`, username, @@ -59,7 +70,8 @@ router.use('/users', passport.authenticate('jwt', {session: false}), requireAdmi router.post('/authenticate', (req, res) => { if (config.disableUsersAndAuth) { - return setAuthToken(res, Users.getConfigUser()._id, true); + setAuthToken(res, Users.getConfigUser()._id, true); + return; } const credentials = { password: req.body.password, @@ -68,25 +80,26 @@ router.post('/authenticate', (req, res) => { Users.comparePassword(credentials, (isMatch, isAdmin, err) => { if (isMatch === true && err == null) { - return setAuthToken(res, credentials.username, isAdmin); + setAuthToken(res, credentials.username, isAdmin); + return; } // Incorrect username or password. - return res.status(401).json({ + res.status(401).json({ message: failedLoginResponse, }); }); }); // Allow unauthenticated registration if no users are currently registered. -router.use('/register', (req, res, next) => { +router.use('/register', (req, _res, next) => { Users.initialUserGate({ handleInitialUser: () => { next(); }, handleSubsequentUser: () => { passport.authenticate('jwt', {session: false}, (passportReq, passportRes) => { - passportRes.json({username: req.username}); + passportRes.json({username: req.body.username}); }); }, }); @@ -123,7 +136,8 @@ router.post('/register', (req, res) => { // Allow unauthenticated verification if no users are currently registered. router.use('/verify', (req, res, next) => { if (config.disableUsersAndAuth) { - return setAuthToken(res, Users.getConfigUser()._id, true); + setAuthToken(res, Users.getConfigUser()._id, true); + return; } Users.initialUserGate({ handleInitialUser: () => { @@ -174,7 +188,9 @@ router.get('/users', (req, res) => { router.delete('/users/:username', (req, res) => { Users.removeUser(req.params.username, ajaxUtil.getResponseFn(res)); - services.destroyUserServices(req.user); + if (req.user != null) { + services.destroyUserServices(req.user); + } }); router.patch('/users/:username', (req, res) => { @@ -190,8 +206,16 @@ router.patch('/users/:username', (req, res) => { Users.updateUser(username, userPatch, () => { Users.lookupUser({username}, (err, user) => { - if (err) return req.status(500).json({error: err}); - services.updateUserServices(user); + if (err) { + res.status(500).json({error: err}); + return; + } + + if (user != null) { + services.destroyUserServices(user); + services.bootstrapServicesForUser(user); + } + res.send(); }); }); diff --git a/server/routes/client.js b/server/routes/client.ts similarity index 93% rename from server/routes/client.js rename to server/routes/client.ts index ebba528c..60d57b5c 100644 --- a/server/routes/client.js +++ b/server/routes/client.ts @@ -14,7 +14,7 @@ const upload = multer({ }); router.get('/connection-test', (req, res) => { - req.services.clientGatewayService + req.services?.clientGatewayService .testGateway() .then(() => { res.status(200).json({isConnected: true}); @@ -25,7 +25,7 @@ router.get('/connection-test', (req, res) => { }); router.post('/connection-test', (req, res) => { - req.services.clientGatewayService + req.services?.clientGatewayService .testGateway(req.body) .then(() => { res.status(200).json({isConnected: true}); @@ -76,7 +76,7 @@ router.patch('/torrents/:hash/file-priority', (req, res) => { }); router.post('/torrents/check-hash', (req, res) => { - client.checkHash(req.user, req.services, req.body.hash, ajaxUtil.getResponseFn(res)); + client.checkHash(req.user, req.services, req.body, ajaxUtil.getResponseFn(res)); }); router.post('/torrents/move', (req, res) => { @@ -84,10 +84,10 @@ router.post('/torrents/move', (req, res) => { }); router.post('/torrents/delete', (req, res) => { - const {deleteData, hash: hashes} = req.body; + const {deleteData, hashes} = req.body; const callback = ajaxUtil.getResponseFn(res); - req.services.clientGatewayService + req.services?.clientGatewayService .removeTorrents({hashes, deleteData}) .then(callback) .catch((err) => { diff --git a/server/services/BaseService.js b/server/services/BaseService.js deleted file mode 100644 index 637c021e..00000000 --- a/server/services/BaseService.js +++ /dev/null @@ -1,22 +0,0 @@ -import EventEmitter from 'events'; - -class BaseService extends EventEmitter { - constructor(user, services, ...eventEmitterConfig) { - super(...eventEmitterConfig); - if (!user || !user._id) throw new Error('Missing user ID'); - this.user = user; - this.services = services; - } - - destroy() { - delete this.services; - delete this.user; - } - - updateUser(user, services) { - this.user = user; - this.services = services; - } -} - -export default BaseService; diff --git a/server/services/BaseService.ts b/server/services/BaseService.ts new file mode 100644 index 00000000..cc22d499 --- /dev/null +++ b/server/services/BaseService.ts @@ -0,0 +1,35 @@ +import {EventEmitter} from 'events'; +import type TypedEmitter from 'typed-emitter'; + +import type {UserInDatabase} from '@shared/types/Auth'; + +import type {UserServices} from '.'; + +class BaseService extends (EventEmitter as {new (): TypedEmitter}) { + user: UserInDatabase; + services?: UserServices; + + constructor(user: UserInDatabase) { + super(); + this.user = user; + } + + destroy() { + delete this.services; + } + + onServicesUpdated = () => { + // do nothing. + }; + + updateUser(user: UserInDatabase) { + this.user = user; + } + + updateServices(service?: UserServices) { + this.services = service; + this.onServicesUpdated(); + } +} + +export default BaseService; diff --git a/server/services/clientGatewayService.js b/server/services/clientGatewayService.js deleted file mode 100644 index 29fba2e0..00000000 --- a/server/services/clientGatewayService.js +++ /dev/null @@ -1,265 +0,0 @@ -import path from 'path'; -import fs from 'fs'; - -import BaseService from './BaseService'; -import clientGatewayServiceEvents from '../constants/clientGatewayServiceEvents'; -import fileListPropMap from '../constants/fileListPropMap'; -import methodCallUtil from '../util/methodCallUtil'; -import scgiUtil from '../util/scgiUtil'; - -const fileListMethodCallConfig = methodCallUtil.getMethodCallConfigFromPropMap(fileListPropMap, ['pathComponents']); - -class ClientGatewayService extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); - - this.hasError = null; - this.torrentListReducers = []; - this.processClientRequestError = this.processClientRequestError.bind(this); - this.processClientRequestSuccess = this.processClientRequestSuccess.bind(this); - } - - /** - * Adds a reducer to be applied when processing the torrent list. - * - * @param {Object} reducer - The reducer object - * @param {string} reducer.key - The key of the reducer, to be applied to the - * torrent list object. - * @param {function} reducer.reduce - The actual reducer. This will recevie - * the entire processed torrent list response and it should return it own - * processed value, to be assigned to the provided key. - */ - addTorrentListReducer(reducer = {}) { - if (typeof reducer.key !== 'string') { - throw new Error('reducer.key must be a string.'); - } - - if (typeof reducer.reduce !== 'function') { - throw new Error('reducer.reduce must be a function.'); - } - - this.torrentListReducers.push(reducer); - } - - removeTorrents(options = {hashes: [], deleteData: false}) { - const methodCalls = options.hashes.reduce((accumulator, hash, index) => { - let eraseFileMethodCallIndex = index; - - // If we're deleting files, we grab each torrents' file list before we - // remove them. - if (options.deleteData) { - // We offset the indices of these method calls so that we know exactly - // where to retrieve the responses in the future. - const directoryBaseMethodCallIndex = index + options.hashes.length; - // We also need to ensure that the erase method call occurs after - // our request for information. - eraseFileMethodCallIndex = index + options.hashes.length * 2; - - accumulator[index] = { - methodName: 'f.multicall', - params: [hash, ''].concat(fileListMethodCallConfig.methodCalls), - }; - - accumulator[directoryBaseMethodCallIndex] = { - methodName: 'd.directory_base', - params: [hash], - }; - } - - accumulator[eraseFileMethodCallIndex] = { - methodName: 'd.erase', - params: [hash], - }; - - return accumulator; - }, []); - - return this.services.clientRequestManager - .methodCall('system.multicall', [methodCalls]) - .then((response) => { - if (options.deleteData) { - const torrentCount = options.hashes.length; - const filesToDelete = options.hashes.reduce((accumulator, hash, hashIndex) => { - const fileList = response[hashIndex][0]; - const directoryBase = response[hashIndex + torrentCount][0]; - - const torrentFilesToDelete = fileList.reduce((fileListAccumulator, file) => { - // We only look at the first path component returned because - // if it's a directory within the torrent, then we'll remove - // the entire directory. - const filePath = path.join(directoryBase, file[0][0]); - - // filePath might be a directory, so it may have already been - // added. If not, we add it. - if (!fileListAccumulator.includes(filePath)) { - fileListAccumulator.push(filePath); - } - - return fileListAccumulator; - }, []); - - return accumulator.concat(torrentFilesToDelete); - }, []); - - filesToDelete.forEach((file) => { - try { - if (fs.lstatSync(file).isDirectory()) { - fs.rmdirSync(file, {recursive: true}); - } else { - fs.unlinkSync(file); - } - } catch (error) { - console.error(`Error deleting file: ${file}\n${error}`); - } - }); - } - - this.emit(clientGatewayServiceEvents.TORRENTS_REMOVED); - - return response; - }) - .catch(this.processClientRequestError); - } - - /** - * Sends a multicall request to rTorrent with the requested method calls. - * - * @param {Object} options - An object of options... - * @param {Array} options.methodCalls - An array of strings representing - * method calls, which the client uses to retrieve details. - * @param {Array} options.propLabels - An array of strings that are used as - * keys for the transformed torrent details. - * @param {Array} options.valueTransformations - An array of functions that - * will be called with the values as returned by the client. These return - * values will be assigned to the key from the propLabels array. - * @return {Promise} - Resolves with the processed client response or rejects - * with the processed client error. - */ - fetchTorrentList(options) { - return this.services.clientRequestManager - .methodCall('d.multicall2', ['', 'main'].concat(options.methodCalls)) - .then(this.processClientRequestSuccess) - .then((torrents) => this.processTorrentListResponse(torrents, options)) - .catch(this.processClientRequestError); - } - - fetchTransferSummary(options) { - const methodCalls = options.methodCalls.map((methodName) => ({methodName, params: []})); - - return this.services.clientRequestManager - .methodCall('system.multicall', [methodCalls]) - .then(this.processClientRequestSuccess) - .then((transferRate) => this.processTransferRateResponse(transferRate, options)) - .catch(this.processClientRequestError); - } - - processClientRequestSuccess(response) { - if (this.hasError == null || this.hasError === true) { - this.hasError = false; - this.emit(clientGatewayServiceEvents.CLIENT_CONNECTION_STATE_CHANGE); - } - - return response; - } - - processClientRequestError(error) { - if (!this.hasError) { - this.hasError = true; - this.emit(clientGatewayServiceEvents.CLIENT_CONNECTION_STATE_CHANGE); - } - throw error; - } - - /** - * After rTorrent responds with the requested torrent details, we construct - * an object with hashes as keys and processed details as values. - * - * @param {Array} response - The array of all torrents and their details. - * @param {Object} options - An object of options that instruct us how to - * process the client's response. - * @param {Array} options.propLabels - An array of strings that map to the - * method call. These are the keys of the torrent details. - * @param {Array} options.valueTransformations - An array of functions that - * transform the detail from the client's response. - * @return {Object} - An object that represents all torrents with hashes as - * keys, each value being an object of detail labels and values. - */ - processTorrentListResponse(torrentList, options) { - this.emit(clientGatewayServiceEvents.PROCESS_TORRENT_LIST_START); - - // We map the array of details to objects with sensibly named keys. We want - // to return an object with torrent hashes as keys and an object of torrent - // details as values. - const processedTorrentList = torrentList.reduce( - (listAccumulator, torrentDetailValues) => { - // Transform the array of torrent detail values to an object with - // sensibly named keys. - const processedTorrentDetailValues = torrentDetailValues.reduce((valueAccumulator, value, valueIndex) => { - const key = options.propLabels[valueIndex]; - const transformValue = options.valueTransformations[valueIndex]; - - valueAccumulator[key] = transformValue(value); - return valueAccumulator; - }, {}); - - // Assign values from external reducers to the torrent list object. - this.torrentListReducers.forEach((reducer) => { - const {key, reduce} = reducer; - - processedTorrentDetailValues[key] = reduce(processedTorrentDetailValues); - }); - - listAccumulator.torrents[processedTorrentDetailValues.hash] = processedTorrentDetailValues; - - this.emit(clientGatewayServiceEvents.PROCESS_TORRENT, processedTorrentDetailValues); - - return listAccumulator; - }, - {torrents: {}}, - ); - - // Provide the number of torrents. - processedTorrentList.length = torrentList.length; - // Provide a unique ID for this specific torrent list. - processedTorrentList.id = Date.now(); - - this.emit(clientGatewayServiceEvents.PROCESS_TORRENT_LIST_END, processedTorrentList); - - return processedTorrentList; - } - - processTransferRateResponse(transferRate = [], options) { - this.emit(clientGatewayServiceEvents.PROCESS_TRANSFER_RATE_START); - - return transferRate.reduce((accumulator, value, index) => { - const key = options.propLabels[index]; - const transformValue = options.valueTransformations[index]; - - accumulator[key] = transformValue(value); - - return accumulator; - }, {}); - } - - testGateway(clientSettings) { - if (!clientSettings) { - return this.services.clientRequestManager - .methodCall('system.methodExist', ['system.multicall']) - .then(this.processClientRequestSuccess) - .catch(this.processClientRequestError); - } - - return scgiUtil.methodCall( - { - socket: clientSettings.socket, - socketPath: clientSettings.socketPath, - port: clientSettings.port, - host: clientSettings.host, - }, - 'system.methodExist', - ['system.multicall'], - ); - } -} - -export default ClientGatewayService; diff --git a/server/services/clientGatewayService.ts b/server/services/clientGatewayService.ts new file mode 100644 index 00000000..31bebfee --- /dev/null +++ b/server/services/clientGatewayService.ts @@ -0,0 +1,312 @@ +import path from 'path'; +import fs from 'fs'; + +import type {Credentials} from '@shared/types/Auth'; +import type {TorrentProperties, Torrents} from '@shared/types/Torrent'; +import type {TransferSummary} from '@shared/types/TransferData'; + +import BaseService from './BaseService'; +import fileListPropMap from '../constants/fileListPropMap'; +import methodCallUtil from '../util/methodCallUtil'; +import scgiUtil from '../util/scgiUtil'; + +interface ClientGatewayServiceEvents { + TORRENTS_REMOVED: () => void; + CLIENT_CONNECTION_STATE_CHANGE: () => void; + PROCESS_TORRENT_LIST_START: () => void; + PROCESS_TORRENT_LIST_END: (processedTorrentList: {torrents: Torrents}) => void; + PROCESS_TORRENT: (processedTorrentDetailValues: TorrentProperties) => void; + PROCESS_TRANSFER_RATE_START: () => void; +} + +interface TorrentListReducer { + key: T; + reduce: (properties: TorrentProperties) => TorrentProperties[T]; +} + +interface MethodCallConfig { + methodCalls: Array; + propLabels: Array; + valueTransformations: Array<(value: string) => string | number | boolean>; +} + +const fileListMethodCallConfig = methodCallUtil.getMethodCallConfigFromPropMap(fileListPropMap, ['pathComponents']); + +class ClientGatewayService extends BaseService { + hasError: boolean | null = null; + torrentListReducers: Array = []; + + constructor(...args: ConstructorParameters) { + super(...args); + + this.processClientRequestError = this.processClientRequestError.bind(this); + this.processClientRequestSuccess = this.processClientRequestSuccess.bind(this); + } + + /** + * Adds a reducer to be applied when processing the torrent list. + * + * @param {Object} reducer - The reducer object + * @param {string} reducer.key - The key of the reducer, to be applied to the + * torrent list object. + * @param {function} reducer.reduce - The actual reducer. This will receive + * the entire processed torrent list response and it should return it own + * processed value, to be assigned to the provided key. + */ + addTorrentListReducer(reducer: T) { + if (typeof reducer.key !== 'string') { + throw new Error('reducer.key must be a string.'); + } + + if (typeof reducer.reduce !== 'function') { + throw new Error('reducer.reduce must be a function.'); + } + + this.torrentListReducers.push(reducer); + } + + removeTorrents({hashes, deleteData}: {hashes: Array; deleteData: boolean}) { + if (this.services == null || this.services.clientRequestManager == null) { + return Promise.reject(); + } + + const methodCalls = hashes.reduce( + (accumulator: Array<{methodName: string; params: Array}>, hash, index) => { + let eraseFileMethodCallIndex = index; + + // If we're deleting files, we grab each torrents' file list before we + // remove them. + if (deleteData === true) { + // We offset the indices of these method calls so that we know exactly + // where to retrieve the responses in the future. + const directoryBaseMethodCallIndex = index + hashes.length; + // We also need to ensure that the erase method call occurs after + // our request for information. + eraseFileMethodCallIndex = index + hashes.length * 2; + + accumulator[index] = { + methodName: 'f.multicall', + params: [hash, ''].concat(fileListMethodCallConfig.methodCalls), + }; + + accumulator[directoryBaseMethodCallIndex] = { + methodName: 'd.directory_base', + params: [hash], + }; + } + + accumulator[eraseFileMethodCallIndex] = { + methodName: 'd.erase', + params: [hash], + }; + + return accumulator; + }, + [], + ); + + return this.services.clientRequestManager.methodCall('system.multicall', [methodCalls]).then((response) => { + if (deleteData === true) { + const torrentCount = hashes.length; + const filesToDelete = hashes.reduce((accumulator, _hash, hashIndex) => { + const fileList = (response as string[][][][][])[hashIndex][0]; + const directoryBase = (response as string[][])[hashIndex + torrentCount][0]; + + const torrentFilesToDelete = fileList.reduce((fileListAccumulator, file) => { + // We only look at the first path component returned because + // if it's a directory within the torrent, then we'll remove + // the entire directory. + const filePath = path.join(directoryBase, file[0][0]); + + // filePath might be a directory, so it may have already been + // added. If not, we add it. + if (!fileListAccumulator.includes(filePath)) { + fileListAccumulator.push(filePath); + } + + return fileListAccumulator; + }, [] as Array); + + return accumulator.concat(torrentFilesToDelete); + }, [] as Array); + + filesToDelete.forEach((file) => { + try { + if (fs.lstatSync(file).isDirectory()) { + fs.rmdirSync(file, {recursive: true}); + } else { + fs.unlinkSync(file); + } + } catch (error) { + console.error(`Error deleting file: ${file}\n${error}`); + } + }); + } + + this.emit('TORRENTS_REMOVED'); + + return response; + }, this.processClientRequestError); + } + + /** + * Sends a multicall request to rTorrent with the requested method calls. + * + * @param {Object} options - An object of options... + * @param {Array} options.methodCalls - An array of strings representing + * method calls, which the client uses to retrieve details. + * @param {Array} options.propLabels - An array of strings that are used as + * keys for the transformed torrent details. + * @param {Array} options.valueTransformations - An array of functions that + * will be called with the values as returned by the client. These return + * values will be assigned to the key from the propLabels array. + * @return {Promise} - Resolves with the processed client response or rejects + * with the processed client error. + */ + fetchTorrentList(options: MethodCallConfig) { + if (this.services == null) { + return Promise.reject(); + } + + return this.services.clientRequestManager + .methodCall('d.multicall2', ['', 'main'].concat(options.methodCalls)) + .then(this.processClientRequestSuccess) + .then( + (torrents) => this.processTorrentListResponse(torrents as Array>, options), + this.processClientRequestError, + ); + } + + fetchTransferSummary(options: MethodCallConfig) { + if (this.services == null) { + return Promise.reject(); + } + + const methodCalls = options.methodCalls.map((methodName) => ({methodName, params: []})); + + return this.services.clientRequestManager + .methodCall('system.multicall', [methodCalls]) + .then(this.processClientRequestSuccess) + .then( + (transferRate) => this.processTransferRateResponse(transferRate as Array, options), + this.processClientRequestError, + ); + } + + processClientRequestSuccess(response: T): T { + if (this.hasError == null || this.hasError === true) { + this.hasError = false; + this.emit('CLIENT_CONNECTION_STATE_CHANGE'); + } + + return response; + } + + processClientRequestError(error: Error) { + if (!this.hasError) { + this.hasError = true; + this.emit('CLIENT_CONNECTION_STATE_CHANGE'); + } + return Promise.reject(error); + } + + /** + * After rTorrent responds with the requested torrent details, we construct + * an object with hashes as keys and processed details as values. + * + * @param {Array} response - The array of all torrents and their details. + * @param {Object} options - An object of options that instruct us how to + * process the client's response. + * @param {Array} options.propLabels - An array of strings that map to the + * method call. These are the keys of the torrent details. + * @param {Array} options.valueTransformations - An array of functions that + * transform the detail from the client's response. + * @return {Object} - An object that represents all torrents with hashes as + * keys, each value being an object of detail labels and values. + */ + processTorrentListResponse( + torrentList: Array>, + {propLabels, valueTransformations}: MethodCallConfig, + ): {id: number; torrents: Torrents} { + this.emit('PROCESS_TORRENT_LIST_START'); + + // We map the array of details to objects with sensibly named keys. We want + // to return an object with torrent hashes as keys and an object of torrent + // details as values. + const processedTorrentList = torrentList.reduce( + (listAccumulator, torrentDetailValues) => { + // Transform the array of torrent detail values to an object with + // sensibly named keys. + let processedTorrentDetailValues = (torrentDetailValues.reduce( + (valueAccumulator: Record, value: string, valueIndex: number) => { + const key = propLabels[valueIndex]; + const transformValue = valueTransformations[valueIndex]; + + return Object.assign(valueAccumulator, {[key]: transformValue(value)}); + }, + {}, + ) as unknown) as TorrentProperties; + + // Assign values from external reducers to the torrent list object. + this.torrentListReducers.forEach((reducer) => { + const {key, reduce} = reducer; + + processedTorrentDetailValues = Object.assign(processedTorrentDetailValues, { + [key]: reduce(processedTorrentDetailValues), + }); + }); + + this.emit('PROCESS_TORRENT', processedTorrentDetailValues); + + return { + id: listAccumulator.id, + torrents: Object.assign(listAccumulator.torrents, { + [processedTorrentDetailValues.hash]: processedTorrentDetailValues, + }), + }; + }, + {id: Date.now(), torrents: {}} as {id: number; torrents: Torrents}, + ); + + this.emit('PROCESS_TORRENT_LIST_END', processedTorrentList); + + return processedTorrentList; + } + + processTransferRateResponse(transferRate: Array, {propLabels, valueTransformations}: MethodCallConfig) { + this.emit('PROCESS_TRANSFER_RATE_START'); + + return (transferRate.reduce((accumulator, value, index) => { + const key = propLabels[index]; + const transformValue = valueTransformations[index]; + + accumulator[key] = transformValue(value); + + return accumulator; + }, {} as Record) as unknown) as TransferSummary; + } + + testGateway(clientSettings?: Pick) { + if (clientSettings == null) { + if (this.services != null && this.services.clientRequestManager != null) { + return this.services.clientRequestManager + .methodCall('system.methodExist', ['system.multicall']) + .then(this.processClientRequestSuccess) + .catch(this.processClientRequestError); + } + return Promise.reject(); + } + + return scgiUtil.methodCall( + { + socketPath: clientSettings.socketPath, + port: clientSettings.port, + host: clientSettings.host, + }, + 'system.methodExist', + ['system.multicall'], + ); + } +} + +export default ClientGatewayService; diff --git a/server/services/clientRequestManager.js b/server/services/clientRequestManager.ts similarity index 60% rename from server/services/clientRequestManager.js rename to server/services/clientRequestManager.ts index 5b737efd..a4dfed00 100644 --- a/server/services/clientRequestManager.js +++ b/server/services/clientRequestManager.ts @@ -2,14 +2,19 @@ import BaseService from './BaseService'; import scgiUtil from '../util/scgiUtil'; class ClientRequestManager extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); + isRequestPending = false; + lastResponseTimestamp = 0; + pendingRequests: Array<{ + methodName: string; + parameters: Array}>>; + resolve: (value?: unknown) => void; + reject: () => void; + }> = []; - this.isRequestPending = false; - this.lastResponseTimestamp = 0; - this.pendingRequests = []; + constructor(...args: ConstructorParameters) { + super(...args); - this.sendDefferedMethodCall = this.sendDefferedMethodCall.bind(this); + this.sendDeferredMethodCall = this.sendDeferredMethodCall.bind(this); this.sendMethodCall = this.sendMethodCall.bind(this); this.methodCall = this.methodCall.bind(this); } @@ -24,27 +29,27 @@ class ClientRequestManager extends BaseService { if (timeSinceLastResponse <= 250) { const delay = 250 - timeSinceLastResponse; - setTimeout(this.sendDefferedMethodCall, delay); + setTimeout(this.sendDeferredMethodCall, delay); this.lastResponseTimestamp = currentTimestamp + delay; } else { - this.sendDefferedMethodCall(); + this.sendDeferredMethodCall(); this.lastResponseTimestamp = currentTimestamp; } } - sendDefferedMethodCall() { - if (this.pendingRequests.length > 0) { - this.isRequestPending = true; - - const nextRequest = this.pendingRequests.shift(); - - this.sendMethodCall(nextRequest.methodName, nextRequest.parameters) - .then(nextRequest.resolve) - .catch(nextRequest.reject); + sendDeferredMethodCall() { + const nextRequest = this.pendingRequests.shift(); + if (nextRequest == null) { + return; } + + this.isRequestPending = true; + this.sendMethodCall(nextRequest.methodName, nextRequest.parameters) + .then(nextRequest.resolve) + .catch(nextRequest.reject); } - sendMethodCall(methodName, parameters) { + sendMethodCall(methodName: string, parameters: Array}>>) { const connectionMethod = { host: this.user.host, port: this.user.port, @@ -63,7 +68,7 @@ class ClientRequestManager extends BaseService { }); } - methodCall(methodName, parameters) { + methodCall(methodName: string, parameters: Array}>>) { // We only allow one request at a time. if (this.isRequestPending) { return new Promise((resolve, reject) => { diff --git a/server/services/diskUsageService.ts b/server/services/diskUsageService.ts new file mode 100644 index 00000000..91d613db --- /dev/null +++ b/server/services/diskUsageService.ts @@ -0,0 +1,78 @@ +/** + * This service is not per rTorrent session, which is why it does not inherit + * `BaseService` nor have any use of the per user API ie. `getService()` + */ +import {EventEmitter} from 'events'; +import type TypedEmitter from 'typed-emitter'; +import type {Disks} from '@shared/types/DiskUsage'; +import {isPlatformSupported, diskUsage} from '../util/diskUsage'; +import type {SupportedPlatform} from '../util/diskUsage'; + +export interface DiskUsage { + id: number; + disks: Disks; +} + +interface DiskUsageEvents { + DISK_USAGE_CHANGE: (usage: DiskUsage) => void; + newListener: (event: keyof Omit) => void; + removeListener: (event: keyof Omit) => void; +} + +const INTERVAL_UPDATE = 10000; + +class DiskUsageService extends (EventEmitter as new () => TypedEmitter) { + disks: Disks = []; + tLastChange = 0; + interval = 0; + updateInterval?: NodeJS.Timeout; + + constructor() { + super(); + + if (!isPlatformSupported()) { + console.log(`warning: DiskUsageService does not support this platform`); + return; + } + + // start polling disk usage when the first listener is added + this.on('newListener', (event) => { + if (this.listenerCount('DISK_USAGE_CHANGE') === 0 && event === 'DISK_USAGE_CHANGE') { + this.updateInterval = setInterval(this.updateDisks.bind(this), INTERVAL_UPDATE); + } + }); + + // stop polling disk usage when the last listener is removed + this.on('removeListener', (event) => { + if ( + this.listenerCount('DISK_USAGE_CHANGE') === 0 && + event === 'DISK_USAGE_CHANGE' && + this.updateInterval != null + ) { + clearInterval(this.updateInterval); + } + }); + } + + updateDisks() { + if (!isPlatformSupported()) { + return Promise.reject(); + } + return diskUsage[process.platform as SupportedPlatform]().then((disks) => { + if (disks.length !== this.disks.length || disks.some((d, i) => d.used !== this.disks[i].used)) { + this.tLastChange = Date.now(); + this.disks = disks; + this.emit('DISK_USAGE_CHANGE', this.getDiskUsage()); + } + }); + } + + getDiskUsage(): DiskUsage { + return { + id: this.tLastChange, + disks: this.disks, + } as const; + } +} + +export default new DiskUsageService(); diff --git a/server/services/feedService.js b/server/services/feedService.js index 182cf4f1..361d892f 100644 --- a/server/services/feedService.js +++ b/server/services/feedService.js @@ -78,13 +78,13 @@ const getUrlsFromItems = (feedItems) => { }; class FeedService extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); + constructor(...args) { + super(...args); - this.isDBReady = false; this.db = this.loadDatabase(); - - this.init(); + this.onServicesUpdated = () => { + this.init(); + }; } addFeed(feed, callback) { @@ -104,7 +104,9 @@ class FeedService extends BaseService { } addItem(type, item, callback) { - if (!this.isDBReady) return; + if (this.db == null) { + return; + } this.db.insert(Object.assign(item, {type}), (err, newDoc) => { if (err) { @@ -117,7 +119,7 @@ class FeedService extends BaseService { } modifyItem(id, newItem, callback) { - if (!this.isDBReady) { + if (this.db == null) { return; } @@ -305,13 +307,15 @@ class FeedService extends BaseService { } loadDatabase() { - if (this.isDBReady) return; + if (this.db != null) { + return this.db; + } const db = new Datastore({ autoload: true, filename: path.join(config.dbPath, this.user._id, 'settings', 'feeds.db'), }); - this.isDBReady = true; + return db; } diff --git a/server/services/historyService.js b/server/services/historyService.ts similarity index 52% rename from server/services/historyService.js rename to server/services/historyService.ts index 21543ede..64fb95ff 100644 --- a/server/services/historyService.js +++ b/server/services/historyService.ts @@ -1,120 +1,130 @@ +import type {DiffAction} from '@shared/constants/diffActionTypes'; +import type {HistorySnapshot} from '@shared/constants/historySnapshotTypes'; +import type {TransferHistory, TransferSummary, TransferSummaryDiff} from '@shared/types/TransferData'; + import BaseService from './BaseService'; import config from '../../config'; import HistoryEra from '../models/HistoryEra'; -import historyServiceEvents from '../constants/historyServiceEvents'; import historySnapshotTypes from '../../shared/constants/historySnapshotTypes'; import methodCallUtil from '../util/methodCallUtil'; import objectUtil from '../../shared/util/objectUtil'; import transferSummaryPropMap from '../constants/transferSummaryPropMap'; -const transferSummaryMethodCallConfig = methodCallUtil.getMethodCallConfigFromPropMap(transferSummaryPropMap); - -const processData = (opts, callback, data, error) => { - if (error) { - callback(null, error); - return; - } - - data = data.slice(data.length - config.maxHistoryStates); - - callback( - data.reduce( - (accumulator, snapshot) => { - accumulator.download.push(snapshot.dn); - accumulator.upload.push(snapshot.up); - accumulator.timestamps.push(snapshot.ts); - - return accumulator; - }, - {upload: [], download: [], timestamps: []}, - ), - ); +type HistorySnapshotEvents = { + // TODO: Switch to string literal template type when TypeScript 4.1 is released. + // [snapshot in `${HistorySnapshot}_SNAPSHOT_FULL_UPDATE`]: (payload: {id: number, data: TransferHistory}) => void; + FIVE_MINUTE_SNAPSHOT_FULL_UPDATE: (payload: {id: number; data: TransferHistory}) => void; }; -class HistoryService extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); +interface HistoryServiceEvents extends HistorySnapshotEvents { + TRANSFER_SUMMARY_DIFF_CHANGE: (payload: {id: number; diff: TransferSummaryDiff}) => void; + FETCH_TRANSFER_SUMMARY_SUCCESS: () => void; + FETCH_TRANSFER_SUMMARY_ERROR: () => void; +} - this.errorCount = 0; - this.lastSnapshots = {}; - this.pollTimeout = null; - this.transferSummary = {}; +const transferSummaryMethodCallConfig = methodCallUtil.getMethodCallConfigFromPropMap(transferSummaryPropMap); + +class HistoryService extends BaseService { + errorCount = 0; + pollTimeout?: NodeJS.Timeout; + lastSnapshots: Partial> = {}; + + transferSummary: TransferSummary = { + downRate: 0, + downThrottle: 0, + downTotal: 0, + upRate: 0, + upThrottle: 0, + upTotal: 0, + }; + + snapshots: Record = { + YEAR: new HistoryEra(this.user, { + interval: 1000 * 60 * 60 * 24 * 7, // 7 days + maxTime: 0, // infinite + name: 'yearSnapshot', + }), + + MONTH: new HistoryEra(this.user, { + interval: 1000 * 60 * 60 * 12, // 12 hours + maxTime: 1000 * 60 * 60 * 24 * 365, // 365 days + name: 'monthSnapshot', + nextEraUpdateInterval: 1000 * 60 * 60 * 24 * 7, // 7 days + }), + + WEEK: new HistoryEra(this.user, { + interval: 1000 * 60 * 60 * 4, // 4 hours + maxTime: 1000 * 60 * 60 * 24 * 7 * 24, // 24 weeks + name: 'weekSnapshot', + nextEraUpdateInterval: 1000 * 60 * 60 * 12, // 12 hours + }), + + DAY: new HistoryEra(this.user, { + interval: 1000 * 60 * 60, // 60 minutes + maxTime: 1000 * 60 * 60 * 24 * 30, // 30 days + name: 'daySnapshot', + nextEraUpdateInterval: 1000 * 60 * 60 * 4, // 4 hours + }), + + HOUR: new HistoryEra(this.user, { + interval: 1000 * 60 * 15, // 15 minutes + maxTime: 1000 * 60 * 60 * 24, // 24 hours + name: 'hourSnapshot', + nextEraUpdateInterval: 1000 * 60 * 60, // 60 minutes + }), + + THIRTY_MINUTE: new HistoryEra(this.user, { + interval: 1000 * 20, // 20 seconds + maxTime: 1000 * 60 * 30, // 30 minutes + name: 'thirtyMinSnapshot', + nextEraUpdateInterval: 1000 * 60 * 15, // 15 minutes + }), + + FIVE_MINUTE: new HistoryEra(this.user, { + interval: 1000 * 5, // 5 seconds + maxTime: 1000 * 60 * 5, // 5 minutes + name: 'fiveMinSnapshot', + nextEraUpdateInterval: 1000 * 20, // 20 seconds + }), + } as const; + + constructor(...args: ConstructorParameters) { + super(...args); + + let nextEra: HistoryEra; + Object.values(this.snapshots).forEach((snapshot, index) => { + if (index === 0) { + nextEra = snapshot; + return; + } + snapshot.setNextEra(nextEra); + nextEra = snapshot; + }); this.fetchCurrentTransferSummary = this.fetchCurrentTransferSummary.bind(this); this.handleFetchTransferSummaryError = this.handleFetchTransferSummaryError.bind(this); this.handleFetchTransferSummarySuccess = this.handleFetchTransferSummarySuccess.bind(this); - this.yearSnapshot = new HistoryEra(this.user, { - interval: 1000 * 60 * 60 * 24 * 7, // 7 days - maxTime: 0, // infinite - name: 'yearSnapshot', - }); - - this.monthSnapshot = new HistoryEra(this.user, { - interval: 1000 * 60 * 60 * 12, // 12 hours - maxTime: 1000 * 60 * 60 * 24 * 365, // 365 days - name: 'monthSnapshot', - nextEraUpdateInterval: 1000 * 60 * 60 * 24 * 7, // 7 days - nextEra: this.yearSnapshot, - }); - - this.weekSnapshot = new HistoryEra(this.user, { - interval: 1000 * 60 * 60 * 4, // 4 hours - maxTime: 1000 * 60 * 60 * 24 * 7 * 24, // 24 weeks - name: 'weekSnapshot', - nextEraUpdateInterval: 1000 * 60 * 60 * 12, // 12 hours - nextEra: this.monthSnapshot, - }); - - this.daySnapshot = new HistoryEra(this.user, { - interval: 1000 * 60 * 60, // 60 minutes - maxTime: 1000 * 60 * 60 * 24 * 30, // 30 days - name: 'daySnapshot', - nextEraUpdateInterval: 1000 * 60 * 60 * 4, // 4 hours - nextEra: this.weekSnapshot, - }); - - this.hourSnapshot = new HistoryEra(this.user, { - interval: 1000 * 60 * 15, // 15 minutes - maxTime: 1000 * 60 * 60 * 24, // 24 hours - name: 'hourSnapshot', - nextEraUpdateInterval: 1000 * 60 * 60, // 60 minutes - nextEra: this.daySnapshot, - }); - - this.thirtyMinSnapshot = new HistoryEra(this.user, { - interval: 1000 * 20, // 20 seconds - maxTime: 1000 * 60 * 30, // 30 minutes - name: 'thirtyMinSnapshot', - nextEraUpdateInterval: 1000 * 60 * 15, // 15 minutes - nextEra: this.hourSnapshot, - }); - - this.fiveMinSnapshot = new HistoryEra(this.user, { - interval: 1000 * 5, // 5 seconds - maxTime: 1000 * 60 * 5, // 5 minutes - name: 'fiveMinSnapshot', - nextEraUpdateInterval: 1000 * 20, // 20 seconds - nextEra: this.thirtyMinSnapshot, - }); - - this.fetchCurrentTransferSummary(); + this.onServicesUpdated = () => { + this.fetchCurrentTransferSummary(); + }; } checkSnapshotDiffs() { - Object.keys(historySnapshotTypes).forEach((snapshotType) => { - this.getHistory({snapshot: historySnapshotTypes[snapshotType]}, (nextSnapshot, error) => { - if (error) { + historySnapshotTypes.forEach((snapshotType: Readonly) => { + this.getHistory({snapshot: snapshotType}, (nextSnapshot, error) => { + if (error || nextSnapshot == null) { return; } - const lastSnapshot = this.lastSnapshots[snapshotType] || {}; - const {timestamps = []} = lastSnapshot; + const lastSnapshot = this.lastSnapshots[snapshotType] || {timestamps: []}; + const {timestamps} = lastSnapshot; + const nextLastTimestamp = timestamps[timestamps.length - 1]; const prevLastTimestamp = nextSnapshot.timestamps[nextSnapshot.timestamps.length - 1]; if (nextLastTimestamp !== prevLastTimestamp) { - this.emit(historyServiceEvents[`${snapshotType}_SNAPSHOT_FULL_UPDATE`], { + this.emit(`${snapshotType}_SNAPSHOT_FULL_UPDATE` as `FIVE_MINUTE_SNAPSHOT_FULL_UPDATE`, { id: nextLastTimestamp, data: nextSnapshot, }); @@ -130,7 +140,9 @@ class HistoryService extends BaseService { } destroy() { - clearTimeout(this.pollTimeout); + if (this.pollTimeout != null) { + clearTimeout(this.pollTimeout); + } } fetchCurrentTransferSummary() { @@ -138,7 +150,7 @@ class HistoryService extends BaseService { clearTimeout(this.pollTimeout); } - this.services.clientGatewayService + this.services?.clientGatewayService .fetchTransferSummary(transferSummaryMethodCallConfig) .then(this.handleFetchTransferSummarySuccess.bind(this)) .catch(this.handleFetchTransferSummaryError.bind(this)); @@ -148,34 +160,36 @@ class HistoryService extends BaseService { return { id: Date.now(), transferSummary: this.transferSummary, - }; + } as const; } - getHistory(opts = {}, callback) { - const historyCallback = processData.bind(this, opts, callback); + getHistory({snapshot}: {snapshot: HistorySnapshot}, callback: (data: TransferHistory | null, error?: Error) => void) { + this.snapshots[snapshot].getData((transferSnapshots, error) => { + if (error || transferSnapshots == null) { + callback(null, error); + return; + } - if (opts.snapshot === historySnapshotTypes.FIVE_MINUTE) { - this.fiveMinSnapshot.getData(opts, historyCallback); - } else if (opts.snapshot === historySnapshotTypes.THIRTY_MINUTE) { - this.thirtyMinSnapshot.getData(opts, historyCallback); - } else if (opts.snapshot === historySnapshotTypes.HOUR) { - this.hourSnapshot.getData(opts, historyCallback); - } else if (opts.snapshot === historySnapshotTypes.DAY) { - this.daySnapshot.getData(opts, historyCallback); - } else if (opts.snapshot === historySnapshotTypes.WEEK) { - this.weekSnapshot.getData(opts, historyCallback); - } else if (opts.snapshot === historySnapshotTypes.MONTH) { - this.monthSnapshot.getData(opts, historyCallback); - } else if (opts.snapshot === historySnapshotTypes.YEAR) { - this.yearSnapshot.getData(opts, historyCallback); - } + callback( + transferSnapshots.reduce( + (history, transferSnapshot) => { + history.download.push(transferSnapshot.download); + history.upload.push(transferSnapshot.upload); + history.timestamps.push(transferSnapshot.timestamp); + + return history; + }, + {upload: [], download: [], timestamps: []} as TransferHistory, + ), + ); + }); } - handleFetchTransferSummarySuccess(nextTransferSummary) { - const summaryDiff = objectUtil.getDiff(this.transferSummary, nextTransferSummary); + handleFetchTransferSummarySuccess(nextTransferSummary: TransferSummary) { + const summaryDiff = objectUtil.getDiff(this.transferSummary, nextTransferSummary) as DiffAction; if (summaryDiff.length > 0) { - this.emit(historyServiceEvents.TRANSFER_SUMMARY_DIFF_CHANGE, { + this.emit('TRANSFER_SUMMARY_DIFF_CHANGE', { diff: summaryDiff, id: Date.now(), }); @@ -183,7 +197,7 @@ class HistoryService extends BaseService { this.errorCount = 0; this.transferSummary = nextTransferSummary; - this.fiveMinSnapshot.addData({ + this.snapshots.FIVE_MINUTE.addData({ upload: nextTransferSummary.upRate, download: nextTransferSummary.downRate, }); @@ -191,21 +205,21 @@ class HistoryService extends BaseService { this.checkSnapshotDiffs(); this.deferFetchTransferSummary(); - this.emit(historyServiceEvents.FETCH_TRANSFER_SUMMARY_SUCCESS); + this.emit('FETCH_TRANSFER_SUMMARY_SUCCESS'); } handleFetchTransferSummaryError() { let nextInterval = config.torrentClientPollInterval || 2000; - // If more than consecutive errors have occurred, then we delay the next - // request. - if (++this.errorCount >= 3) { + // If more than 2 consecutive errors have occurred, then we delay the next request. + this.errorCount += 1; + if (this.errorCount > 2) { nextInterval = Math.max(nextInterval + (this.errorCount * nextInterval) / 4, 1000 * 60); } this.deferFetchTransferSummary(nextInterval); - this.emit(historyServiceEvents.FETCH_TRANSFER_SUMMARY_ERROR); + this.emit('FETCH_TRANSFER_SUMMARY_ERROR'); } } diff --git a/server/services/index.js b/server/services/index.js deleted file mode 100644 index 0c827c24..00000000 --- a/server/services/index.js +++ /dev/null @@ -1,127 +0,0 @@ -import ClientGatewayService from './clientGatewayService'; -import ClientRequestManager from './clientRequestManager'; -import FeedService from './feedService'; -import HistoryService from './historyService'; -import NotificationService from './notificationService'; -import TaxonomyService from './taxonomyService'; -import TorrentService from './torrentService'; - -const clientRequestManagers = new Map(); -const clientGatewayServices = new Map(); -const feedServices = new Map(); -const historyServices = new Map(); -const notificationServices = new Map(); -const taxonomyServices = new Map(); -const torrentServices = new Map(); -const allServiceMaps = [ - clientRequestManagers, - clientGatewayServices, - feedServices, - historyServices, - notificationServices, - taxonomyServices, - torrentServices, -]; - -const getService = ({servicesMap, service: Service, user}) => { - let serviceInstance = servicesMap.get(user._id); - if (!serviceInstance) { - // eslint-disable-next-line no-use-before-define - serviceInstance = new Service(user, getAllServices(user)); - servicesMap.set(user._id, serviceInstance); - } - - return serviceInstance; -}; - -const getClientRequestManager = (user) => - getService({servicesMap: clientRequestManagers, service: ClientRequestManager, user}); - -const getClientGatewayService = (user) => - getService({servicesMap: clientGatewayServices, service: ClientGatewayService, user}); - -const getFeedService = (user) => getService({servicesMap: feedServices, service: FeedService, user}); - -const getHistoryService = (user) => getService({servicesMap: historyServices, service: HistoryService, user}); - -const getNotificationService = (user) => - getService({servicesMap: notificationServices, service: NotificationService, user}); - -const getTaxonomyService = (user) => getService({servicesMap: taxonomyServices, service: TaxonomyService, user}); - -const getTorrentService = (user) => getService({servicesMap: torrentServices, service: TorrentService, user}); - -const bootstrapServicesForUser = (user) => { - getClientRequestManager(user); - getClientGatewayService(user); - getFeedService(user); - getHistoryService(user); - getNotificationService(user); - getTaxonomyService(user); - getTorrentService(user); -}; - -const destroyUserServices = (user) => { - const userId = user._id; - allServiceMaps.forEach((serviceMap) => { - const userService = serviceMap.get(userId); - if (userService != null) { - userService.destroy(); - serviceMap.delete(userId); - } - }); -}; - -const getAllServices = (user) => ({ - get clientRequestManager() { - return getClientRequestManager(user); - }, - - get clientGatewayService() { - return getClientGatewayService(user); - }, - - get feedService() { - return getFeedService(user); - }, - - get historyService() { - return getHistoryService(user); - }, - - get notificationService() { - return getNotificationService(user); - }, - - get taxonomyService() { - return getTaxonomyService(user); - }, - - get torrentService() { - return getTorrentService(user); - }, -}); - -const updateUserServices = (user) => { - const userId = user._id; - allServiceMaps.forEach((serviceMap) => { - const service = serviceMap.get(userId); - if (service != null) { - service.updateUser(user); - serviceMap.delete(userId); - } - }); -}; - -export default { - bootstrapServicesForUser, - destroyUserServices, - getAllServices, - getClientRequestManager, - getClientGatewayService, - getHistoryService, - getNotificationService, - getTaxonomyService, - getTorrentService, - updateUserServices, -}; diff --git a/server/services/index.ts b/server/services/index.ts new file mode 100644 index 00000000..44b30669 --- /dev/null +++ b/server/services/index.ts @@ -0,0 +1,163 @@ +import type {UserInDatabase} from '@shared/types/Auth'; + +import ClientGatewayService from './clientGatewayService'; +import ClientRequestManager from './clientRequestManager'; +import FeedService from './feedService'; +import HistoryService from './historyService'; +import NotificationService from './notificationService'; +import TaxonomyService from './taxonomyService'; +import TorrentService from './torrentService'; + +type Service = + | typeof ClientGatewayService + | typeof ClientRequestManager + | typeof FeedService + | typeof HistoryService + | typeof NotificationService + | typeof TaxonomyService + | typeof TorrentService; + +const serviceInstances: { + clientGatewayServices: Record; + clientRequestManagers: Record; + feedServices: Record; + historyServices: Record; + notificationServices: Record; + taxonomyServices: Record; + torrentServices: Record; +} = { + clientGatewayServices: {}, + clientRequestManagers: {}, + feedServices: {}, + historyServices: {}, + notificationServices: {}, + taxonomyServices: {}, + torrentServices: {}, +}; + +type ServiceMap = keyof typeof serviceInstances; + +const getService = (servicesMap: ServiceMap, Service: S, user: UserInDatabase): InstanceType => { + // if a service instance for user exists, return it + const serviceInstance = serviceInstances[servicesMap][user._id]; + if (serviceInstance != null) { + return serviceInstance as InstanceType; + } + + // otherwise, create a new service instance and return it + const newInstance = new Service(user) as InstanceType; + serviceInstances[servicesMap][user._id] = newInstance; + return newInstance; +}; + +const getClientRequestManager = (user: UserInDatabase) => { + return getService('clientRequestManagers', ClientRequestManager, user); +}; + +const getClientGatewayService = (user: UserInDatabase) => { + return getService('clientGatewayServices', ClientGatewayService, user); +}; + +const getFeedService = (user: UserInDatabase): FeedService => { + return getService('feedServices', FeedService, user); +}; + +const getHistoryService = (user: UserInDatabase): HistoryService => { + return getService('historyServices', HistoryService, user); +}; + +const getNotificationService = (user: UserInDatabase): NotificationService => { + return getService('notificationServices', NotificationService, user); +}; + +const getTaxonomyService = (user: UserInDatabase): TaxonomyService => { + return getService('taxonomyServices', TaxonomyService, user); +}; + +const getTorrentService = (user: UserInDatabase): TorrentService => { + return getService('torrentServices', TorrentService, user); +}; + +const getAllServices = (user: UserInDatabase) => + ({ + get clientRequestManager() { + return getClientRequestManager(user); + }, + + get clientGatewayService() { + return getClientGatewayService(user); + }, + + get feedService() { + return getFeedService(user); + }, + + get historyService() { + return getHistoryService(user); + }, + + get notificationService() { + return getNotificationService(user); + }, + + get taxonomyService() { + return getTaxonomyService(user); + }, + + get torrentService() { + return getTorrentService(user); + }, + } as const); + +const createUserServices = (user: UserInDatabase): boolean => { + return !Object.values(getAllServices(user)).some((service) => { + if (service == null) { + return true; + } + return false; + }); +}; + +const destroyUserServices = (user: UserInDatabase) => { + const userId = user._id; + Object.keys(serviceInstances).forEach((key) => { + const serviceMap = key as keyof typeof serviceInstances; + const userService = serviceInstances[serviceMap][userId]; + if (userService != null) { + delete serviceInstances[serviceMap][userId]; + userService.destroy(); + } + }); +}; + +const linkUserServices = (user: UserInDatabase) => { + Object.keys(serviceInstances).forEach((key) => { + const serviceMap = key as ServiceMap; + const service = serviceInstances[serviceMap][user._id]; + if (service != null) { + service.updateServices(getAllServices(user)); + } + }); +}; + +const bootstrapServicesForUser = (user: UserInDatabase) => { + if (createUserServices(user) === false) { + console.error(`Failed to initialize services for user ${user.username}`); + return; + } + linkUserServices(user); +}; + +export type UserServices = ReturnType; + +export default { + bootstrapServicesForUser, + destroyUserServices, + getAllServices, + getClientRequestManager, + getClientGatewayService, + getHistoryService, + getNotificationService, + getTaxonomyService, + getTorrentService, +}; diff --git a/server/services/notificationService.js b/server/services/notificationService.ts similarity index 55% rename from server/services/notificationService.js rename to server/services/notificationService.ts index 0dcc0967..41b61f40 100644 --- a/server/services/notificationService.js +++ b/server/services/notificationService.ts @@ -1,31 +1,33 @@ -import castArray from 'lodash/castArray'; import Datastore from 'nedb'; import debounce from 'lodash/debounce'; import path from 'path'; +import type {Notification, NotificationCount, NotificationFetchOptions} from '@shared/types/Notification'; + import BaseService from './BaseService'; import config from '../../config'; -import notificationServiceEvents from '../constants/notificationServiceEvents'; + +interface NotificationServiceEvents { + NOTIFICATION_COUNT_CHANGE: (payload: {id: number; data: NotificationCount}) => void; +} const DEFAULT_QUERY_LIMIT = 20; -const INITIAL_COUNT_VALUE = {read: 0, total: 0, unread: 0}; -class NotificationService extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); +class NotificationService extends BaseService { + count: NotificationCount = {read: 0, total: 0, unread: 0}; + db = this.loadDatabase(); - this.count = {...INITIAL_COUNT_VALUE}; - this.ready = false; - - this.db = this.loadDatabase(); + constructor(...args: ConstructorParameters) { + super(...args); this.emitUpdate = debounce(this.emitUpdate.bind(this), 100); - this.countNotifications(); + + this.onServicesUpdated = () => { + this.countNotifications(); + }; } - addNotification(notifications) { - notifications = castArray(notifications); - + addNotification(notifications: Array>) { this.count.total += notifications.length; this.count.unread += notifications.length; @@ -40,14 +42,14 @@ class NotificationService extends BaseService { this.db.insert(notificationsToInsert, () => this.emitUpdate()); } - clearNotifications(options, callback) { + clearNotifications(callback: (data?: null, err?: Error) => void) { this.db.remove({}, {multi: true}, (err) => { if (err) { callback(null, err); return; } - this.count = {...INITIAL_COUNT_VALUE}; + this.count = {read: 0, total: 0, unread: 0}; callback(); }); @@ -56,18 +58,18 @@ class NotificationService extends BaseService { } countNotifications() { - this.db.find({}, (err, docs) => { + this.db.find({}, (err: Error, notifications: Array) => { if (err) { - this.count = {...INITIAL_COUNT_VALUE}; + this.count = {read: 0, total: 0, unread: 0}; } else { - docs.forEach((notification) => { + notifications.forEach((notification) => { if (notification.read) { - this.count.read++; + this.count.read += 1; } else { - this.count.unread++; + this.count.unread += 1; } - this.count.total++; + this.count.total += 1; }); } @@ -76,7 +78,7 @@ class NotificationService extends BaseService { } emitUpdate() { - this.emit(notificationServiceEvents.NOTIFICATION_COUNT_CHANGE, { + this.emit('NOTIFICATION_COUNT_CHANGE', { id: Date.now(), data: this.count, }); @@ -86,15 +88,18 @@ class NotificationService extends BaseService { return this.count; } - getNotifications(query, callback) { + getNotifications( + query: NotificationFetchOptions, + callback: (data: {notifications: Notification[][]; count: NotificationCount} | null, err?: Error) => void, + ) { const sortedNotifications = this.db.find({}).sort({ts: -1}); - const queryCallback = (err, docs) => { + const queryCallback = (err: Error | null, notifications: Notification[][]) => { if (err) { callback(null, err); return; } - callback({notifications: docs, count: this.count}); + callback({notifications, count: this.count}); }; if (query.allNotifications) { @@ -109,16 +114,16 @@ class NotificationService extends BaseService { } } - loadDatabase() { - if (this.ready) return; + loadDatabase(): Datastore> { + if (this.db != null) { + return this.db; + } const db = new Datastore({ autoload: true, filename: path.join(config.dbPath, this.user._id, 'notifications.db'), }); - this.ready = true; - return db; } } diff --git a/server/services/taxonomyService.js b/server/services/taxonomyService.js deleted file mode 100644 index 30e94296..00000000 --- a/server/services/taxonomyService.js +++ /dev/null @@ -1,133 +0,0 @@ -import BaseService from './BaseService'; -import clientGatewayServiceEvents from '../constants/clientGatewayServiceEvents'; -import objectUtil from '../../shared/util/objectUtil'; -import taxonomyServiceEvents from '../constants/taxonomyServiceEvents'; -import torrentStatusMap from '../../shared/constants/torrentStatusMap'; - -class TaxonomyService extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); - - this.lastStatusCounts = {all: 0}; - this.lastTagCounts = {all: 0}; - this.lastTrackerCounts = {all: 0}; - - this.statusCounts = {all: 0}; - this.tagCounts = {all: 0}; - this.trackerCounts = {all: 0}; - - this.handleProcessTorrent = this.handleProcessTorrent.bind(this); - this.handleProcessTorrentListStart = this.handleProcessTorrentListStart.bind(this); - this.handleProcessTorrentListEnd = this.handleProcessTorrentListEnd.bind(this); - - const {clientGatewayService} = this.services; - - clientGatewayService.on(clientGatewayServiceEvents.PROCESS_TORRENT_LIST_START, this.handleProcessTorrentListStart); - - clientGatewayService.on(clientGatewayServiceEvents.PROCESS_TORRENT_LIST_END, this.handleProcessTorrentListEnd); - - clientGatewayService.on(clientGatewayServiceEvents.PROCESS_TORRENT, this.handleProcessTorrent); - } - - destroy() { - const {clientGatewayService} = this.services; - - clientGatewayService.removeListener( - clientGatewayServiceEvents.PROCESS_TORRENT_LIST_START, - this.handleProcessTorrentListStart, - ); - - clientGatewayService.removeListener( - clientGatewayServiceEvents.PROCESS_TORRENT_LIST_END, - this.handleProcessTorrentListEnd, - ); - - clientGatewayService.removeListener(clientGatewayServiceEvents.PROCESS_TORRENT, this.handleProcessTorrent); - } - - getTaxonomy() { - return { - id: Date.now(), - taxonomy: { - statusCounts: this.statusCounts, - tagCounts: this.tagCounts, - trackerCounts: this.trackerCounts, - }, - }; - } - - handleProcessTorrentListStart() { - this.lastStatusCounts = {...this.statusCounts}; - this.lastTagCounts = {...this.tagCounts}; - this.lastTrackerCounts = {...this.trackerCounts}; - - torrentStatusMap.statusShorthand.forEach((statusShorthand) => { - this.statusCounts[torrentStatusMap[statusShorthand]] = 0; - }); - - this.statusCounts.all = 0; - this.tagCounts = {all: 0}; - this.trackerCounts = {all: 0}; - } - - handleProcessTorrentListEnd(torrentList) { - const {length = 0} = torrentList; - - this.statusCounts.all = length; - this.tagCounts.all = length; - this.trackerCounts.all = length; - - const taxonomyDiffs = { - statusCounts: objectUtil.getDiff(this.lastStatusCounts, this.statusCounts), - tagCounts: objectUtil.getDiff(this.lastTagCounts, this.tagCounts), - trackerCounts: objectUtil.getDiff(this.lastTrackerCounts, this.trackerCounts), - }; - - const didDiffChange = Object.keys(taxonomyDiffs).some((diffKey) => taxonomyDiffs[diffKey].length > 0); - - if (didDiffChange) { - this.emit(taxonomyServiceEvents.TAXONOMY_DIFF_CHANGE, { - diff: taxonomyDiffs, - id: Date.now(), - }); - } - } - - handleProcessTorrent(torrentDetails) { - this.incrementStatusCounts(torrentDetails.status); - this.incrementTagCounts(torrentDetails.tags); - this.incrementTrackerCounts(torrentDetails.trackerURIs); - } - - incrementStatusCounts(statuses) { - statuses.forEach((status) => { - this.statusCounts[torrentStatusMap[status]]++; - }); - } - - incrementTagCounts(tags) { - if (tags.length === 0) { - tags = ['untagged']; - } - - tags.forEach((tag) => { - if (this.tagCounts[tag] != null) { - this.tagCounts[tag]++; - } else { - this.tagCounts[tag] = 1; - } - }); - } - - incrementTrackerCounts(trackers) { - trackers.forEach((tracker) => { - if (this.trackerCounts[tracker] != null) { - this.trackerCounts[tracker]++; - } else { - this.trackerCounts[tracker] = 1; - } - }); - } -} - -export default TaxonomyService; diff --git a/server/services/taxonomyService.ts b/server/services/taxonomyService.ts new file mode 100644 index 00000000..3940197b --- /dev/null +++ b/server/services/taxonomyService.ts @@ -0,0 +1,143 @@ +import BaseService from './BaseService'; +import objectUtil from '../../shared/util/objectUtil'; +import torrentStatusMap from '../../shared/constants/torrentStatusMap'; + +import type {Taxonomy, TaxonomyDiffs} from '../../shared/types/Taxonomy'; +import type {TorrentStatus} from '../../shared/constants/torrentStatusMap'; +import type {TorrentProperties, Torrents} from '../../shared/types/Torrent'; + +interface TaxonomyServiceEvents { + TAXONOMY_DIFF_CHANGE: (payload: {id: number; diff: TaxonomyDiffs}) => void; +} + +class TaxonomyService extends BaseService { + taxonomy: Taxonomy = { + statusCounts: {all: 0}, + tagCounts: {all: 0, untagged: 0}, + trackerCounts: {all: 0}, + }; + + lastTaxonomy: Taxonomy = this.taxonomy; + + constructor(...args: ConstructorParameters) { + super(...args); + + this.handleProcessTorrent = this.handleProcessTorrent.bind(this); + this.handleProcessTorrentListStart = this.handleProcessTorrentListStart.bind(this); + this.handleProcessTorrentListEnd = this.handleProcessTorrentListEnd.bind(this); + + this.onServicesUpdated = () => { + if (this.services == null || this.services.clientGatewayService == null) { + return; + } + + const {clientGatewayService} = this.services; + + clientGatewayService.on('PROCESS_TORRENT_LIST_START', this.handleProcessTorrentListStart); + clientGatewayService.on('PROCESS_TORRENT_LIST_END', this.handleProcessTorrentListEnd); + clientGatewayService.on('PROCESS_TORRENT', this.handleProcessTorrent); + }; + } + + destroy() { + if (this.services == null || this.services.clientGatewayService == null) { + return; + } + + const {clientGatewayService} = this.services; + + clientGatewayService.removeListener('PROCESS_TORRENT_LIST_START', this.handleProcessTorrentListStart); + clientGatewayService.removeListener('PROCESS_TORRENT_LIST_END', this.handleProcessTorrentListEnd); + clientGatewayService.removeListener('PROCESS_TORRENT', this.handleProcessTorrent); + } + + getTaxonomy() { + return { + id: Date.now(), + taxonomy: this.taxonomy, + }; + } + + handleProcessTorrentListStart() { + this.lastTaxonomy = { + statusCounts: {...this.taxonomy.statusCounts}, + tagCounts: {...this.taxonomy.tagCounts}, + trackerCounts: {...this.taxonomy.trackerCounts}, + }; + + torrentStatusMap.forEach((status) => { + this.taxonomy.statusCounts[status] = 0; + }); + + this.taxonomy.statusCounts.all = 0; + this.taxonomy.tagCounts = {all: 0, untagged: 0}; + this.taxonomy.trackerCounts = {all: 0}; + } + + handleProcessTorrentListEnd({torrents}: {torrents: Torrents}) { + const {length} = Object.keys(torrents); + + this.taxonomy.statusCounts.all = length; + this.taxonomy.tagCounts.all = length; + this.taxonomy.trackerCounts.all = length; + + let didDiffChange = false; + const taxonomyDiffs = Object.keys(this.taxonomy).reduce((accumulator, key) => { + const countType = key as keyof Taxonomy; + const countDiff = objectUtil.getDiff(this.lastTaxonomy[countType], this.taxonomy[countType]); + + if (countDiff.length > 0) { + didDiffChange = true; + } + + return Object.assign(accumulator, { + [countType]: countDiff, + }); + }, {} as TaxonomyDiffs); + + if (didDiffChange) { + this.emit('TAXONOMY_DIFF_CHANGE', { + diff: taxonomyDiffs, + id: Date.now(), + }); + } + } + + handleProcessTorrent(torrentProperties: TorrentProperties) { + this.incrementStatusCounts(torrentProperties.status); + this.incrementTagCounts(torrentProperties.tags); + this.incrementTrackerCounts(torrentProperties.trackerURIs); + } + + incrementStatusCounts(statuses: Array) { + statuses.forEach((status) => { + this.taxonomy.statusCounts[status] += 1; + }); + } + + incrementTagCounts(tags: TorrentProperties['tags']) { + if (tags.length === 0) { + this.taxonomy.tagCounts.untagged += 1; + } + + tags.forEach((tag) => { + if (this.taxonomy.tagCounts[tag] != null) { + this.taxonomy.tagCounts[tag] += 1; + } else { + this.taxonomy.tagCounts[tag] = 1; + } + }); + } + + incrementTrackerCounts(trackers: TorrentProperties['trackerURIs']) { + trackers.forEach((tracker) => { + if (this.taxonomy.trackerCounts[tracker] != null) { + this.taxonomy.trackerCounts[tracker] += 1; + } else { + this.taxonomy.trackerCounts[tracker] = 1; + } + }); + } +} + +export default TaxonomyService; diff --git a/server/services/torrentService.js b/server/services/torrentService.js deleted file mode 100644 index 1cdc4973..00000000 --- a/server/services/torrentService.js +++ /dev/null @@ -1,283 +0,0 @@ -import deepEqual from 'deep-equal'; -import BaseService from './BaseService'; -import clientGatewayServiceEvents from '../constants/clientGatewayServiceEvents'; -import config from '../../config'; -import formatUtil from '../../shared/util/formatUtil'; -import methodCallUtil from '../util/methodCallUtil'; -import serverEventTypes from '../../shared/constants/serverEventTypes'; -import truncateTo from '../util/numberUtils'; -import torrentListPropMap from '../constants/torrentListPropMap'; -import torrentServiceEvents from '../constants/torrentServiceEvents'; -import torrentStatusMap from '../../shared/constants/torrentStatusMap'; - -const torrentListMethodCallConfig = methodCallUtil.getMethodCallConfigFromPropMap(torrentListPropMap); - -const getTorrentETAFromDetails = (torrentDetails) => { - const {downRate, bytesDone, sizeBytes} = torrentDetails; - - if (downRate > 0) { - return formatUtil.secondsToDuration((sizeBytes - bytesDone) / downRate); - } - - return Infinity; -}; - -const getTorrentPercentCompleteFromDetails = (torrentDetails) => { - const percentComplete = (torrentDetails.bytesDone / torrentDetails.sizeBytes) * 100; - - if (percentComplete > 0 && percentComplete < 10) { - return Number(truncateTo(percentComplete, 2)); - } - if (percentComplete > 10 && percentComplete < 100) { - return Number(truncateTo(percentComplete, 1)); - } - - return percentComplete; -}; - -const getTorrentStatusFromDetails = (torrentDetails) => { - const {isHashing, isComplete, isOpen, upRate, downRate, state, message} = torrentDetails; - - const torrentStatus = []; - - if (isHashing !== '0') { - torrentStatus.push(torrentStatusMap.checking); - } else if (isComplete && isOpen && state === '1') { - torrentStatus.push(torrentStatusMap.complete); - torrentStatus.push(torrentStatusMap.seeding); - } else if (isComplete && isOpen && state === '0') { - torrentStatus.push(torrentStatusMap.stopped); - } else if (isComplete && !isOpen) { - torrentStatus.push(torrentStatusMap.stopped); - torrentStatus.push(torrentStatusMap.complete); - } else if (!isComplete && isOpen && state === '1') { - torrentStatus.push(torrentStatusMap.downloading); - } else if (!isComplete && isOpen && state === '0') { - torrentStatus.push(torrentStatusMap.stopped); - } else if (!isComplete && !isOpen) { - torrentStatus.push(torrentStatusMap.stopped); - } - - if (message.length) { - torrentStatus.push(torrentStatusMap.error); - } - - if (upRate !== 0) { - torrentStatus.push(torrentStatusMap.activelyUploading); - } - - if (downRate !== 0) { - torrentStatus.push(torrentStatusMap.activelyDownloading); - } - - if (upRate !== 0 || downRate !== 0) { - torrentStatus.push(torrentStatusMap.active); - } else { - torrentStatus.push(torrentStatusMap.inactive); - } - - return torrentStatus; -}; - -const hasTorrentFinished = (prevData = {}, nextData = {}) => { - const {status = []} = prevData; - - return ( - !status.includes(torrentStatusMap.checking) && prevData.percentComplete < 100 && nextData.percentComplete === 100 - ); -}; - -class TorrentService extends BaseService { - constructor(...serviceConfig) { - super(...serviceConfig); - - this.errorCount = 0; - this.pollTimeout = null; - this.torrentListSummary = {torrents: {}}; - - this.fetchTorrentList = this.fetchTorrentList.bind(this); - this.handleTorrentProcessed = this.handleTorrentProcessed.bind(this); - this.handleTorrentsRemoved = this.handleTorrentsRemoved.bind(this); - this.handleFetchTorrentListSuccess = this.handleFetchTorrentListSuccess.bind(this); - this.handleFetchTorrentListError = this.handleFetchTorrentListError.bind(this); - - const {clientGatewayService} = this.services; - - clientGatewayService.addTorrentListReducer({ - key: 'status', - reduce: getTorrentStatusFromDetails, - }); - - clientGatewayService.addTorrentListReducer({ - key: 'percentComplete', - reduce: getTorrentPercentCompleteFromDetails, - }); - - clientGatewayService.addTorrentListReducer({ - key: 'eta', - reduce: getTorrentETAFromDetails, - }); - - clientGatewayService.on(clientGatewayServiceEvents.PROCESS_TORRENT, this.handleTorrentProcessed); - - clientGatewayService.on(clientGatewayServiceEvents.TORRENTS_REMOVED, this.handleTorrentsRemoved); - - this.fetchTorrentList(); - } - - assignDeletedTorrentsToDiff(diff, nextTorrentListSummary, options = {}) { - const {newTorrentCount = 0} = options; - - // We need to look for deleted torrents in two scenarios: - // 1. the next list length is less than than the current length - // 2. at least one new torrent was added and the next list length is - // equal to or greater than the current list length. - // - // We definitely don't need to look for deleted torrents if the number - // of new torrents is equal to the difference between next torrent list - // length and previous torrent list length. - let shouldLookForDeletedTorrents = nextTorrentListSummary.length < this.torrentListSummary.length; - - if (newTorrentCount > 0) { - if (nextTorrentListSummary.length >= this.torrentListSummary.length) { - shouldLookForDeletedTorrents = true; - } - - if (newTorrentCount === nextTorrentListSummary.length - this.torrentListSummary.length) { - shouldLookForDeletedTorrents = false; - } - } - - if (shouldLookForDeletedTorrents) { - Object.keys(this.torrentListSummary.torrents).forEach((hash) => { - if (nextTorrentListSummary.torrents[hash] == null) { - diff[hash] = { - action: serverEventTypes.TORRENT_LIST_ACTION_TORRENT_DELETED, - }; - } - }, {}); - } - } - - deferFetchTorrentList(interval = config.torrentClientPollInterval || 2000) { - this.pollTimeout = setTimeout(this.fetchTorrentList, interval); - } - - destroy() { - clearTimeout(this.pollTimeout); - } - - fetchTorrentList() { - if (this.pollTimeout != null) { - clearTimeout(this.pollTimeout); - } - - return this.services.clientGatewayService - .fetchTorrentList(torrentListMethodCallConfig) - .then(this.handleFetchTorrentListSuccess) - .catch(this.handleFetchTorrentListError); - } - - getTorrent(hash) { - return this.torrentListSummary.torrents[hash]; - } - - getTorrentList() { - return this.torrentListSummary; - } - - getTorrentListDiff(nextTorrentListSummary) { - let newTorrentCount = 0; - - // Get the diff... - const diff = Object.keys(nextTorrentListSummary.torrents).reduce((accumulator, hash) => { - const currentTorrentDetails = this.torrentListSummary.torrents[hash]; - const nextTorrentDetails = nextTorrentListSummary.torrents[hash]; - - // If the current torrent list doesn't contain any details for this - // hash, then it's a brand new torrent, so every detail is part of the - // diff. - if (currentTorrentDetails == null) { - accumulator[hash] = { - action: serverEventTypes.TORRENT_LIST_ACTION_TORRENT_ADDED, - data: nextTorrentDetails, - }; - - // Track the number of new torrents added. - newTorrentCount++; - } else { - Object.keys(nextTorrentDetails).forEach((propKey) => { - // If one of the details is inequal, we need to add it to the diff. - if (!deepEqual(currentTorrentDetails[propKey], nextTorrentDetails[propKey])) { - // Initialize with an empty object when this is the first known - // inequal property. - if (accumulator[hash] == null) { - accumulator[hash] = { - action: serverEventTypes.TORRENT_LIST_ACTION_TORRENT_DETAIL_UPDATED, - data: {}, - }; - } - - // Add the diff details. - accumulator[hash].data[propKey] = nextTorrentDetails[propKey]; - } - }); - } - - return accumulator; - }, {}); - - this.assignDeletedTorrentsToDiff(diff, nextTorrentListSummary, {newTorrentCount}); - - return diff; - } - - handleFetchTorrentListError() { - let nextInterval = config.torrentClientPollInterval || 2000; - - // If more than consecutive errors have occurred, then we delay the next - // request. - if (++this.errorCount >= 3) { - nextInterval = Math.min(nextInterval + 2 ** this.errorCount, 1000 * 60); - } - - this.deferFetchTorrentList(nextInterval); - - this.emit(torrentServiceEvents.FETCH_TORRENT_LIST_ERROR); - } - - getTorrentListSummary() { - return this.torrentListSummary; - } - - handleFetchTorrentListSuccess(nextTorrentListSummary) { - const diff = this.getTorrentListDiff(nextTorrentListSummary); - if (Object.keys(diff).length > 0) { - this.emit(torrentServiceEvents.TORRENT_LIST_DIFF_CHANGE, {diff, id: nextTorrentListSummary.id}); - } - - this.torrentListSummary = nextTorrentListSummary; - - this.deferFetchTorrentList(); - - this.errorCount = 0; - this.emit(torrentServiceEvents.FETCH_TORRENT_LIST_SUCCESS); - } - - handleTorrentProcessed(nextTorrentDetails) { - const prevTorrentDetails = this.torrentListSummary.torrents[nextTorrentDetails.hash]; - - if (hasTorrentFinished(prevTorrentDetails, nextTorrentDetails)) { - this.services.notificationService.addNotification({ - id: 'notification.torrent.finished', - data: {name: nextTorrentDetails.name}, - }); - } - } - - handleTorrentsRemoved() { - this.fetchTorrentList(); - } -} - -export default TorrentService; diff --git a/server/services/torrentService.ts b/server/services/torrentService.ts new file mode 100644 index 00000000..cb1f1424 --- /dev/null +++ b/server/services/torrentService.ts @@ -0,0 +1,246 @@ +import deepEqual from 'deep-equal'; + +import type {TorrentProperties, TorrentListDiff, Torrents} from '@shared/types/Torrent'; + +import BaseService from './BaseService'; +import config from '../../config'; +import methodCallUtil from '../util/methodCallUtil'; +import torrentListPropMap from '../constants/torrentListPropMap'; + +import { + getTorrentETAFromProperties, + getTorrentPercentCompleteFromProperties, + getTorrentStatusFromProperties, + hasTorrentFinished, +} from '../util/torrentPropertiesUtil'; + +interface TorrentServiceEvents { + FETCH_TORRENT_LIST_SUCCESS: () => void; + FETCH_TORRENT_LIST_ERROR: () => void; + TORRENT_LIST_DIFF_CHANGE: (payload: {id: number; diff: TorrentListDiff}) => void; +} + +const torrentListMethodCallConfig = methodCallUtil.getMethodCallConfigFromPropMap(torrentListPropMap); + +class TorrentService extends BaseService { + errorCount = 0; + pollTimeout: NodeJS.Timeout | null = null; + torrentListSummary: { + id: number; + torrents: Torrents; + } = {id: Date.now(), torrents: {}}; + + constructor(...args: ConstructorParameters) { + super(...args); + + this.fetchTorrentList = this.fetchTorrentList.bind(this); + this.handleTorrentProcessed = this.handleTorrentProcessed.bind(this); + this.handleTorrentsRemoved = this.handleTorrentsRemoved.bind(this); + this.handleFetchTorrentListSuccess = this.handleFetchTorrentListSuccess.bind(this); + this.handleFetchTorrentListError = this.handleFetchTorrentListError.bind(this); + + this.onServicesUpdated = () => { + if (this.services == null || this.services.clientGatewayService == null) { + return; + } + + const {clientGatewayService} = this.services; + + clientGatewayService.addTorrentListReducer({ + key: 'status', + reduce: getTorrentStatusFromProperties, + }); + + clientGatewayService.addTorrentListReducer({ + key: 'percentComplete', + reduce: getTorrentPercentCompleteFromProperties, + }); + + clientGatewayService.addTorrentListReducer({ + key: 'eta', + reduce: getTorrentETAFromProperties, + }); + + clientGatewayService.on('PROCESS_TORRENT', this.handleTorrentProcessed); + + clientGatewayService.on('TORRENTS_REMOVED', this.handleTorrentsRemoved); + + this.fetchTorrentList(); + }; + } + + assignDeletedTorrentsToDiff( + diff: TorrentListDiff, + nextTorrentListSummary: this['torrentListSummary'], + newTorrentCount: number, + ): TorrentListDiff { + const prevTorrentCount = Object.keys(this.torrentListSummary.torrents).length; + const nextTorrentCount = Object.keys(nextTorrentListSummary.torrents).length; + + // We need to look for deleted torrents in two scenarios: + // 1. the next list length is less than than the current length + // 2. at least one new torrent was added and the next list length is + // equal to or greater than the current list length. + // + // We definitely don't need to look for deleted torrents if the number + // of new torrents is equal to the difference between next torrent list + // length and previous torrent list length. + let shouldLookForDeletedTorrents = nextTorrentCount < prevTorrentCount; + + if (newTorrentCount > 0) { + if (nextTorrentCount >= prevTorrentCount) { + shouldLookForDeletedTorrents = true; + } + + if (newTorrentCount === nextTorrentCount - prevTorrentCount) { + shouldLookForDeletedTorrents = false; + } + } + + let diffWithDeleted = diff; + + if (shouldLookForDeletedTorrents) { + Object.keys(this.torrentListSummary.torrents).forEach((hash) => { + if (nextTorrentListSummary.torrents[hash] == null) { + diffWithDeleted = Object.assign(diffWithDeleted, { + [hash]: { + action: 'TORRENT_LIST_ACTION_TORRENT_DELETED', + }, + }); + } + }, {}); + } + + return diffWithDeleted; + } + + deferFetchTorrentList(interval = config.torrentClientPollInterval || 2000) { + this.pollTimeout = setTimeout(this.fetchTorrentList, interval); + } + + destroy() { + if (this.pollTimeout != null) { + clearTimeout(this.pollTimeout); + } + } + + fetchTorrentList() { + if (this.pollTimeout != null) { + clearTimeout(this.pollTimeout); + } + + this.services?.clientGatewayService + .fetchTorrentList(torrentListMethodCallConfig) + .then(this.handleFetchTorrentListSuccess) + .catch(this.handleFetchTorrentListError); + } + + getTorrent(hash: TorrentProperties['hash']) { + return this.torrentListSummary.torrents[hash]; + } + + getTorrentList() { + return this.torrentListSummary; + } + + getTorrentListDiff(nextTorrentListSummary: this['torrentListSummary']) { + let newTorrentCount = 0; + + // Get the diff... + const diff = Object.keys(nextTorrentListSummary.torrents).reduce((accumulator, hash) => { + const currentTorrentProperties = this.torrentListSummary.torrents[hash]; + const nextTorrentProperties = nextTorrentListSummary.torrents[hash]; + + // If the current torrent list doesn't contain any details for this + // hash, then it's a brand new torrent, so every detail is part of the + // diff. + if (currentTorrentProperties == null) { + accumulator[hash] = { + action: 'TORRENT_LIST_ACTION_TORRENT_ADDED', + data: nextTorrentProperties, + }; + + // Track the number of new torrents added. + newTorrentCount += 1; + } else { + let changed = false; + let changedProperties: Partial = {}; + + Object.keys(nextTorrentProperties).forEach((key) => { + const property = key as keyof TorrentProperties; + // If one of the details is unequal, we need to add it to the diff. + if (!deepEqual(currentTorrentProperties[property], nextTorrentProperties[property])) { + // Add the diff details. + changed = true; + changedProperties = { + ...changedProperties, + [property]: nextTorrentProperties[property], + }; + } + }); + + if (changed) { + accumulator[hash] = { + action: 'TORRENT_LIST_ACTION_TORRENT_DETAIL_UPDATED', + data: changedProperties, + }; + } + } + + return accumulator; + }, {} as TorrentListDiff); + + return this.assignDeletedTorrentsToDiff(diff, nextTorrentListSummary, newTorrentCount); + } + + getTorrentListSummary() { + return this.torrentListSummary; + } + + handleFetchTorrentListError() { + let nextInterval = config.torrentClientPollInterval || 2000; + + // If more than 2 consecutive errors have occurred, then we delay the next request. + this.errorCount += 1; + if (this.errorCount > 2) { + nextInterval = Math.min(nextInterval + 2 ** this.errorCount, 1000 * 60); + } + + this.deferFetchTorrentList(nextInterval); + + this.emit('FETCH_TORRENT_LIST_ERROR'); + } + + handleFetchTorrentListSuccess(nextTorrentListSummary: this['torrentListSummary']) { + const diff = this.getTorrentListDiff(nextTorrentListSummary); + if (Object.keys(diff).length > 0) { + this.emit('TORRENT_LIST_DIFF_CHANGE', {diff, id: nextTorrentListSummary.id}); + } + + this.torrentListSummary = nextTorrentListSummary; + + this.deferFetchTorrentList(); + + this.errorCount = 0; + this.emit('FETCH_TORRENT_LIST_SUCCESS'); + } + + handleTorrentProcessed(nextTorrentProperties: TorrentProperties) { + const prevTorrentProperties = this.torrentListSummary.torrents[nextTorrentProperties.hash]; + + if (hasTorrentFinished(prevTorrentProperties, nextTorrentProperties)) { + this.services?.notificationService.addNotification([ + { + id: 'notification.torrent.finished', + data: {name: nextTorrentProperties.name}, + }, + ]); + } + } + + handleTorrentsRemoved() { + this.fetchTorrentList(); + } +} + +export default TorrentService; diff --git a/server/util/ajaxUtil.ts b/server/util/ajaxUtil.ts index 038cdcf1..9af05886 100644 --- a/server/util/ajaxUtil.ts +++ b/server/util/ajaxUtil.ts @@ -1,7 +1,7 @@ import type {Response} from 'express'; const ajaxUtil = { - getResponseFn: (res: Response) => (data: D, error: Error | string) => { + getResponseFn: (res: Response) => (data: D, error?: Error | string) => { if (error) { if (process.env.NODE_ENV === 'development') { console.trace(error); diff --git a/server/services/diskUsageService.js b/server/util/diskUsage.ts similarity index 53% rename from server/services/diskUsageService.js rename to server/util/diskUsage.ts index 2bfe1ca6..6df70c5c 100644 --- a/server/services/diskUsageService.js +++ b/server/util/diskUsage.ts @@ -1,26 +1,27 @@ -/** - * This service is not per rTorrent session, which is why it does not inherit - * `BaseService` nor have any use of the per user API ie. `getService()` - */ -import EventEmitter from 'events'; import {execFile} from 'child_process'; import util from 'util'; + +import type {Disk} from '@shared/types/DiskUsage'; + import config from '../../config'; -import diskUsageServiceEvents from '../constants/diskUsageServiceEvents'; const execFileAsync = util.promisify(execFile); -const PLATFORMS_SUPPORTED = ['darwin', 'linux', 'freebsd', 'win32']; -const MAX_BUFFER_SIZE = 65536; +const PLATFORMS_SUPPORTED = ['darwin', 'linux', 'freebsd', 'win32'] as const; +export type SupportedPlatform = Extract; + +export const isPlatformSupported = (): boolean => { + return PLATFORMS_SUPPORTED.includes(process.platform as SupportedPlatform); +}; const filterMountPoint = config.diskUsageService && config.diskUsageService.watchMountPoints - ? // if user has configured watchPartitions filter each line output for given - // array - (mountpoint) => config.diskUsageService.watchMountPoints.includes(mountpoint) + ? // if user has configured watchMountPoints, filter each line output for given array + (mountpoint: string) => config.diskUsageService.watchMountPoints.includes(mountpoint) : () => true; // include all mounted file systems by default -const diskUsage = { +const MAX_BUFFER_SIZE = 65536; +export const diskUsage: Readonly Promise>>> = { linux: () => execFileAsync('df -xsquashfs -xtmpfs -xdevtmpfs | tail -n+2', { shell: true, @@ -90,68 +91,10 @@ const diskUsage = { .map((disk) => disk.split(/\s+/)) .filter((disk) => filterMountPoint(disk[1])) .map((disk) => ({ - size: disk[14], - used: disk[14] - disk[10], - avail: disk[10], + size: Number(disk[14]), + used: Number(disk[14]) - Number(disk[10]), + avail: Number(disk[10]), target: disk[1], })), ), }; - -const INTERVAL_UPDATE = 10000; - -class DiskUsageService extends EventEmitter { - constructor() { - super(); - this.disks = []; - this.tLastChange = 0; - this.interval = 0; - - if (!PLATFORMS_SUPPORTED.includes(process.platform)) { - console.log(`warning: DiskUsageService is only supported in ${PLATFORMS_SUPPORTED.join()}`); - return; - } - - // start polling disk usage when the first listener is added - this.on('newListener', (event) => { - if ( - this.listenerCount(diskUsageServiceEvents.DISK_USAGE_CHANGE) === 0 && - event === diskUsageServiceEvents.DISK_USAGE_CHANGE - ) { - this.updateInterval = setInterval(this.updateDisks.bind(this), INTERVAL_UPDATE); - } - }); - - // stop polling disk usage when the last listener is removed - this.on('removeListener', (event) => { - if ( - this.listenerCount(diskUsageServiceEvents.DISK_USAGE_CHANGE) === 0 && - event === diskUsageServiceEvents.DISK_USAGE_CHANGE - ) { - clearInterval(this.updateInterval); - } - }); - } - - updateDisks() { - if (!PLATFORMS_SUPPORTED.includes(process.platform)) { - return Promise.resolve([]); - } - return diskUsage[process.platform]().then((disks) => { - if (disks.length !== this.disks.length || disks.some((d, i) => d.used !== this.disks[i].used)) { - this.tLastChange = Date.now(); - this.disks = disks; - this.emit(diskUsageServiceEvents.DISK_USAGE_CHANGE, this.getDiskUsage()); - } - }); - } - - getDiskUsage() { - return { - id: this.tLastChange, - disks: this.disks, - }; - } -} - -export default new DiskUsageService(); diff --git a/server/util/methodCallUtil.js b/server/util/methodCallUtil.js index db67bc5b..e4568d81 100644 --- a/server/util/methodCallUtil.js +++ b/server/util/methodCallUtil.js @@ -1,5 +1,5 @@ const methodCallUtil = { - getMethodCallConfigFromPropMap(map = new Map(), requestedKeys) { + getMethodCallConfigFromPropMap(map, requestedKeys) { let desiredKeys = Array.from(map.keys()); if (requestedKeys != null) { diff --git a/server/util/minifyUtil.js b/server/util/minifyUtil.js deleted file mode 100644 index e69de29b..00000000 diff --git a/server/util/torrentPropertiesUtil.ts b/server/util/torrentPropertiesUtil.ts new file mode 100644 index 00000000..56f368c9 --- /dev/null +++ b/server/util/torrentPropertiesUtil.ts @@ -0,0 +1,91 @@ +import formatUtil from '../../shared/util/formatUtil'; +import truncateTo from './numberUtils'; + +import type {TorrentProperties} from '../../shared/types/Torrent'; +import type {TorrentStatus} from '../../shared/constants/torrentStatusMap'; + +export const getTorrentETAFromProperties = (torrentProperties: TorrentProperties) => { + const {downRate, bytesDone, sizeBytes} = torrentProperties; + + if (downRate > 0) { + return formatUtil.secondsToDuration((sizeBytes - bytesDone) / downRate); + } + + return Infinity; +}; + +export const getTorrentPercentCompleteFromProperties = (torrentProperties: TorrentProperties) => { + const percentComplete = (torrentProperties.bytesDone / torrentProperties.sizeBytes) * 100; + + if (percentComplete > 0 && percentComplete < 10) { + return Number(truncateTo(percentComplete, 2)); + } + if (percentComplete > 10 && percentComplete < 100) { + return Number(truncateTo(percentComplete, 1)); + } + + return percentComplete; +}; + +export const getTorrentStatusFromProperties = (torrentProperties: TorrentProperties) => { + const {isHashing, isComplete, isOpen, upRate, downRate, state, message} = torrentProperties; + + const torrentStatus: Array = []; + + if (isHashing !== '0') { + torrentStatus.push('checking'); + } else if (isComplete && isOpen && state === '1') { + torrentStatus.push('complete'); + torrentStatus.push('seeding'); + } else if (isComplete && isOpen && state === '0') { + torrentStatus.push('stopped'); + } else if (isComplete && !isOpen) { + torrentStatus.push('stopped'); + torrentStatus.push('complete'); + } else if (!isComplete && isOpen && state === '1') { + torrentStatus.push('downloading'); + } else if (!isComplete && isOpen && state === '0') { + torrentStatus.push('stopped'); + } else if (!isComplete && !isOpen) { + torrentStatus.push('stopped'); + } + + if (message.length) { + torrentStatus.push('error'); + } + + if (upRate !== 0) { + torrentStatus.push('activelyUploading'); + } + + if (downRate !== 0) { + torrentStatus.push('activelyDownloading'); + } + + if (upRate !== 0 || downRate !== 0) { + torrentStatus.push('active'); + } else { + torrentStatus.push('inactive'); + } + + return torrentStatus; +}; + +export const hasTorrentFinished = ( + prevData: Partial = {}, + nextData: Partial = {}, +) => { + if (prevData.status != null && prevData.status.includes('checking')) { + return false; + } + + if (prevData.percentComplete == null || nextData.percentComplete == null) { + return false; + } + + if (prevData.percentComplete < 100 && nextData.percentComplete === 100) { + return true; + } + + return false; +}; diff --git a/shared/constants/clientSettingsMap.js b/shared/constants/clientSettingsMap.ts similarity index 83% rename from shared/constants/clientSettingsMap.js rename to shared/constants/clientSettingsMap.ts index 09732ef8..33fa7e0a 100644 --- a/shared/constants/clientSettingsMap.js +++ b/shared/constants/clientSettingsMap.ts @@ -1,6 +1,6 @@ -const objectUtil = require('../util/objectUtil'); +import objectUtil from '../util/objectUtil'; -const clientSettings = { +export const clientSettingsMap = { dht: 'dht.mode', dhtPort: 'dht.port', dhtStats: 'dht.statistics', @@ -48,8 +48,13 @@ const clientSettings = { throttleMinPeersSeed: 'throttle.min_peers.seed', trackersNumWant: 'trackers.numwant', trackersUseUdp: 'trackers.use_udp', +} as const; + +// TODO: Is this bidirectional map really necessary? +export const clientSettingsBiMap = objectUtil.reflect(clientSettingsMap); + +export type ClientSetting = keyof typeof clientSettingsMap; +export type ClientSettings = { + // TODO: Need proper types for each property + [property in ClientSetting]?: string | Record | null; }; - -const clientSettingsMap = objectUtil.reflect(clientSettings); - -module.exports = {clientSettings, clientSettingsMap}; diff --git a/shared/constants/diffActionTypes.js b/shared/constants/diffActionTypes.js deleted file mode 100644 index 4c5efd57..00000000 --- a/shared/constants/diffActionTypes.js +++ /dev/null @@ -1,7 +0,0 @@ -const diffActionTypes = ['ITEM_ADDED', 'ITEM_CHANGED', 'ITEM_REMOVED']; - -module.exports = diffActionTypes.reduce((memo, key) => { - memo[key] = key; - - return memo; -}, {}); diff --git a/shared/constants/diffActionTypes.ts b/shared/constants/diffActionTypes.ts new file mode 100644 index 00000000..aa354730 --- /dev/null +++ b/shared/constants/diffActionTypes.ts @@ -0,0 +1,18 @@ +import objectUtil from '../util/objectUtil'; + +const diffActionTypes = ['ITEM_ADDED', 'ITEM_CHANGED', 'ITEM_REMOVED'] as const; + +export default objectUtil.createStringMapFromArray(diffActionTypes); + +export type DiffActionType = typeof diffActionTypes[number]; + +export type DiffAction = Array< + | { + action: Exclude; + data: T; + } + | { + action: 'ITEM_REMOVED'; + data: keyof T; + } +>; diff --git a/shared/constants/historySnapshotTypes.js b/shared/constants/historySnapshotTypes.js deleted file mode 100644 index 88de58ce..00000000 --- a/shared/constants/historySnapshotTypes.js +++ /dev/null @@ -1,12 +0,0 @@ -const objectUtil = require('../util/objectUtil'); - -const historySnapshotTypes = { - FIVE_MINUTE: 'fiveMin', - THIRTY_MINUTE: 'thirtyMin', - HOUR: 'hour', - WEEK: 'week', - MONTH: 'month', - YEAR: 'year', -}; - -module.exports = objectUtil.reflect(historySnapshotTypes); diff --git a/shared/constants/historySnapshotTypes.ts b/shared/constants/historySnapshotTypes.ts new file mode 100644 index 00000000..89f0f0db --- /dev/null +++ b/shared/constants/historySnapshotTypes.ts @@ -0,0 +1,4 @@ +const historySnapshotTypes = ['FIVE_MINUTE', 'THIRTY_MINUTE', 'HOUR', 'DAY', 'WEEK', 'MONTH', 'YEAR'] as const; + +export default historySnapshotTypes; +export type HistorySnapshot = typeof historySnapshotTypes[number]; diff --git a/shared/constants/serverEventTypes.js b/shared/constants/serverEventTypes.js deleted file mode 100644 index 5843b3a3..00000000 --- a/shared/constants/serverEventTypes.js +++ /dev/null @@ -1,19 +0,0 @@ -const objectUtil = require('../util/objectUtil'); - -const serverEventTypes = [ - 'CLIENT_CONNECTIVITY_STATUS_CHANGE', - 'DISK_USAGE_CHANGE', - 'NOTIFICATION_COUNT_CHANGE', - 'TAXONOMY_FULL_UPDATE', - 'TAXONOMY_DIFF_CHANGE', - 'TORRENT_LIST_ACTION_TORRENT_ADDED', - 'TORRENT_LIST_ACTION_TORRENT_DELETED', - 'TORRENT_LIST_ACTION_TORRENT_DETAIL_UPDATED', - 'TORRENT_LIST_DIFF_CHANGE', - 'TORRENT_LIST_FULL_UPDATE', - 'TRANSFER_HISTORY_FULL_UPDATE', - 'TRANSFER_SUMMARY_DIFF_CHANGE', - 'TRANSFER_SUMMARY_FULL_UPDATE', -]; - -module.exports = objectUtil.createStringMapFromArray(serverEventTypes); diff --git a/shared/constants/torrentFilePropsMap.js b/shared/constants/torrentFilePropsMap.ts similarity index 83% rename from shared/constants/torrentFilePropsMap.js rename to shared/constants/torrentFilePropsMap.ts index 322dbcdf..7ef76e02 100644 --- a/shared/constants/torrentFilePropsMap.js +++ b/shared/constants/torrentFilePropsMap.ts @@ -1,6 +1,6 @@ const torrentFilePropsMap = { props: ['path', 'pathComponents', 'priority', 'sizeBytes', 'sizeChunks', 'completedChunks'], methods: ['f.path=', 'f.path_components=', 'f.priority=', 'f.size_bytes=', 'f.size_chunks=', 'f.completed_chunks='], -}; +} as const; -module.exports = torrentFilePropsMap; +export default torrentFilePropsMap; diff --git a/shared/constants/torrentPeerPropsMap.js b/shared/constants/torrentPeerPropsMap.ts similarity index 91% rename from shared/constants/torrentPeerPropsMap.js rename to shared/constants/torrentPeerPropsMap.ts index 68e23cc1..ae4facd8 100644 --- a/shared/constants/torrentPeerPropsMap.js +++ b/shared/constants/torrentPeerPropsMap.ts @@ -27,6 +27,6 @@ const torrentPeerPropsMap = { 'p.is_encrypted=', 'p.is_incoming=', ], -}; +} as const; -module.exports = torrentPeerPropsMap; +export default torrentPeerPropsMap; diff --git a/shared/constants/torrentStatusMap.js b/shared/constants/torrentStatusMap.js deleted file mode 100644 index 82ab2102..00000000 --- a/shared/constants/torrentStatusMap.js +++ /dev/null @@ -1,19 +0,0 @@ -const objectUtil = require('../util/objectUtil'); - -const torrentStatusMap = objectUtil.reflect({ - ch: 'checking', - sd: 'seeding', - p: 'paused', - c: 'complete', - d: 'downloading', - ad: 'activelyDownloading', - au: 'activelyUploading', - s: 'stopped', - e: 'error', - i: 'inactive', - a: 'active', -}); - -torrentStatusMap.statusShorthand = ['ch', 'sd', 'p', 'c', 'd', 'ad', 'au', 's', 'e', 'i', 'a']; - -module.exports = torrentStatusMap; diff --git a/shared/constants/torrentStatusMap.ts b/shared/constants/torrentStatusMap.ts new file mode 100644 index 00000000..c02775eb --- /dev/null +++ b/shared/constants/torrentStatusMap.ts @@ -0,0 +1,16 @@ +const torrentStatusMap = [ + 'checking', + 'seeding', + 'paused', + 'complete', + 'downloading', + 'activelyDownloading', + 'activelyUploading', + 'stopped', + 'error', + 'inactive', + 'active', +] as const; + +export type TorrentStatus = typeof torrentStatusMap[number] | 'all'; +export default torrentStatusMap; diff --git a/shared/constants/torrentTrackerPropsMap.js b/shared/constants/torrentTrackerPropsMap.ts similarity index 80% rename from shared/constants/torrentTrackerPropsMap.js rename to shared/constants/torrentTrackerPropsMap.ts index f2375968..83754641 100644 --- a/shared/constants/torrentTrackerPropsMap.js +++ b/shared/constants/torrentTrackerPropsMap.ts @@ -1,6 +1,6 @@ const torrentTrackerPropsMap = { props: ['group', 'url', 'id', 'minInterval', 'normalInterval', 'type'], methods: ['t.group=', 't.url=', 't.id=', 't.min_interval=', 't.normal_interval=', 't.type='], -}; +} as const; -module.exports = torrentTrackerPropsMap; +export default torrentTrackerPropsMap; diff --git a/shared/types/Action.ts b/shared/types/Action.ts new file mode 100644 index 00000000..500dce14 --- /dev/null +++ b/shared/types/Action.ts @@ -0,0 +1,16 @@ +export interface AddTorrentByURLOptions { + urls: Array; + destination: string; + isBasePath: boolean; + start: boolean; + tags?: Array; +} + +export interface MoveTorrentsOptions { + destination: string; + isBasePath: boolean; + filenames: Array; + sourcePaths: Array; + moveFiles: boolean; + isCheckHash: boolean; +} diff --git a/shared/types/Auth.ts b/shared/types/Auth.ts index 0fd44856..37445972 100644 --- a/shared/types/Auth.ts +++ b/shared/types/Auth.ts @@ -14,6 +14,8 @@ export interface Credentials { isAdmin?: boolean; } +export type UserInDatabase = Required & {_id: string}; + // auth/authenticate export interface AuthAuthenticationResponse { success: boolean; diff --git a/shared/types/ClientSettings.tsx b/shared/types/ClientSettings.tsx deleted file mode 100644 index 8f166372..00000000 --- a/shared/types/ClientSettings.tsx +++ /dev/null @@ -1,9 +0,0 @@ -// TODO: Unite with clientSettingsMap when server is TS. - -import {clientSettings} from '../constants/clientSettingsMap'; - -export type ClientSetting = keyof typeof clientSettings; -export type ClientSettings = { - // TODO: Need proper types for each property - [property in ClientSetting]?: string | Record | null; -}; diff --git a/shared/types/DiskUsage.ts b/shared/types/DiskUsage.ts new file mode 100644 index 00000000..21d0f7a0 --- /dev/null +++ b/shared/types/DiskUsage.ts @@ -0,0 +1,8 @@ +export interface Disk { + target: string; + size: number; + avail: number; + used: number; +} + +export type Disks = Array; diff --git a/shared/types/Notification.ts b/shared/types/Notification.ts new file mode 100644 index 00000000..46d1c5ed --- /dev/null +++ b/shared/types/Notification.ts @@ -0,0 +1,28 @@ +export interface Notification { + _id?: string; + id: 'notification.torrent.finished' | 'notification.torrent.errored' | 'notification.feed.downloaded.torrent'; + read: boolean; + ts: number; // timestamp + data: { + name: string; + ruleLabel?: string; + feedLabel?: string; + title?: string; + }; +} + +export interface NotificationCount { + total: number; + unread: number; + read: number; +} + +export interface NotificationState { + id: string; + count: NotificationCount; + limit: number; + start: number; + notifications: Array; +} + +export type NotificationFetchOptions = Pick & {allNotifications?: boolean}; diff --git a/shared/types/ServerEvents.ts b/shared/types/ServerEvents.ts new file mode 100644 index 00000000..d77c56d5 --- /dev/null +++ b/shared/types/ServerEvents.ts @@ -0,0 +1,21 @@ +import type {Disks} from './DiskUsage'; +import type {NotificationCount} from './Notification'; +import type {Taxonomy, TaxonomyDiffs} from './Taxonomy'; +import type {Torrents, TorrentListDiff} from './Torrent'; +import type {TransferHistory, TransferSummary, TransferSummaryDiff} from './TransferData'; + +// type: data +export interface ServerEvents { + CLIENT_CONNECTIVITY_STATUS_CHANGE: { + isConnected: boolean; + }; + DISK_USAGE_CHANGE: Disks; + NOTIFICATION_COUNT_CHANGE: NotificationCount; + TAXONOMY_FULL_UPDATE: Taxonomy; + TAXONOMY_DIFF_CHANGE: TaxonomyDiffs; + TORRENT_LIST_FULL_UPDATE: Torrents; + TORRENT_LIST_DIFF_CHANGE: TorrentListDiff; + TRANSFER_HISTORY_FULL_UPDATE: TransferHistory; + TRANSFER_SUMMARY_FULL_UPDATE: TransferSummary; + TRANSFER_SUMMARY_DIFF_CHANGE: TransferSummaryDiff; +} diff --git a/shared/types/Taxonomy.ts b/shared/types/Taxonomy.ts new file mode 100644 index 00000000..02d5fd1e --- /dev/null +++ b/shared/types/Taxonomy.ts @@ -0,0 +1,15 @@ +import type {DiffAction} from '../constants/diffActionTypes'; + +export interface Taxonomy { + statusCounts: Record; + tagCounts: Record; + trackerCounts: Record; +} + +type TaxonomyDiff = DiffAction | null; + +export interface TaxonomyDiffs { + statusCounts: TaxonomyDiff<'statusCounts'>; + tagCounts: TaxonomyDiff<'tagCounts'>; + trackerCounts: TaxonomyDiff<'trackerCounts'>; +} diff --git a/shared/types/Torrent.ts b/shared/types/Torrent.ts new file mode 100644 index 00000000..fdf61dbf --- /dev/null +++ b/shared/types/Torrent.ts @@ -0,0 +1,117 @@ +import {TorrentStatus} from '../constants/torrentStatusMap'; + +export interface Duration { + weeks?: number; + days?: number; + hours?: number; + minutes?: number; + seconds?: number; + cumSeconds: number; +} + +export interface TorrentDetails { + fileTree: { + files: Array<{ + index: number; + filename: string; + path: string; + percentComplete: number; + priority: number; + sizeBytes: number; + }>; + peers: Array; + trackers: Array; + }; +} + +// TODO: Unite with torrentPeerPropsMap when it is TS. +export interface TorrentPeer { + index: number; + country: string; + address: string; + completedPercent: number; + clientVersion: string; + downloadRate: number; + downloadTotal: number; + uploadRate: number; + uploadTotal: number; + id: string; + peerRate: number; + peerTotal: number; + isEncrypted: boolean; + isIncoming: boolean; +} + +// TODO: Unite with torrentTrackerPropsMap when it is TS. +export interface TorrentTracker { + index: number; + id: string; + url: string; + type: number; + group: number; + minInterval: number; + normalInterval: number; +} + +// TODO: Rampant over-fetching of torrent properties. Need to remove unused items. +// TODO: Unite with torrentListPropMap when it is TS. +export interface TorrentProperties { + baseDirectory: string; + baseFilename: string; + basePath: string; + bytesDone: number; + comment: string; + dateAdded: string; + dateCreated: string; + details: TorrentDetails; + directory: string; + downRate: number; + downTotal: number; + eta: 'Infinity' | Duration; + hash: string; + ignoreScheduler: boolean; + isActive: boolean; + isComplete: boolean; + isHashing: string; + isMultiFile: boolean; + isOpen: boolean; + isPrivate: boolean; + isStateChanged: boolean; + message: string; + name: string; + peersConnected: number; + peersTotal: number; + percentComplete: number; + priority: string; + ratio: number; + seedingTime: string; + seedsConnected: number; + seedsTotal: number; + sizeBytes: number; + state: string; + status: Array; + tags: Array; + throttleName: string; + trackerURIs: Array; + upRate: number; + upTotal: number; +} + +export interface TorrentListDiff { + [hash: string]: + | { + action: 'TORRENT_LIST_ACTION_TORRENT_ADDED'; + data: TorrentProperties; + } + | { + action: 'TORRENT_LIST_ACTION_TORRENT_DELETED'; + } + | { + action: 'TORRENT_LIST_ACTION_TORRENT_DETAIL_UPDATED'; + data: Partial; + }; +} + +export interface Torrents { + [hash: string]: TorrentProperties; +} diff --git a/shared/types/TransferData.ts b/shared/types/TransferData.ts new file mode 100644 index 00000000..cb042641 --- /dev/null +++ b/shared/types/TransferData.ts @@ -0,0 +1,23 @@ +import {DiffAction} from '@shared/constants/diffActionTypes'; + +export interface TransferSummary { + downRate: number; + downThrottle: number; + downTotal: number; + upRate: number; + upThrottle: number; + upTotal: number; +} + +export type TransferSummaryDiff = DiffAction>; + +export type TransferDirection = 'upload' | 'download'; + +export type TransferData = Record; + +export interface TransferSnapshot extends TransferData { + numUpdates?: number; + timestamp: number; +} + +export type TransferHistory = Record>; diff --git a/shared/util/objectUtil.js b/shared/util/objectUtil.js deleted file mode 100644 index 455c4c89..00000000 --- a/shared/util/objectUtil.js +++ /dev/null @@ -1,72 +0,0 @@ -const diffActionTypes = require('../constants/diffActionTypes'); - -const objectUtil = { - createStringMapFromArray: (array) => - array.reduce((memo, key) => { - memo[key] = key; - - return memo; - }, {}), - - createSymbolMapFromArray: (array = []) => - array.reduce((memo, key) => { - memo[key] = Symbol(key); - - return memo; - }, {}), - - getDiff: (prevObject = {}, nextObject = {}) => { - const prevObjectKeys = Object.keys(prevObject); - const nextObjectKeys = Object.keys(nextObject); - - let shouldCheckForRemovals = nextObjectKeys.length < prevObjectKeys.length; - - const diff = nextObjectKeys.reduce((accumulator, key) => { - const prevValue = prevObject[key]; - const nextValue = nextObject[key]; - - if (prevValue == null) { - shouldCheckForRemovals = true; - - accumulator.push({ - action: diffActionTypes.ITEM_ADDED, - data: { - [key]: nextValue, - }, - }); - } else if (prevValue !== nextValue) { - accumulator.push({ - action: diffActionTypes.ITEM_CHANGED, - data: { - [key]: nextValue, - }, - }); - } - - return accumulator; - }, []); - - if (shouldCheckForRemovals) { - prevObjectKeys.forEach((key) => { - if (nextObject[key] == null) { - diff.push({ - action: diffActionTypes.ITEM_REMOVED, - data: key, - }); - } - }); - } - - return diff; - }, - - reflect: (object) => - Object.keys(object).reduce((memo, key) => { - memo[key] = object[key]; - memo[object[key]] = key; - - return memo; - }, {}), -}; - -module.exports = objectUtil; diff --git a/shared/util/objectUtil.ts b/shared/util/objectUtil.ts new file mode 100644 index 00000000..164b3f6a --- /dev/null +++ b/shared/util/objectUtil.ts @@ -0,0 +1,88 @@ +import type {DiffAction} from '../constants/diffActionTypes'; + +type KeyFromValue> = { + [K in keyof T]: V extends T[K] ? K : never; +}[keyof T]; + +const objectUtil = { + createStringMapFromArray: (array: Readonly>): Readonly<{[key in T]: key}> => { + return array.reduce((memo, key) => { + return Object.assign(memo, { + [key]: key, + }); + }, {} as Partial<{[key in T]: key}>) as Readonly<{[key in T]: key}>; + }, + + getDiff:

( + prevObject: Partial>, + nextObject: Partial>, + ) => { + const prevObjectKeys = Object.keys(prevObject); + const nextObjectKeys = Object.keys(nextObject); + + let shouldCheckForRemovals = nextObjectKeys.length < prevObjectKeys.length; + + const diff = nextObjectKeys.reduce((accumulator, key) => { + const prevValue = prevObject[key as P]; + const nextValue = nextObject[key as N]; + + if (prevValue == null) { + shouldCheckForRemovals = true; + + accumulator.push({ + action: 'ITEM_ADDED', + data: { + [key]: nextValue, + }, + } as { + action: 'ITEM_ADDED'; + data: { + [key in N]: typeof nextObject[key]; + }; + }); + } else if (prevValue !== nextValue) { + accumulator.push({ + action: 'ITEM_CHANGED', + data: { + [key]: nextValue, + }, + } as { + action: 'ITEM_CHANGED'; + data: { + [key in N]: typeof nextObject[key]; + }; + }); + } + + return accumulator; + }, [] as DiffAction>); + + if (shouldCheckForRemovals) { + prevObjectKeys.forEach((key) => { + if (nextObject[key as N] == null) { + diff.push({ + action: 'ITEM_REMOVED', + data: key as N, + }); + } + }); + } + + return diff; + }, + + reflect: , K extends keyof T>( + object: T, + ): {[value in T[K]]: KeyFromValue} & {[key in K]: T[key]} => { + return Object.assign( + object, + Object.keys(object).reduce((memo, key) => { + return Object.assign(memo, { + [object[key as K]]: key, + }); + }, {} as {[value in T[K]]: KeyFromValue}), + ); + }, +}; + +export default objectUtil; diff --git a/shared/util/regEx.js b/shared/util/regEx.ts similarity index 86% rename from shared/util/regEx.js rename to shared/util/regEx.ts index 3d608307..9bdfd185 100644 --- a/shared/util/regEx.js +++ b/shared/util/regEx.ts @@ -2,6 +2,6 @@ const regEx = { url: /^(?:https?|ftp):\/\/.{1,}\.{1}.{1,}/, domainName: /(?:https?|udp):\/\/(?:www\.)?([-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,18}\b)*(\/[/\d\w.-]*)*(?:[?])*(.+)*/i, cdata: //, -}; +} as const; -module.exports = regEx; +export default regEx; diff --git a/shared/util/stringUtil.js b/shared/util/stringUtil.js deleted file mode 100644 index 6a56ae84..00000000 --- a/shared/util/stringUtil.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - capitalize: (string) => string.charAt(0).toUpperCase() + string.slice(1), - - pluralize: (string, count) => { - if (count !== 1) { - if (string.charAt(string.length - 1) === 'y') { - return `${string.substring(0, string.length - 1)}ies`; - } - return `${string}s`; - } - - return string; - }, - - withoutTrailingSlash: (input) => input.replace(/\/{1,}$/, ''), -}; diff --git a/shared/util/stringUtil.ts b/shared/util/stringUtil.ts new file mode 100644 index 00000000..941c4dc3 --- /dev/null +++ b/shared/util/stringUtil.ts @@ -0,0 +1,4 @@ +export default { + capitalize: (string: string): string => string.charAt(0).toUpperCase() + string.slice(1), + withoutTrailingSlash: (input: string): string => input.replace(/\/{1,}$/, ''), +};