You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Prowlarr/frontend/src/Components/SignalRConnector.js

354 lines
8.8 KiB

import $ from 'jquery';
import 'signalr';
import PropTypes from 'prop-types';
import { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { repopulatePage } from 'Utilities/pagePopulator';
import titleCase from 'Utilities/String/titleCase';
import { fetchCommands, updateCommand, finishCommand } from 'Store/Actions/commandActions';
import { setAppValue, setVersion } from 'Store/Actions/appActions';
import { update, updateItem, removeItem } from 'Store/Actions/baseActions';
import { fetchHealth } from 'Store/Actions/systemActions';
import { fetchQueue, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchTags, fetchTagDetails } from 'Store/Actions/tagActions';
function getState(status) {
switch (status) {
case 0:
return 'connecting';
case 1:
return 'connected';
case 2:
return 'reconnecting';
case 4:
return 'disconnected';
default:
throw new Error(`invalid status ${status}`);
}
}
function isAppDisconnected(disconnectedTime) {
if (!disconnectedTime) {
return false;
}
return Math.floor(new Date().getTime() / 1000) - disconnectedTime > 180;
}
function getHandlerName(name) {
name = titleCase(name);
name = name.replace('/', '');
return `handle${name}`;
}
function createMapStateToProps() {
return createSelector(
(state) => state.app.isReconnecting,
(state) => state.app.isDisconnected,
(state) => state.queue.paged.isPopulated,
(isReconnecting, isDisconnected, isQueuePopulated) => {
return {
isReconnecting,
isDisconnected,
isQueuePopulated
};
}
);
}
const mapDispatchToProps = {
dispatchFetchCommands: fetchCommands,
dispatchUpdateCommand: updateCommand,
dispatchFinishCommand: finishCommand,
dispatchSetAppValue: setAppValue,
dispatchSetVersion: setVersion,
dispatchUpdate: update,
dispatchUpdateItem: updateItem,
dispatchRemoveItem: removeItem,
dispatchFetchHealth: fetchHealth,
dispatchFetchQueue: fetchQueue,
dispatchFetchQueueDetails: fetchQueueDetails,
dispatchFetchRootFolders: fetchRootFolders,
dispatchFetchTags: fetchTags,
dispatchFetchTagDetails: fetchTagDetails
};
class SignalRConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.signalRconnectionOptions = { transport: ['webSockets', 'longPolling'] };
this.signalRconnection = null;
this.retryInterval = 1;
this.retryTimeoutId = null;
this.disconnectedTime = null;
}
componentDidMount() {
console.log('Starting signalR');
const url = `${window.Radarr.urlBase}/signalr`;
this.signalRconnection = $.connection(url, { apiKey: window.Radarr.apiKey });
this.signalRconnection.stateChanged(this.onStateChanged);
this.signalRconnection.received(this.onReceived);
this.signalRconnection.reconnecting(this.onReconnecting);
this.signalRconnection.disconnected(this.onDisconnected);
this.signalRconnection.start(this.signalRconnectionOptions);
}
componentWillUnmount() {
if (this.retryTimeoutId) {
this.retryTimeoutId = clearTimeout(this.retryTimeoutId);
}
this.signalRconnection.stop();
this.signalRconnection = null;
}
//
// Control
retryConnection = () => {
if (isAppDisconnected(this.disconnectedTime)) {
this.setState({
isDisconnected: true
});
}
this.retryTimeoutId = setTimeout(() => {
if (!this.signalRconnection) {
console.error('signalR: Connection was disposed');
return;
}
this.signalRconnection.start(this.signalRconnectionOptions);
this.retryInterval = Math.min(this.retryInterval + 1, 10);
}, this.retryInterval * 1000);
}
handleMessage = (message) => {
const {
name,
body
} = message;
const handler = this[getHandlerName(name)];
if (handler) {
handler(body);
return;
}
console.error(`signalR: Unable to find handler for ${name}`);
}
handleCalendar = (body) => {
if (body.action === 'updated') {
this.props.dispatchUpdateItem({
section: 'calendar',
updateOnly: true,
...body.resource
});
}
}
handleCommand = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchCommands();
return;
}
const resource = body.resource;
const status = resource.status;
// Both sucessful and failed commands need to be
// completed, otherwise they spin until they timeout.
if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource);
} else {
this.props.dispatchUpdateCommand(resource);
}
}
handleMoviefile = (body) => {
const section = 'movieFiles';
if (body.action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
// Repopulate the page to handle recently imported file
repopulatePage('movieFileUpdated');
} else if (body.action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: body.resource.id });
}
}
handleHealth = () => {
this.props.dispatchFetchHealth();
}
handleMovie = (body) => {
const action = body.action;
const section = 'movies';
if (action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: body.resource.id });
}
}
handleQueue = () => {
if (this.props.isQueuePopulated) {
this.props.dispatchFetchQueue();
}
}
handleQueueDetails = () => {
this.props.dispatchFetchQueueDetails();
}
handleQueueStatus = (body) => {
this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
}
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
}
handleVersion = (body) => {
const version = body.Version;
this.props.dispatchSetVersion({ version });
}
handleSystemTask = () => {
// No-op for now, we may want this later
}
handleTag = (body) => {
if (body.action === 'sync') {
this.props.dispatchFetchTags();
this.props.dispatchFetchTagDetails();
return;
}
}
//
// Listeners
onStateChanged = (change) => {
const state = getState(change.newState);
console.log(`signalR: ${state}`);
if (state === 'connected') {
// Clear disconnected time
this.disconnectedTime = null;
const {
dispatchFetchCommands,
dispatchSetAppValue
} = this.props;
// Repopulate the page (if a repopulator is set) to ensure things
// are in sync after reconnecting.
if (this.props.isReconnecting || this.props.isDisconnected) {
dispatchFetchCommands();
repopulatePage();
}
dispatchSetAppValue({
isConnected: true,
isReconnecting: false,
isDisconnected: false,
isRestarting: false
});
this.retryInterval = 5;
if (this.retryTimeoutId) {
clearTimeout(this.retryTimeoutId);
}
}
}
onReceived = (message) => {
console.debug('signalR: received', message.name, message.body);
this.handleMessage(message);
}
onReconnecting = () => {
if (window.Radarr.unloading) {
return;
}
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.dispatchSetAppValue({
isReconnecting: true
});
}
onDisconnected = () => {
if (window.Radarr.unloading) {
return;
}
if (!this.disconnectedTime) {
this.disconnectedTime = Math.floor(new Date().getTime() / 1000);
}
this.props.dispatchSetAppValue({
isConnected: false,
isReconnecting: true,
isDisconnected: isAppDisconnected(this.disconnectedTime)
});
this.retryConnection();
}
//
// Render
render() {
return null;
}
}
SignalRConnector.propTypes = {
isReconnecting: PropTypes.bool.isRequired,
isDisconnected: PropTypes.bool.isRequired,
isQueuePopulated: PropTypes.bool.isRequired,
dispatchFetchCommands: PropTypes.func.isRequired,
dispatchUpdateCommand: PropTypes.func.isRequired,
dispatchFinishCommand: PropTypes.func.isRequired,
dispatchSetAppValue: PropTypes.func.isRequired,
dispatchSetVersion: PropTypes.func.isRequired,
dispatchUpdate: PropTypes.func.isRequired,
dispatchUpdateItem: PropTypes.func.isRequired,
dispatchRemoveItem: PropTypes.func.isRequired,
dispatchFetchHealth: PropTypes.func.isRequired,
dispatchFetchQueue: PropTypes.func.isRequired,
dispatchFetchQueueDetails: PropTypes.func.isRequired,
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchTagDetails: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SignalRConnector);