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 @@ public MovieRepository(IMainDatabase database, _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 @@ public List MoviesBetweenDates(DateTime start, DateTime end, bool include } 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 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.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 @@ public List GetCalendar(DateTime? start, DateTime? end, bool unmo 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 @@ public MovieResource() // Compatibility public bool? HasFile { get; set; } + public int MovieFileId { get; set; } // Editing Only public bool Monitored { get; set; } @@ -80,6 +82,10 @@ public MovieResource() 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 @@ public static MovieResource ToResource(this Movie model, int availDelay, MovieTr 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; + } + } +}