diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js
index e6f8f4c20..882e5d539 100644
--- a/frontend/src/App/AppRoutes.js
+++ b/frontend/src/App/AppRoutes.js
@@ -33,6 +33,8 @@ import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
+import MissingConnector from 'Wanted/Missing/MissingConnector';
function AppRoutes(props) {
const {
@@ -121,6 +123,20 @@ function AppRoutes(props) {
component={BlocklistConnector}
/>
+ {/*
+ Wanted
+ */}
+
+
+
+
+
{/*
Settings
*/}
diff --git a/frontend/src/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js
index 50b03cada..33e68fbc5 100644
--- a/frontend/src/Components/Page/Sidebar/PageSidebar.js
+++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js
@@ -71,6 +71,22 @@ const links = [
]
},
+ {
+ iconName: icons.WARNING,
+ title: () => translate('Wanted'),
+ to: '/wanted/missing',
+ children: [
+ {
+ title: () => translate('Missing'),
+ to: '/wanted/missing'
+ },
+ {
+ title: () => translate('CutoffUnmet'),
+ to: '/wanted/cutoffunmet'
+ }
+ ]
+ },
+
{
iconName: icons.SETTINGS,
title: () => translate('Settings'),
diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js
index 0294a6d54..540cff1b6 100644
--- a/frontend/src/Components/SignalRConnector.js
+++ b/frontend/src/Components/SignalRConnector.js
@@ -244,6 +244,26 @@ class SignalRConnector extends Component {
this.props.dispatchSetVersion({ version });
};
+ handleWantedCutoff = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'wanted.cutoffUnmet',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ };
+
+ handleWantedMissing = (body) => {
+ if (body.action === 'updated') {
+ this.props.dispatchUpdateItem({
+ section: 'wanted.missing',
+ updateOnly: true,
+ ...body.resource
+ });
+ }
+ };
+
handleSystemTask = () => {
this.props.dispatchFetchCommands();
};
diff --git a/frontend/src/Movie/MovieSearchCell.css b/frontend/src/Movie/MovieSearchCell.css
new file mode 100644
index 000000000..bc4681ff2
--- /dev/null
+++ b/frontend/src/Movie/MovieSearchCell.css
@@ -0,0 +1,6 @@
+.movieSearchCell {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 70px;
+ white-space: nowrap;
+}
diff --git a/frontend/src/Movie/MovieSearchCell.css.d.ts b/frontend/src/Movie/MovieSearchCell.css.d.ts
new file mode 100644
index 000000000..8c283ac98
--- /dev/null
+++ b/frontend/src/Movie/MovieSearchCell.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'movieSearchCell': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Movie/MovieSearchCell.js b/frontend/src/Movie/MovieSearchCell.js
new file mode 100644
index 000000000..bc7bd8aeb
--- /dev/null
+++ b/frontend/src/Movie/MovieSearchCell.js
@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import IconButton from 'Components/Link/IconButton';
+import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import { icons } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import MovieInteractiveSearchModalConnector from './Search/MovieInteractiveSearchModalConnector';
+import styles from './MovieSearchCell.css';
+
+class MovieSearchCell extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ isInteractiveSearchModalOpen: false
+ };
+ }
+
+ //
+ // Listeners
+
+ onManualSearchPress = () => {
+ this.setState({ isInteractiveSearchModalOpen: true });
+ };
+
+ onInteractiveSearchModalClose = () => {
+ this.setState({ isInteractiveSearchModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ movieId,
+ movieTitle,
+ isSearching,
+ onSearchPress,
+ ...otherProps
+ } = this.props;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+}
+
+MovieSearchCell.propTypes = {
+ movieId: PropTypes.number.isRequired,
+ movieTitle: PropTypes.string.isRequired,
+ isSearching: PropTypes.bool.isRequired,
+ onSearchPress: PropTypes.func.isRequired
+};
+
+export default MovieSearchCell;
diff --git a/frontend/src/Movie/MovieSearchCellConnector.js b/frontend/src/Movie/MovieSearchCellConnector.js
new file mode 100644
index 000000000..0d8bc34e4
--- /dev/null
+++ b/frontend/src/Movie/MovieSearchCellConnector.js
@@ -0,0 +1,48 @@
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import MovieSearchCell from 'Movie/MovieSearchCell';
+import { executeCommand } from 'Store/Actions/commandActions';
+import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
+import createMovieSelector from 'Store/Selectors/createMovieSelector';
+import { isCommandExecuting } from 'Utilities/Command';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state, { movieId }) => movieId,
+ createMovieSelector(),
+ createCommandsSelector(),
+ (movieId, movie, commands) => {
+ const isSearching = commands.some((command) => {
+ const movieSearch = command.name === commandNames.MOVIE_SEARCH;
+
+ if (!movieSearch) {
+ return false;
+ }
+
+ return (
+ isCommandExecuting(command) &&
+ command.body.movieIds.indexOf(movieId) > -1
+ );
+ });
+
+ return {
+ movieMonitored: movie.monitored,
+ isSearching
+ };
+ }
+ );
+}
+
+function createMapDispatchToProps(dispatch, props) {
+ return {
+ onSearchPress(name, path) {
+ dispatch(executeCommand({
+ name: commandNames.MOVIE_SEARCH,
+ movieIds: [props.movieId]
+ }));
+ }
+ };
+}
+
+export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchCell);
diff --git a/frontend/src/Movie/MovieStatus.css b/frontend/src/Movie/MovieStatus.css
new file mode 100644
index 000000000..3833887df
--- /dev/null
+++ b/frontend/src/Movie/MovieStatus.css
@@ -0,0 +1,4 @@
+.center {
+ display: flex;
+ justify-content: center;
+}
diff --git a/frontend/src/Movie/MovieStatus.css.d.ts b/frontend/src/Movie/MovieStatus.css.d.ts
new file mode 100644
index 000000000..a49c06d3a
--- /dev/null
+++ b/frontend/src/Movie/MovieStatus.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'center': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Movie/MovieStatus.js b/frontend/src/Movie/MovieStatus.js
new file mode 100644
index 000000000..be54b6380
--- /dev/null
+++ b/frontend/src/Movie/MovieStatus.js
@@ -0,0 +1,115 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import QueueDetails from 'Activity/Queue/QueueDetails';
+import Icon from 'Components/Icon';
+import ProgressBar from 'Components/ProgressBar';
+import { icons, kinds, sizes } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import MovieQuality from './MovieQuality';
+import styles from './MovieStatus.css';
+
+function MovieStatus(props) {
+ const {
+ isAvailable,
+ monitored,
+ grabbed,
+ queueItem,
+ movieFile
+ } = props;
+
+ const hasMovieFile = !!movieFile;
+ const isQueued = !!queueItem;
+
+ if (isQueued) {
+ const {
+ sizeleft,
+ size
+ } = queueItem;
+
+ const progress = size ? (100 - sizeleft / size * 100) : 0;
+
+ return (
+
+
+ }
+ />
+
+ );
+ }
+
+ if (grabbed) {
+ return (
+
+
+
+ );
+ }
+
+ if (hasMovieFile) {
+ const quality = movieFile.quality;
+ const isCutoffNotMet = movieFile.qualityCutoffNotMet;
+
+ return (
+
+
+
+ );
+ }
+
+ if (!monitored) {
+ return (
+
+
+
+ );
+ }
+
+ if (isAvailable) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+MovieStatus.propTypes = {
+ isAvailable: PropTypes.bool.isRequired,
+ monitored: PropTypes.bool.isRequired,
+ grabbed: PropTypes.bool,
+ queueItem: PropTypes.object,
+ movieFile: PropTypes.object
+};
+
+export default MovieStatus;
diff --git a/frontend/src/Movie/MovieStatusConnector.js b/frontend/src/Movie/MovieStatusConnector.js
new file mode 100644
index 000000000..25b104d35
--- /dev/null
+++ b/frontend/src/Movie/MovieStatusConnector.js
@@ -0,0 +1,50 @@
+import _ from 'lodash';
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import MovieStatus from 'Movie/MovieStatus';
+import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector';
+import { createMovieByEntitySelector } from 'Store/Selectors/createMovieSelector';
+import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
+
+function createMapStateToProps() {
+ return createSelector(
+ createMovieByEntitySelector(),
+ createQueueItemSelector(),
+ createMovieFileSelector(),
+ (movie, queueItem, movieFile) => {
+ const result = _.pick(movie, [
+ 'isAvailable',
+ 'monitored',
+ 'grabbed'
+ ]);
+
+ result.queueItem = queueItem;
+ result.movieFile = movieFile;
+
+ return result;
+ }
+ );
+}
+
+class MovieStatusConnector extends Component {
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MovieStatusConnector.propTypes = {
+ movieId: PropTypes.number.isRequired,
+ movieFileId: PropTypes.number.isRequired
+};
+
+export default connect(createMapStateToProps, null)(MovieStatusConnector);
diff --git a/frontend/src/Movie/Search/MovieInteractiveSearchModal.js b/frontend/src/Movie/Search/MovieInteractiveSearchModal.js
index 071c2b67d..b381ac563 100644
--- a/frontend/src/Movie/Search/MovieInteractiveSearchModal.js
+++ b/frontend/src/Movie/Search/MovieInteractiveSearchModal.js
@@ -8,6 +8,7 @@ function MovieInteractiveSearchModal(props) {
const {
isOpen,
movieId,
+ movieTitle,
onModalClose
} = props;
@@ -20,6 +21,7 @@ function MovieInteractiveSearchModal(props) {
>
@@ -29,6 +31,7 @@ function MovieInteractiveSearchModal(props) {
MovieInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
movieId: PropTypes.number.isRequired,
+ movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};
diff --git a/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.js b/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.js
index dfcabc73e..4f309a514 100644
--- a/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.js
+++ b/frontend/src/Movie/Search/MovieInteractiveSearchModalContent.js
@@ -12,13 +12,17 @@ import translate from 'Utilities/String/translate';
function MovieInteractiveSearchModalContent(props) {
const {
movieId,
+ movieTitle,
onModalClose
} = props;
return (
- {translate('InteractiveSearchModalHeader')}
+ {movieTitle === undefined ?
+ translate('InteractiveSearchModalHeader') :
+ translate('InteractiveSearchModalHeaderTitle', { title: movieTitle })
+ }
@@ -38,6 +42,7 @@ function MovieInteractiveSearchModalContent(props) {
MovieInteractiveSearchModalContent.propTypes = {
movieId: PropTypes.number.isRequired,
+ movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired
};
diff --git a/frontend/src/Movie/movieEntities.js b/frontend/src/Movie/movieEntities.js
index 32b276a4b..790cddf59 100644
--- a/frontend/src/Movie/movieEntities.js
+++ b/frontend/src/Movie/movieEntities.js
@@ -1,9 +1,13 @@
export const CALENDAR = 'calendar';
export const MOVIES = 'movies';
export const INTERACTIVE_IMPORT = 'interactiveImport.movies';
+export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
+export const WANTED_MISSING = 'wanted.missing';
export default {
CALENDAR,
MOVIES,
- INTERACTIVE_IMPORT
+ INTERACTIVE_IMPORT,
+ WANTED_CUTOFF_UNMET,
+ WANTED_MISSING
};
diff --git a/frontend/src/MovieFile/MovieFileLanguageConnector.js b/frontend/src/MovieFile/MovieFileLanguageConnector.js
index f20f3fc73..af4b239e6 100644
--- a/frontend/src/MovieFile/MovieFileLanguageConnector.js
+++ b/frontend/src/MovieFile/MovieFileLanguageConnector.js
@@ -8,7 +8,7 @@ function createMapStateToProps() {
createMovieFileSelector(),
(movieFile) => {
return {
- language: movieFile ? movieFile.language : undefined
+ languages: movieFile ? movieFile.languages : undefined
};
}
);
diff --git a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js b/frontend/src/Store/Actions/Creators/createBatchToggleMovieMonitoredHandler.js
similarity index 55%
rename from frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
rename to frontend/src/Store/Actions/Creators/createBatchToggleMovieMonitoredHandler.js
index 0b5596a94..0433df1ba 100644
--- a/frontend/src/Store/Actions/Creators/createBatchToggleEpisodeMonitoredHandler.js
+++ b/frontend/src/Store/Actions/Creators/createBatchToggleMovieMonitoredHandler.js
@@ -1,29 +1,29 @@
import createAjaxRequest from 'Utilities/createAjaxRequest';
-import updateEpisodes from 'Utilities/Episode/updateEpisodes';
+import updateMovies from 'Utilities/Movie/updateMovies';
import getSectionState from 'Utilities/State/getSectionState';
-function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
+function createBatchToggleMovieMonitoredHandler(section, fetchHandler) {
return function(getState, payload, dispatch) {
const {
- episodeIds,
+ movieIds,
monitored
} = payload;
const state = getSectionState(getState(), section, true);
- dispatch(updateEpisodes(section, state.items, episodeIds, {
+ dispatch(updateMovies(section, state.items, movieIds, {
isSaving: true
}));
const promise = createAjaxRequest({
- url: '/episode/monitor',
+ url: '/movie/editor',
method: 'PUT',
- data: JSON.stringify({ episodeIds, monitored }),
+ data: JSON.stringify({ movieIds, monitored }),
dataType: 'json'
}).request;
promise.done(() => {
- dispatch(updateEpisodes(section, state.items, episodeIds, {
+ dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false,
monitored
}));
@@ -32,11 +32,11 @@ function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
});
promise.fail(() => {
- dispatch(updateEpisodes(section, state.items, episodeIds, {
+ dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false
}));
});
};
}
-export default createBatchToggleEpisodeMonitoredHandler;
+export default createBatchToggleMovieMonitoredHandler;
diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js
index 394fcd964..dffb83e69 100644
--- a/frontend/src/Store/Actions/index.js
+++ b/frontend/src/Store/Actions/index.js
@@ -28,6 +28,7 @@ import * as rootFolders from './rootFolderActions';
import * as settings from './settingsActions';
import * as system from './systemActions';
import * as tags from './tagActions';
+import * as wanted from './wantedActions';
export default [
addMovie,
@@ -59,5 +60,6 @@ export default [
movieCredits,
settings,
system,
- tags
+ tags,
+ wanted
];
diff --git a/frontend/src/Store/Actions/wantedActions.js b/frontend/src/Store/Actions/wantedActions.js
new file mode 100644
index 000000000..2eb186f86
--- /dev/null
+++ b/frontend/src/Store/Actions/wantedActions.js
@@ -0,0 +1,298 @@
+import { createAction } from 'redux-actions';
+import { filterTypes, sortDirections } from 'Helpers/Props';
+import createBatchToggleMovieMonitoredHandler from 'Store/Actions/Creators/createBatchToggleMovieMonitoredHandler';
+import { createThunk, handleThunks } from 'Store/thunks';
+import serverSideCollectionHandlers from 'Utilities/serverSideCollectionHandlers';
+import translate from 'Utilities/String/translate';
+import createHandleActions from './Creators/createHandleActions';
+import createServerSideCollectionHandlers from './Creators/createServerSideCollectionHandlers';
+import createClearReducer from './Creators/Reducers/createClearReducer';
+import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
+
+//
+// Variables
+
+export const section = 'wanted';
+
+//
+// State
+
+export const defaultState = {
+ missing: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'movieMetadata.sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ error: null,
+ items: [],
+
+ columns: [
+ {
+ name: 'movieMetadata.sortTitle',
+ label: () => translate('MovieTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'movieMetadata.year',
+ label: () => translate('Year'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'status',
+ label: () => translate('Status'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: () => translate('Monitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: () => translate('Unmonitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ },
+
+ cutoffUnmet: {
+ isFetching: false,
+ isPopulated: false,
+ pageSize: 20,
+ sortKey: 'movieMetadata.sortTitle',
+ sortDirection: sortDirections.ASCENDING,
+ items: [],
+
+ columns: [
+ {
+ name: 'movieMetadata.sortTitle',
+ label: () => translate('MovieTitle'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'movieMetadata.year',
+ label: () => translate('Year'),
+ isSortable: true,
+ isVisible: true
+ },
+ {
+ name: 'languages',
+ label: () => translate('Languages'),
+ isVisible: false
+ },
+ {
+ name: 'status',
+ label: () => translate('Status'),
+ isVisible: true
+ },
+ {
+ name: 'actions',
+ columnLabel: () => translate('Actions'),
+ isVisible: true,
+ isModifiable: false
+ }
+ ],
+
+ selectedFilterKey: 'monitored',
+
+ filters: [
+ {
+ key: 'monitored',
+ label: () => translate('Monitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: true,
+ type: filterTypes.EQUAL
+ }
+ ]
+ },
+ {
+ key: 'unmonitored',
+ label: () => translate('Unmonitored'),
+ filters: [
+ {
+ key: 'monitored',
+ value: false,
+ type: filterTypes.EQUAL
+ }
+ ]
+ }
+ ]
+ }
+};
+
+export const persistState = [
+ 'wanted.missing.pageSize',
+ 'wanted.missing.sortKey',
+ 'wanted.missing.sortDirection',
+ 'wanted.missing.selectedFilterKey',
+ 'wanted.missing.columns',
+ 'wanted.cutoffUnmet.pageSize',
+ 'wanted.cutoffUnmet.sortKey',
+ 'wanted.cutoffUnmet.sortDirection',
+ 'wanted.cutoffUnmet.selectedFilterKey',
+ 'wanted.cutoffUnmet.columns'
+];
+
+//
+// Actions Types
+
+export const FETCH_MISSING = 'wanted/missing/fetchMissing';
+export const GOTO_FIRST_MISSING_PAGE = 'wanted/missing/gotoMissingFirstPage';
+export const GOTO_PREVIOUS_MISSING_PAGE = 'wanted/missing/gotoMissingPreviousPage';
+export const GOTO_NEXT_MISSING_PAGE = 'wanted/missing/gotoMissingNextPage';
+export const GOTO_LAST_MISSING_PAGE = 'wanted/missing/gotoMissingLastPage';
+export const GOTO_MISSING_PAGE = 'wanted/missing/gotoMissingPage';
+export const SET_MISSING_SORT = 'wanted/missing/setMissingSort';
+export const SET_MISSING_FILTER = 'wanted/missing/setMissingFilter';
+export const SET_MISSING_TABLE_OPTION = 'wanted/missing/setMissingTableOption';
+export const CLEAR_MISSING = 'wanted/missing/clearMissing';
+
+export const BATCH_TOGGLE_MISSING_MOVIES = 'wanted/missing/batchToggleMissingMovies';
+
+export const FETCH_CUTOFF_UNMET = 'wanted/cutoffUnmet/fetchCutoffUnmet';
+export const GOTO_FIRST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFirstPage';
+export const GOTO_PREVIOUS_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPreviousPage';
+export const GOTO_NEXT_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetNextPage';
+export const GOTO_LAST_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetFastPage';
+export const GOTO_CUTOFF_UNMET_PAGE = 'wanted/cutoffUnmet/gotoCutoffUnmetPage';
+export const SET_CUTOFF_UNMET_SORT = 'wanted/cutoffUnmet/setCutoffUnmetSort';
+export const SET_CUTOFF_UNMET_FILTER = 'wanted/cutoffUnmet/setCutoffUnmetFilter';
+export const SET_CUTOFF_UNMET_TABLE_OPTION = 'wanted/cutoffUnmet/setCutoffUnmetTableOption';
+export const CLEAR_CUTOFF_UNMET = 'wanted/cutoffUnmet/clearCutoffUnmet';
+
+export const BATCH_TOGGLE_CUTOFF_UNMET_MOVIES = 'wanted/cutoffUnmet/batchToggleCutoffUnmetMovies';
+
+//
+// Action Creators
+
+export const fetchMissing = createThunk(FETCH_MISSING);
+export const gotoMissingFirstPage = createThunk(GOTO_FIRST_MISSING_PAGE);
+export const gotoMissingPreviousPage = createThunk(GOTO_PREVIOUS_MISSING_PAGE);
+export const gotoMissingNextPage = createThunk(GOTO_NEXT_MISSING_PAGE);
+export const gotoMissingLastPage = createThunk(GOTO_LAST_MISSING_PAGE);
+export const gotoMissingPage = createThunk(GOTO_MISSING_PAGE);
+export const setMissingSort = createThunk(SET_MISSING_SORT);
+export const setMissingFilter = createThunk(SET_MISSING_FILTER);
+export const setMissingTableOption = createAction(SET_MISSING_TABLE_OPTION);
+export const clearMissing = createAction(CLEAR_MISSING);
+
+export const batchToggleMissingMovies = createThunk(BATCH_TOGGLE_MISSING_MOVIES);
+
+export const fetchCutoffUnmet = createThunk(FETCH_CUTOFF_UNMET);
+export const gotoCutoffUnmetFirstPage = createThunk(GOTO_FIRST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPreviousPage = createThunk(GOTO_PREVIOUS_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetNextPage = createThunk(GOTO_NEXT_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetLastPage = createThunk(GOTO_LAST_CUTOFF_UNMET_PAGE);
+export const gotoCutoffUnmetPage = createThunk(GOTO_CUTOFF_UNMET_PAGE);
+export const setCutoffUnmetSort = createThunk(SET_CUTOFF_UNMET_SORT);
+export const setCutoffUnmetFilter = createThunk(SET_CUTOFF_UNMET_FILTER);
+export const setCutoffUnmetTableOption = createAction(SET_CUTOFF_UNMET_TABLE_OPTION);
+export const clearCutoffUnmet = createAction(CLEAR_CUTOFF_UNMET);
+
+export const batchToggleCutoffUnmetMovies = createThunk(BATCH_TOGGLE_CUTOFF_UNMET_MOVIES);
+
+//
+// Action Handlers
+
+export const actionHandlers = handleThunks({
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.missing',
+ '/wanted/missing',
+ fetchMissing,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_MISSING,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_MISSING_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_MISSING_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_MISSING_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_MISSING_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_MISSING_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_MISSING_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_MISSING_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_MISSING_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.missing', fetchMissing),
+
+ ...createServerSideCollectionHandlers(
+ 'wanted.cutoffUnmet',
+ '/wanted/cutoff',
+ fetchCutoffUnmet,
+ {
+ [serverSideCollectionHandlers.FETCH]: FETCH_CUTOFF_UNMET,
+ [serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.EXACT_PAGE]: GOTO_CUTOFF_UNMET_PAGE,
+ [serverSideCollectionHandlers.SORT]: SET_CUTOFF_UNMET_SORT,
+ [serverSideCollectionHandlers.FILTER]: SET_CUTOFF_UNMET_FILTER
+ }
+ ),
+
+ [BATCH_TOGGLE_CUTOFF_UNMET_MOVIES]: createBatchToggleMovieMonitoredHandler('wanted.cutoffUnmet', fetchCutoffUnmet)
+
+});
+
+//
+// Reducers
+
+export const reducers = createHandleActions({
+
+ [SET_MISSING_TABLE_OPTION]: createSetTableOptionReducer('wanted.missing'),
+ [SET_CUTOFF_UNMET_TABLE_OPTION]: createSetTableOptionReducer('wanted.cutoffUnmet'),
+
+ [CLEAR_MISSING]: createClearReducer(
+ 'wanted.missing',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ ),
+
+ [CLEAR_CUTOFF_UNMET]: createClearReducer(
+ 'wanted.cutoffUnmet',
+ {
+ isFetching: false,
+ isPopulated: false,
+ error: null,
+ items: [],
+ totalPages: 0,
+ totalRecords: 0
+ }
+ )
+
+}, defaultState, section);
diff --git a/frontend/src/Store/Selectors/createMovieSelector.js b/frontend/src/Store/Selectors/createMovieSelector.js
index 7513498b2..bf06d5c3f 100644
--- a/frontend/src/Store/Selectors/createMovieSelector.js
+++ b/frontend/src/Store/Selectors/createMovieSelector.js
@@ -1,4 +1,6 @@
+import _ from 'lodash';
import { createSelector } from 'reselect';
+import movieEntities from 'Movie/movieEntities';
export function createMovieSelectorForHook(movieId) {
return createSelector(
@@ -11,6 +13,16 @@ export function createMovieSelectorForHook(movieId) {
);
}
+export function createMovieByEntitySelector() {
+ return createSelector(
+ (state, { movieId }) => movieId,
+ (state, { movieEntity = movieEntities.MOVIES }) => _.get(state, movieEntity, { items: [] }),
+ (movieId, movies) => {
+ return _.find(movies.items, { id: movieId });
+ }
+ );
+}
+
function createMovieSelector() {
return createSelector(
(state, { movieId }) => movieId,
diff --git a/frontend/src/Utilities/Episode/updateEpisodes.js b/frontend/src/Utilities/Movie/updateMovies.js
similarity index 56%
rename from frontend/src/Utilities/Episode/updateEpisodes.js
rename to frontend/src/Utilities/Movie/updateMovies.js
index 80890b53f..ff03c5401 100644
--- a/frontend/src/Utilities/Episode/updateEpisodes.js
+++ b/frontend/src/Utilities/Movie/updateMovies.js
@@ -1,9 +1,9 @@
import _ from 'lodash';
import { update } from 'Store/Actions/baseActions';
-function updateEpisodes(section, episodes, episodeIds, options) {
- const data = _.reduce(episodes, (result, item) => {
- if (episodeIds.indexOf(item.id) > -1) {
+function updateMovies(section, movies, movieIds, options) {
+ const data = _.reduce(movies, (result, item) => {
+ if (movieIds.indexOf(item.id) > -1) {
result.push({
...item,
...options
@@ -18,4 +18,4 @@ function updateEpisodes(section, episodes, episodeIds, options) {
return update({ section, data });
}
-export default updateEpisodes;
+export default updateMovies;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
new file mode 100644
index 000000000..41bbb9a9e
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmet.js
@@ -0,0 +1,301 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import { align, icons, kinds } from 'Helpers/Props';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import CutoffUnmetRow from './CutoffUnmetRow';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class CutoffUnmet extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllCutoffUnmetModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ };
+
+ //
+ // Listeners
+
+ onFilterMenuItemPress = (filterKey, filterValue) => {
+ this.props.onFilterSelect(filterKey, filterValue);
+ };
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ };
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ };
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ };
+
+ onToggleSelectedPress = () => {
+ const movieIds = this.getSelectedIds();
+
+ this.props.batchToggleCutoffUnmetMovies({
+ movieIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ };
+
+ onSearchAllCutoffUnmetPress = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: true });
+ };
+
+ onSearchAllCutoffUnmetConfirmed = () => {
+ const {
+ selectedFilterKey,
+ onSearchAllCutoffUnmetPress
+ } = this.props;
+
+ // TODO: Custom filters will need to check whether there is a monitored
+ // filter once implemented.
+
+ onSearchAllCutoffUnmetPress(selectedFilterKey === 'monitored');
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ };
+
+ onConfirmSearchAllCutoffUnmetModalClose = () => {
+ this.setState({ isConfirmSearchAllCutoffUnmetModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForCutoffUnmetMovies,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllCutoffUnmetModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ {translate('CutoffUnmetLoadError')}
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ {translate('CutoffUnmetNoItems')}
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ {translate('SearchForCutoffUnmetMoviesConfirmationCount', { totalRecords })}
+
+
+ {translate('MassSearchCancelWarning')}
+
+
+ }
+ confirmLabel={translate('Search')}
+ onConfirm={this.onSearchAllCutoffUnmetConfirmed}
+ onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
+ />
+
+ }
+
+
+ );
+ }
+}
+
+CutoffUnmet.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForCutoffUnmetMovies: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleCutoffUnmetMovies: PropTypes.func.isRequired,
+ onSearchAllCutoffUnmetPress: PropTypes.func.isRequired
+};
+
+export default CutoffUnmet;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
new file mode 100644
index 000000000..d78776728
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetConnector.js
@@ -0,0 +1,185 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import withCurrentPage from 'Components/withCurrentPage';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
+import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import CutoffUnmet from './CutoffUnmet';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.cutoffUnmet,
+ createCommandExecutingSelector(commandNames.CUTOFF_UNMET_MOVIES_SEARCH),
+ (cutoffUnmet, isSearchingForCutoffUnmetMovies) => {
+ return {
+ isSearchingForCutoffUnmetMovies,
+ isSaving: cutoffUnmet.items.filter((m) => m.isSaving).length > 1,
+ ...cutoffUnmet
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails,
+ fetchMovieFiles,
+ clearMovieFiles
+};
+
+class CutoffUnmetConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchCutoffUnmet,
+ gotoCutoffUnmetFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
+
+ if (useCurrentPage) {
+ fetchCutoffUnmet();
+ } else {
+ gotoCutoffUnmetFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const movieIds = selectUniqueIds(this.props.items, 'id');
+ const movieFileIds = selectUniqueIds(this.props.items, 'movieFileId');
+
+ this.props.fetchQueueDetails({ movieIds });
+
+ if (movieFileIds.length) {
+ this.props.fetchMovieFiles({ movieFileIds });
+ }
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearCutoffUnmet();
+ this.props.clearQueueDetails();
+ this.props.clearMovieFiles();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchCutoffUnmet();
+ };
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoCutoffUnmetFirstPage();
+ };
+
+ onPreviousPagePress = () => {
+ this.props.gotoCutoffUnmetPreviousPage();
+ };
+
+ onNextPagePress = () => {
+ this.props.gotoCutoffUnmetNextPage();
+ };
+
+ onLastPagePress = () => {
+ this.props.gotoCutoffUnmetLastPage();
+ };
+
+ onPageSelect = (page) => {
+ this.props.gotoCutoffUnmetPage({ page });
+ };
+
+ onSortPress = (sortKey) => {
+ this.props.setCutoffUnmetSort({ sortKey });
+ };
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setCutoffUnmetFilter({ selectedFilterKey });
+ };
+
+ onTableOptionChange = (payload) => {
+ this.props.setCutoffUnmetTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoCutoffUnmetFirstPage();
+ }
+ };
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.MOVIE_SEARCH,
+ movieIds: selected
+ });
+ };
+
+ onSearchAllCutoffUnmetPress = (monitored) => {
+ this.props.executeCommand({
+ name: commandNames.CUTOFF_UNMET_MOVIES_SEARCH,
+ monitored
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+CutoffUnmetConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchCutoffUnmet: PropTypes.func.isRequired,
+ gotoCutoffUnmetFirstPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPreviousPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetNextPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetLastPage: PropTypes.func.isRequired,
+ gotoCutoffUnmetPage: PropTypes.func.isRequired,
+ setCutoffUnmetSort: PropTypes.func.isRequired,
+ setCutoffUnmetFilter: PropTypes.func.isRequired,
+ setCutoffUnmetTableOption: PropTypes.func.isRequired,
+ clearCutoffUnmet: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired,
+ fetchMovieFiles: PropTypes.func.isRequired,
+ clearMovieFiles: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(CutoffUnmetConnector)
+);
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
new file mode 100644
index 000000000..c4867cae5
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css
@@ -0,0 +1,6 @@
+.languages,
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts
new file mode 100644
index 000000000..141d66d4e
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'languages': string;
+ 'status': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
new file mode 100644
index 000000000..ab54b956a
--- /dev/null
+++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js
@@ -0,0 +1,120 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TableRow from 'Components/Table/TableRow';
+import movieEntities from 'Movie/movieEntities';
+import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
+import MovieStatusConnector from 'Movie/MovieStatusConnector';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+import MovieFileLanguageConnector from 'MovieFile/MovieFileLanguageConnector';
+import styles from './CutoffUnmetRow.css';
+
+function CutoffUnmetRow(props) {
+ const {
+ id,
+ movieFileId,
+ year,
+ title,
+ titleSlug,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'movieMetadata.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'movieMetadata.year') {
+ return (
+
+ {year}
+
+ );
+ }
+
+ if (name === 'languages') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+CutoffUnmetRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ movieFileId: PropTypes.number,
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default CutoffUnmetRow;
diff --git a/frontend/src/Wanted/Missing/Missing.js b/frontend/src/Wanted/Missing/Missing.js
new file mode 100644
index 000000000..d88a12028
--- /dev/null
+++ b/frontend/src/Wanted/Missing/Missing.js
@@ -0,0 +1,319 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import Alert from 'Components/Alert';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import FilterMenu from 'Components/Menu/FilterMenu';
+import ConfirmModal from 'Components/Modal/ConfirmModal';
+import PageContent from 'Components/Page/PageContent';
+import PageContentBody from 'Components/Page/PageContentBody';
+import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
+import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
+import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
+import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
+import Table from 'Components/Table/Table';
+import TableBody from 'Components/Table/TableBody';
+import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
+import TablePager from 'Components/Table/TablePager';
+import { align, icons, kinds } from 'Helpers/Props';
+import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
+import getFilterValue from 'Utilities/Filter/getFilterValue';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import translate from 'Utilities/String/translate';
+import getSelectedIds from 'Utilities/Table/getSelectedIds';
+import removeOldSelectedState from 'Utilities/Table/removeOldSelectedState';
+import selectAll from 'Utilities/Table/selectAll';
+import toggleSelected from 'Utilities/Table/toggleSelected';
+import MissingRow from './MissingRow';
+
+function getMonitoredValue(props) {
+ const {
+ filters,
+ selectedFilterKey
+ } = props;
+
+ return getFilterValue(filters, selectedFilterKey, 'monitored', false);
+}
+
+class Missing extends Component {
+
+ //
+ // Lifecycle
+
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ allSelected: false,
+ allUnselected: false,
+ lastToggled: null,
+ selectedState: {},
+ isConfirmSearchAllMissingModalOpen: false,
+ isInteractiveImportModalOpen: false
+ };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ this.setState((state) => {
+ return removeOldSelectedState(state, prevProps.items);
+ });
+ }
+ }
+
+ //
+ // Control
+
+ getSelectedIds = () => {
+ return getSelectedIds(this.state.selectedState);
+ };
+
+ //
+ // Listeners
+
+ onSelectAllChange = ({ value }) => {
+ this.setState(selectAll(this.state.selectedState, value));
+ };
+
+ onSelectedChange = ({ id, value, shiftKey = false }) => {
+ this.setState((state) => {
+ return toggleSelected(state, this.props.items, id, value, shiftKey);
+ });
+ };
+
+ onSearchSelectedPress = () => {
+ const selected = this.getSelectedIds();
+
+ this.props.onSearchSelectedPress(selected);
+ };
+
+ onToggleSelectedPress = () => {
+ const movieIds = this.getSelectedIds();
+
+ this.props.batchToggleMissingMovies({
+ movieIds,
+ monitored: !getMonitoredValue(this.props)
+ });
+ };
+
+ onSearchAllMissingPress = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: true });
+ };
+
+ onSearchAllMissingConfirmed = () => {
+ const {
+ selectedFilterKey,
+ onSearchAllMissingPress
+ } = this.props;
+
+ // TODO: Custom filters will need to check whether there is a monitored
+ // filter once implemented.
+
+ onSearchAllMissingPress(selectedFilterKey === 'monitored');
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ };
+
+ onConfirmSearchAllMissingModalClose = () => {
+ this.setState({ isConfirmSearchAllMissingModalOpen: false });
+ };
+
+ onInteractiveImportPress = () => {
+ this.setState({ isInteractiveImportModalOpen: true });
+ };
+
+ onInteractiveImportModalClose = () => {
+ this.setState({ isInteractiveImportModalOpen: false });
+ };
+
+ //
+ // Render
+
+ render() {
+ const {
+ isFetching,
+ isPopulated,
+ error,
+ items,
+ selectedFilterKey,
+ filters,
+ columns,
+ totalRecords,
+ isSearchingForMissingMovies,
+ isSaving,
+ onFilterSelect,
+ ...otherProps
+ } = this.props;
+
+ const {
+ allSelected,
+ allUnselected,
+ selectedState,
+ isConfirmSearchAllMissingModalOpen,
+ isInteractiveImportModalOpen
+ } = this.state;
+
+ const itemsSelected = !!this.getSelectedIds().length;
+ const isShowingMonitored = getMonitoredValue(this.props);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ isFetching && !isPopulated &&
+
+ }
+
+ {
+ !isFetching && error &&
+
+ {translate('MissingLoadError')}
+
+ }
+
+ {
+ isPopulated && !error && !items.length &&
+
+ {translate('MissingNoItems')}
+
+ }
+
+ {
+ isPopulated && !error && !!items.length &&
+
+
+
+ {
+ items.map((item) => {
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+
+ {translate('SearchForAllMissingMoviesConfirmationCount', { totalRecords })}
+
+
+ {translate('MassSearchCancelWarning')}
+
+
+ }
+ confirmLabel={translate('Search')}
+ onConfirm={this.onSearchAllMissingConfirmed}
+ onCancel={this.onConfirmSearchAllMissingModalClose}
+ />
+
+ }
+
+
+
+
+ );
+ }
+}
+
+Missing.propTypes = {
+ isFetching: PropTypes.bool.isRequired,
+ isPopulated: PropTypes.bool.isRequired,
+ error: PropTypes.object,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ selectedFilterKey: PropTypes.string.isRequired,
+ filters: PropTypes.arrayOf(PropTypes.object).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ totalRecords: PropTypes.number,
+ isSearchingForMissingMovies: PropTypes.bool.isRequired,
+ isSaving: PropTypes.bool.isRequired,
+ onFilterSelect: PropTypes.func.isRequired,
+ onSearchSelectedPress: PropTypes.func.isRequired,
+ batchToggleMissingMovies: PropTypes.func.isRequired,
+ onSearchAllMissingPress: PropTypes.func.isRequired
+};
+
+export default Missing;
diff --git a/frontend/src/Wanted/Missing/MissingConnector.js b/frontend/src/Wanted/Missing/MissingConnector.js
new file mode 100644
index 000000000..e5c3e1f48
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingConnector.js
@@ -0,0 +1,173 @@
+import PropTypes from 'prop-types';
+import React, { Component } from 'react';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import * as commandNames from 'Commands/commandNames';
+import withCurrentPage from 'Components/withCurrentPage';
+import { executeCommand } from 'Store/Actions/commandActions';
+import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
+import * as wantedActions from 'Store/Actions/wantedActions';
+import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
+import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
+import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
+import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
+import Missing from './Missing';
+
+function createMapStateToProps() {
+ return createSelector(
+ (state) => state.wanted.missing,
+ createCommandExecutingSelector(commandNames.MISSING_MOVIES_SEARCH),
+ (missing, isSearchingForMissingMovies) => {
+ return {
+ isSearchingForMissingMovies,
+ isSaving: missing.items.filter((m) => m.isSaving).length > 1,
+ ...missing
+ };
+ }
+ );
+}
+
+const mapDispatchToProps = {
+ ...wantedActions,
+ executeCommand,
+ fetchQueueDetails,
+ clearQueueDetails
+};
+
+class MissingConnector extends Component {
+
+ //
+ // Lifecycle
+
+ componentDidMount() {
+ const {
+ useCurrentPage,
+ fetchMissing,
+ gotoMissingFirstPage
+ } = this.props;
+
+ registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']);
+
+ if (useCurrentPage) {
+ fetchMissing();
+ } else {
+ gotoMissingFirstPage();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (hasDifferentItems(prevProps.items, this.props.items)) {
+ const movieIds = selectUniqueIds(this.props.items, 'id');
+ this.props.fetchQueueDetails({ movieIds });
+ }
+ }
+
+ componentWillUnmount() {
+ unregisterPagePopulator(this.repopulate);
+ this.props.clearMissing();
+ this.props.clearQueueDetails();
+ }
+
+ //
+ // Control
+
+ repopulate = () => {
+ this.props.fetchMissing();
+ };
+
+ //
+ // Listeners
+
+ onFirstPagePress = () => {
+ this.props.gotoMissingFirstPage();
+ };
+
+ onPreviousPagePress = () => {
+ this.props.gotoMissingPreviousPage();
+ };
+
+ onNextPagePress = () => {
+ this.props.gotoMissingNextPage();
+ };
+
+ onLastPagePress = () => {
+ this.props.gotoMissingLastPage();
+ };
+
+ onPageSelect = (page) => {
+ this.props.gotoMissingPage({ page });
+ };
+
+ onSortPress = (sortKey) => {
+ this.props.setMissingSort({ sortKey });
+ };
+
+ onFilterSelect = (selectedFilterKey) => {
+ this.props.setMissingFilter({ selectedFilterKey });
+ };
+
+ onTableOptionChange = (payload) => {
+ this.props.setMissingTableOption(payload);
+
+ if (payload.pageSize) {
+ this.props.gotoMissingFirstPage();
+ }
+ };
+
+ onSearchSelectedPress = (selected) => {
+ this.props.executeCommand({
+ name: commandNames.MOVIE_SEARCH,
+ movieIds: selected
+ });
+ };
+
+ onSearchAllMissingPress = (monitored) => {
+ this.props.executeCommand({
+ name: commandNames.MISSING_MOVIES_SEARCH,
+ monitored
+ });
+ };
+
+ //
+ // Render
+
+ render() {
+ return (
+
+ );
+ }
+}
+
+MissingConnector.propTypes = {
+ useCurrentPage: PropTypes.bool.isRequired,
+ items: PropTypes.arrayOf(PropTypes.object).isRequired,
+ fetchMissing: PropTypes.func.isRequired,
+ gotoMissingFirstPage: PropTypes.func.isRequired,
+ gotoMissingPreviousPage: PropTypes.func.isRequired,
+ gotoMissingNextPage: PropTypes.func.isRequired,
+ gotoMissingLastPage: PropTypes.func.isRequired,
+ gotoMissingPage: PropTypes.func.isRequired,
+ setMissingSort: PropTypes.func.isRequired,
+ setMissingFilter: PropTypes.func.isRequired,
+ setMissingTableOption: PropTypes.func.isRequired,
+ clearMissing: PropTypes.func.isRequired,
+ executeCommand: PropTypes.func.isRequired,
+ fetchQueueDetails: PropTypes.func.isRequired,
+ clearQueueDetails: PropTypes.func.isRequired
+};
+
+export default withCurrentPage(
+ connect(createMapStateToProps, mapDispatchToProps)(MissingConnector)
+);
diff --git a/frontend/src/Wanted/Missing/MissingRow.css b/frontend/src/Wanted/Missing/MissingRow.css
new file mode 100644
index 000000000..1794c2530
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.css
@@ -0,0 +1,5 @@
+.status {
+ composes: cell from '~Components/Table/Cells/TableRowCell.css';
+
+ width: 100px;
+}
diff --git a/frontend/src/Wanted/Missing/MissingRow.css.d.ts b/frontend/src/Wanted/Missing/MissingRow.css.d.ts
new file mode 100644
index 000000000..01f2045fa
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'status': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js
new file mode 100644
index 000000000..a5175de20
--- /dev/null
+++ b/frontend/src/Wanted/Missing/MissingRow.js
@@ -0,0 +1,110 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import TableRowCell from 'Components/Table/Cells/TableRowCell';
+import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
+import TableRow from 'Components/Table/TableRow';
+import movieEntities from 'Movie/movieEntities';
+import MovieSearchCellConnector from 'Movie/MovieSearchCellConnector';
+import MovieStatusConnector from 'Movie/MovieStatusConnector';
+import MovieTitleLink from 'Movie/MovieTitleLink';
+import styles from './MissingRow.css';
+
+function MissingRow(props) {
+ const {
+ id,
+ movieFileId,
+ year,
+ title,
+ titleSlug,
+ isSelected,
+ columns,
+ onSelectedChange
+ } = props;
+
+ if (!title) {
+ return null;
+ }
+
+ return (
+
+
+
+ {
+ columns.map((column) => {
+ const {
+ name,
+ isVisible
+ } = column;
+
+ if (!isVisible) {
+ return null;
+ }
+
+ if (name === 'movieMetadata.sortTitle') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'movieMetadata.year') {
+ return (
+
+ {year}
+
+ );
+ }
+
+ if (name === 'status') {
+ return (
+
+
+
+ );
+ }
+
+ if (name === 'actions') {
+ return (
+
+ );
+ }
+
+ return null;
+ })
+ }
+
+ );
+}
+
+MissingRow.propTypes = {
+ id: PropTypes.number.isRequired,
+ movieFileId: PropTypes.number,
+ title: PropTypes.string.isRequired,
+ year: PropTypes.number.isRequired,
+ titleSlug: PropTypes.string.isRequired,
+ isSelected: PropTypes.bool,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectedChange: PropTypes.func.isRequired
+};
+
+export default MissingRow;
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index 48d3bd458..7df847de4 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -267,6 +267,8 @@
"Cutoff": "Cutoff",
"CutoffNotMet": "Cutoff Not Met",
"CutoffUnmet": "Cut-off Unmet",
+ "CutoffUnmetLoadError": "Error loading cutoff unmet items",
+ "CutoffUnmetNoItems": "No cutoff unmet items",
"Dash": "Dash",
"Database": "Database",
"DatabaseMigration": "Database Migration",
@@ -783,6 +785,7 @@
"InteractiveImportNoQuality": "Quality must be chosen for each selected file",
"InteractiveSearch": "Interactive Search",
"InteractiveSearchModalHeader": "Interactive Search",
+ "InteractiveSearchModalHeaderTitle": "Interactive Search - {title}",
"InteractiveSearchResultsFailedErrorMessage": "Search failed because its {message}. Try refreshing the movie info and verify the necessary information is present before searching again.",
"Interval": "Interval",
"InvalidFormat": "Invalid Format",
@@ -854,6 +857,7 @@
"MarkAsFailed": "Mark as Failed",
"MarkAsFailedMessageText": "Are you sure you want to mark '{0}' as failed?",
"MassMovieSearch": "Mass Movie Search",
+ "MassSearchCancelWarning": "This cannot be cancelled once started without restarting {appName} or disabling all of your indexers.",
"MatchedToMovie": "Matched to Movie",
"Max": "Max",
"MaximumLimits": "Maximum Limits",
@@ -889,7 +893,9 @@
"MinutesNinety": "90 Minutes: {ninety}",
"MinutesSixty": "60 Minutes: {sixty}",
"Missing": "Missing",
+ "MissingLoadError": "Error loading missing items",
"MissingMonitoredAndConsideredAvailable": "Missing (Monitored)",
+ "MissingNoItems": "No missing items",
"MissingNotMonitored": "Missing (Unmonitored)",
"Mode": "Mode",
"Monday": "Monday",
@@ -897,6 +903,7 @@
"MonitorCollection": "Monitor Collection",
"MonitorMovie": "Monitor Movie",
"MonitorMovies": "Monitor Movies",
+ "MonitorSelected": "Monitor Selected",
"Monitored": "Monitored",
"MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library",
"MonitoredHelpText": "Download movie if available",
@@ -922,6 +929,7 @@
"MovieDetailsPreviousMovie": "Movie Details: Previous Movie",
"MovieDownloadFailedTooltip": "Movie download failed",
"MovieDownloadIgnoredTooltip": "Movie Download Ignored",
+ "MovieDownloaded": "Movie Downloaded",
"MovieEditor": "Movie Editor",
"MovieExcludedFromAutomaticAdd": "Movie Excluded From Automatic Add",
"MovieFileDeleted": "Movie File Deleted",
@@ -948,12 +956,15 @@
"MovieInvalidFormat": "Movie: Invalid Format",
"MovieIsDownloading": "Movie is downloading",
"MovieIsMonitored": "Movie is monitored",
+ "MovieIsNotAvailable": "Movie is not available",
+ "MovieIsNotMonitored": "Movie is not monitored",
"MovieIsOnImportExclusionList": "Movie is on Import Exclusion List",
"MovieIsPopular": "Movie is Popular on TMDb",
"MovieIsRecommend": "Movie is recommended based on recent addition",
"MovieIsTrending": "Movie is Trending on TMDb",
"MovieIsUnmonitored": "Movie is unmonitored",
"MovieMatchType": "Movie Match Type",
+ "MovieMissingFromDisk": "Movie missing from disk",
"MovieNaming": "Movie Naming",
"MovieOnly": "Movie Only",
"MovieSearchResultsLoadError": "Unable to load results for this movie search. Try again later",
@@ -1490,6 +1501,10 @@
"SearchCutoffUnmet": "Search Cutoff Unmet",
"SearchFailedPleaseTryAgainLater": "Search failed, please try again later.",
"SearchFiltered": "Search Filtered",
+ "SearchForAllMissingMovies": "Search for all missing movies",
+ "SearchForAllMissingMoviesConfirmationCount": "Are you sure you want to search for all {totalRecords} missing movies?",
+ "SearchForCutoffUnmetMovies": "Search for all Cutoff Unmet movies",
+ "SearchForCutoffUnmetMoviesConfirmationCount": "Are you sure you want to search for all {totalRecords} Cutoff Unmet movies?",
"SearchForMissing": "Search for Missing",
"SearchForMovie": "Search for movie",
"SearchIsNotSupportedWithThisIndexer": "Search is not supported with this indexer",
@@ -1700,6 +1715,7 @@
"Unlimited": "Unlimited",
"UnmappedFilesOnly": "Unmapped Files Only",
"UnmappedFolders": "Unmapped Folders",
+ "UnmonitorSelected": "Unmonitor Selected",
"Unmonitored": "Unmonitored",
"Unreleased": "Unreleased",
"UnsavedChanges": "Unsaved Changes",
diff --git a/src/NzbDrone.Core/Movies/MovieRepository.cs b/src/NzbDrone.Core/Movies/MovieRepository.cs
index 34042c2d3..66da67cd7 100644
--- a/src/NzbDrone.Core/Movies/MovieRepository.cs
+++ b/src/NzbDrone.Core/Movies/MovieRepository.cs
@@ -48,6 +48,13 @@ namespace NzbDrone.Core.Movies
_alternativeTitleRepository = alternativeTitleRepository;
}
+ protected override IEnumerable PagedQuery(SqlBuilder builder) =>
+ _database.QueryJoined(builder, (movie, movieMetadata) =>
+ {
+ movie.MovieMetadata = movieMetadata;
+ return movie;
+ });
+
protected override SqlBuilder Builder() => new SqlBuilder(_database.DatabaseType)
.Join((m, p) => m.QualityProfileId == p.Id)
.Join((m, p) => m.MovieMetadataId == p.Id)
@@ -242,24 +249,26 @@ namespace NzbDrone.Core.Movies
}
public SqlBuilder MoviesWithoutFilesBuilder() => Builder()
- .Where(x => x.MovieFileId == 0);
+ .Where(x => x.MovieFileId == 0)
+ .GroupBy(e => e.Id);
public PagingSpec MoviesWithoutFiles(PagingSpec pagingSpec)
{
pagingSpec.Records = GetPagedRecords(MoviesWithoutFilesBuilder(), pagingSpec, PagedQuery);
- pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWithoutFilesBuilder().SelectCount(), pagingSpec);
+ pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWithoutFilesBuilder().SelectCountDistinct(x => x.Id), pagingSpec);
return pagingSpec;
}
public SqlBuilder MoviesWhereCutoffUnmetBuilder(List qualitiesBelowCutoff) => Builder()
- .Where(x => x.MovieFileId != 0)
- .Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff));
+ .Where(x => x.MovieFileId != 0)
+ .Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff))
+ .GroupBy(e => e.Id);
public PagingSpec MoviesWhereCutoffUnmet(PagingSpec pagingSpec, List qualitiesBelowCutoff)
{
pagingSpec.Records = GetPagedRecords(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff), pagingSpec, PagedQuery);
- pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff).SelectCount(), pagingSpec);
+ pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff).SelectCountDistinct(x => x.Id), pagingSpec);
return pagingSpec;
}
diff --git a/src/Radarr.Api.V3/Calendar/CalendarController.cs b/src/Radarr.Api.V3/Calendar/CalendarController.cs
index 9deb71fee..e1316b20f 100644
--- a/src/Radarr.Api.V3/Calendar/CalendarController.cs
+++ b/src/Radarr.Api.V3/Calendar/CalendarController.cs
@@ -4,8 +4,8 @@ using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
+using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
-using NzbDrone.Core.Languages;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Movies.Translations;
using NzbDrone.Core.MovieStats;
@@ -13,35 +13,27 @@ using NzbDrone.Core.Tags;
using NzbDrone.SignalR;
using Radarr.Api.V3.Movies;
using Radarr.Http;
-using Radarr.Http.REST;
namespace Radarr.Api.V3.Calendar
{
[V3ApiController]
- public class CalendarController : RestControllerWithSignalR
+ public class CalendarController : MovieControllerWithSignalR
{
private readonly IMovieService _moviesService;
- private readonly IMovieTranslationService _movieTranslationService;
- private readonly IMovieStatisticsService _movieStatisticsService;
- private readonly IUpgradableSpecification _qualityUpgradableSpecification;
private readonly ITagService _tagService;
- private readonly IConfigService _configService;
public CalendarController(IBroadcastSignalRMessage signalR,
- IMovieService moviesService,
+ IMovieService movieService,
IMovieTranslationService movieTranslationService,
IMovieStatisticsService movieStatisticsService,
- IUpgradableSpecification qualityUpgradableSpecification,
+ IUpgradableSpecification upgradableSpecification,
+ ICustomFormatCalculationService formatCalculator,
ITagService tagService,
IConfigService configService)
- : base(signalR)
+ : base(movieService, movieTranslationService, movieStatisticsService, upgradableSpecification, formatCalculator, configService, signalR)
{
- _moviesService = moviesService;
- _movieTranslationService = movieTranslationService;
- _movieStatisticsService = movieStatisticsService;
- _qualityUpgradableSpecification = qualityUpgradableSpecification;
+ _moviesService = movieService;
_tagService = tagService;
- _configService = configService;
}
[NonAction]
@@ -84,56 +76,5 @@ namespace Radarr.Api.V3.Calendar
return resources.OrderBy(e => e.InCinemas).ToList();
}
-
- protected List MapToResource(List movies)
- {
- var resources = new List();
- var availDelay = _configService.AvailabilityDelay;
- var language = (Language)_configService.MovieInfoLanguage;
-
- foreach (var movie in movies)
- {
- if (movie == null)
- {
- continue;
- }
-
- var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId);
- var translation = GetMovieTranslation(translations, movie.MovieMetadata, language);
-
- var resource = movie.ToResource(availDelay, translation, _qualityUpgradableSpecification);
- FetchAndLinkMovieStatistics(resource);
-
- resources.Add(resource);
- }
-
- return resources;
- }
-
- private MovieTranslation GetMovieTranslation(List translations, MovieMetadata movie, Language language)
- {
- if (language == Language.Original)
- {
- return new MovieTranslation
- {
- Title = movie.OriginalTitle,
- Overview = movie.Overview
- };
- }
-
- return translations.FirstOrDefault(t => t.Language == language && t.MovieMetadataId == movie.Id);
- }
-
- private void FetchAndLinkMovieStatistics(MovieResource resource)
- {
- LinkMovieStatistics(resource, _movieStatisticsService.MovieStatistics(resource.Id));
- }
-
- private void LinkMovieStatistics(MovieResource resource, MovieStatistics movieStatistics)
- {
- resource.Statistics = movieStatistics.ToResource();
- resource.HasFile = movieStatistics.MovieFileCount > 0;
- resource.SizeOnDisk = movieStatistics.SizeOnDisk;
- }
}
}
diff --git a/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs b/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs
new file mode 100644
index 000000000..455ce6b4c
--- /dev/null
+++ b/src/Radarr.Api.V3/Movies/MovieControllerWithSignalR.cs
@@ -0,0 +1,159 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore.Events;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Languages;
+using NzbDrone.Core.MediaFiles.Events;
+using NzbDrone.Core.Messaging.Events;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Translations;
+using NzbDrone.Core.MovieStats;
+using NzbDrone.SignalR;
+using Radarr.Http.REST;
+
+namespace Radarr.Api.V3.Movies
+{
+ public abstract class MovieControllerWithSignalR : RestControllerWithSignalR,
+ IHandle,
+ IHandle,
+ IHandle
+ {
+ protected readonly IMovieService _movieService;
+ protected readonly IMovieTranslationService _movieTranslationService;
+ protected readonly IMovieStatisticsService _movieStatisticsService;
+ protected readonly IUpgradableSpecification _upgradableSpecification;
+ protected readonly ICustomFormatCalculationService _formatCalculator;
+ protected readonly IConfigService _configService;
+
+ protected MovieControllerWithSignalR(IMovieService movieService,
+ IMovieTranslationService movieTranslationService,
+ IMovieStatisticsService movieStatisticsService,
+ IUpgradableSpecification upgradableSpecification,
+ ICustomFormatCalculationService formatCalculator,
+ IConfigService configService,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(signalRBroadcaster)
+ {
+ _movieService = movieService;
+ _movieTranslationService = movieTranslationService;
+ _movieStatisticsService = movieStatisticsService;
+ _upgradableSpecification = upgradableSpecification;
+ _formatCalculator = formatCalculator;
+ _configService = configService;
+ }
+
+ protected MovieControllerWithSignalR(IMovieService movieService,
+ IUpgradableSpecification upgradableSpecification,
+ ICustomFormatCalculationService formatCalculator,
+ IBroadcastSignalRMessage signalRBroadcaster,
+ string resource)
+ : base(signalRBroadcaster)
+ {
+ _movieService = movieService;
+ _upgradableSpecification = upgradableSpecification;
+ _formatCalculator = formatCalculator;
+ }
+
+ protected override MovieResource GetResourceById(int id)
+ {
+ var movie = _movieService.GetMovie(id);
+ var resource = MapToResource(movie);
+ return resource;
+ }
+
+ protected MovieResource MapToResource(Movie movie)
+ {
+ if (movie == null)
+ {
+ return null;
+ }
+
+ var availDelay = _configService.AvailabilityDelay;
+ var language = (Language)_configService.MovieInfoLanguage;
+
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, movie.MovieMetadata, language);
+
+ var resource = movie.ToResource(availDelay, translation, _upgradableSpecification, _formatCalculator);
+ FetchAndLinkMovieStatistics(resource);
+
+ return resource;
+ }
+
+ protected List MapToResource(List movies)
+ {
+ var resources = new List();
+ var availDelay = _configService.AvailabilityDelay;
+ var language = (Language)_configService.MovieInfoLanguage;
+
+ foreach (var movie in movies)
+ {
+ if (movie == null)
+ {
+ continue;
+ }
+
+ var translations = _movieTranslationService.GetAllTranslationsForMovieMetadata(movie.MovieMetadataId);
+ var translation = GetMovieTranslation(translations, movie.MovieMetadata, language);
+
+ var resource = movie.ToResource(availDelay, translation, _upgradableSpecification, _formatCalculator);
+ FetchAndLinkMovieStatistics(resource);
+
+ resources.Add(resource);
+ }
+
+ return resources;
+ }
+
+ private MovieTranslation GetMovieTranslation(List translations, MovieMetadata movie, Language language)
+ {
+ if (language == Language.Original)
+ {
+ return new MovieTranslation
+ {
+ Title = movie.OriginalTitle,
+ Overview = movie.Overview
+ };
+ }
+
+ return translations.FirstOrDefault(t => t.Language == language && t.MovieMetadataId == movie.Id);
+ }
+
+ private void FetchAndLinkMovieStatistics(MovieResource resource)
+ {
+ LinkMovieStatistics(resource, _movieStatisticsService.MovieStatistics(resource.Id));
+ }
+
+ private void LinkMovieStatistics(MovieResource resource, MovieStatistics movieStatistics)
+ {
+ resource.Statistics = movieStatistics.ToResource();
+ resource.HasFile = movieStatistics.MovieFileCount > 0;
+ resource.SizeOnDisk = movieStatistics.SizeOnDisk;
+ }
+
+ [NonAction]
+ public void Handle(MovieGrabbedEvent message)
+ {
+ var resource = message.Movie.Movie.ToResource(0, null, _upgradableSpecification, _formatCalculator);
+ resource.Grabbed = true;
+
+ BroadcastResourceChange(ModelAction.Updated, resource);
+ }
+
+ [NonAction]
+ public void Handle(MovieFileImportedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.MovieInfo.Movie.Id);
+ }
+
+ [NonAction]
+ public void Handle(MovieFileDeletedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.MovieFile.Movie.Id);
+ }
+ }
+}
diff --git a/src/Radarr.Api.V3/Movies/MovieResource.cs b/src/Radarr.Api.V3/Movies/MovieResource.cs
index 44e0bf03f..ef0cb5e2f 100644
--- a/src/Radarr.Api.V3/Movies/MovieResource.cs
+++ b/src/Radarr.Api.V3/Movies/MovieResource.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Text.Json.Serialization;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
@@ -56,6 +57,7 @@ namespace Radarr.Api.V3.Movies
// Compatibility
public bool? HasFile { get; set; }
+ public int MovieFileId { get; set; }
// Editing Only
public bool Monitored { get; set; }
@@ -80,6 +82,10 @@ namespace Radarr.Api.V3.Movies
public MovieCollectionResource Collection { get; set; }
public float Popularity { get; set; }
public MovieStatisticsResource Statistics { get; set; }
+
+ // Hiding this so people don't think its usable (only used to set the initial state)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public bool Grabbed { get; set; }
}
public static class MovieResourceMapper
@@ -118,6 +124,8 @@ namespace Radarr.Api.V3.Movies
Year = model.Year,
SecondaryYear = model.MovieMetadata.Value.SecondaryYear,
+ MovieFileId = model.MovieFileId,
+
Path = model.Path,
QualityProfileId = model.QualityProfileId,
diff --git a/src/Radarr.Api.V3/Wanted/CutoffController.cs b/src/Radarr.Api.V3/Wanted/CutoffController.cs
new file mode 100644
index 000000000..f43076796
--- /dev/null
+++ b/src/Radarr.Api.V3/Wanted/CutoffController.cs
@@ -0,0 +1,61 @@
+using System;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Translations;
+using NzbDrone.Core.MovieStats;
+using NzbDrone.SignalR;
+using Radarr.Api.V3.Movies;
+using Radarr.Http;
+using Radarr.Http.Extensions;
+
+namespace Radarr.Api.V3.Wanted
+{
+ [V3ApiController("wanted/cutoff")]
+ public class CutoffController : MovieControllerWithSignalR
+ {
+ private readonly IMovieCutoffService _movieCutoffService;
+
+ public CutoffController(IMovieCutoffService movieCutoffService,
+ IMovieService movieService,
+ IMovieTranslationService movieTranslationService,
+ IMovieStatisticsService movieStatisticsService,
+ IUpgradableSpecification upgradableSpecification,
+ ICustomFormatCalculationService formatCalculator,
+ IConfigService configService,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(movieService, movieTranslationService, movieStatisticsService, upgradableSpecification, formatCalculator, configService, signalRBroadcaster)
+ {
+ _movieCutoffService = movieCutoffService;
+ }
+
+ [NonAction]
+ protected override MovieResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ [HttpGet]
+ [Produces("application/json")]
+ public PagingResource GetCutoffUnmetMovies([FromQuery] PagingRequestResource paging, bool monitored = true)
+ {
+ var pagingResource = new PagingResource(paging);
+ var pagingSpec = new PagingSpec
+ {
+ Page = pagingResource.Page,
+ PageSize = pagingResource.PageSize,
+ SortKey = pagingResource.SortKey,
+ SortDirection = pagingResource.SortDirection
+ };
+
+ pagingSpec.FilterExpressions.Add(v => v.Monitored == monitored);
+
+ var resource = pagingSpec.ApplyToPage(_movieCutoffService.MoviesWhereCutoffUnmet, v => MapToResource(v));
+
+ return resource;
+ }
+ }
+}
diff --git a/src/Radarr.Api.V3/Wanted/MissingController.cs b/src/Radarr.Api.V3/Wanted/MissingController.cs
new file mode 100644
index 000000000..953725782
--- /dev/null
+++ b/src/Radarr.Api.V3/Wanted/MissingController.cs
@@ -0,0 +1,57 @@
+using System;
+using Microsoft.AspNetCore.Mvc;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Datastore;
+using NzbDrone.Core.DecisionEngine.Specifications;
+using NzbDrone.Core.Movies;
+using NzbDrone.Core.Movies.Translations;
+using NzbDrone.Core.MovieStats;
+using NzbDrone.SignalR;
+using Radarr.Api.V3.Movies;
+using Radarr.Http;
+using Radarr.Http.Extensions;
+
+namespace Radarr.Api.V3.Wanted
+{
+ [V3ApiController("wanted/missing")]
+ public class MissingController : MovieControllerWithSignalR
+ {
+ public MissingController(IMovieService movieService,
+ IMovieTranslationService movieTranslationService,
+ IMovieStatisticsService movieStatisticsService,
+ IUpgradableSpecification upgradableSpecification,
+ ICustomFormatCalculationService formatCalculator,
+ IConfigService configService,
+ IBroadcastSignalRMessage signalRBroadcaster)
+ : base(movieService, movieTranslationService, movieStatisticsService, upgradableSpecification, formatCalculator, configService, signalRBroadcaster)
+ {
+ }
+
+ [NonAction]
+ protected override MovieResource GetResourceById(int id)
+ {
+ throw new NotImplementedException();
+ }
+
+ [HttpGet]
+ [Produces("application/json")]
+ public PagingResource GetMissingMovies([FromQuery] PagingRequestResource paging, bool monitored = true)
+ {
+ var pagingResource = new PagingResource(paging);
+ var pagingSpec = new PagingSpec
+ {
+ Page = pagingResource.Page,
+ PageSize = pagingResource.PageSize,
+ SortKey = pagingResource.SortKey,
+ SortDirection = pagingResource.SortDirection
+ };
+
+ pagingSpec.FilterExpressions.Add(v => v.Monitored == monitored);
+
+ var resource = pagingSpec.ApplyToPage(_movieService.MoviesWithoutFiles, v => MapToResource(v));
+
+ return resource;
+ }
+ }
+}