1
0
mirror of https://github.com/Radarr/Radarr.git synced 2024-07-07 04:19:25 +02:00

New: Wanted Cutoff/Missing

This commit is contained in:
Bogdan 2024-01-30 22:06:48 +02:00
parent 9798202589
commit 152f50a1ef
37 changed files with 2267 additions and 88 deletions

View File

@ -33,6 +33,8 @@ import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks'; import Tasks from 'System/Tasks/Tasks';
import UpdatesConnector from 'System/Updates/UpdatesConnector'; import UpdatesConnector from 'System/Updates/UpdatesConnector';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase'; import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
function AppRoutes(props) { function AppRoutes(props) {
const { const {
@ -121,6 +123,20 @@ function AppRoutes(props) {
component={BlocklistConnector} component={BlocklistConnector}
/> />
{/*
Wanted
*/}
<Route
path="/wanted/missing"
component={MissingConnector}
/>
<Route
path="/wanted/cutoffunmet"
component={CutoffUnmetConnector}
/>
{/* {/*
Settings Settings
*/} */}

View File

@ -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, iconName: icons.SETTINGS,
title: () => translate('Settings'), title: () => translate('Settings'),

View File

@ -244,6 +244,26 @@ class SignalRConnector extends Component {
this.props.dispatchSetVersion({ version }); 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 = () => { handleSystemTask = () => {
this.props.dispatchFetchCommands(); this.props.dispatchFetchCommands();
}; };

View File

@ -0,0 +1,6 @@
.movieSearchCell {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 70px;
white-space: nowrap;
}

View File

@ -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;

View File

@ -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 (
<TableRowCell className={styles.movieSearchCell}>
<SpinnerIconButton
name={icons.SEARCH}
isSpinning={isSearching}
onPress={onSearchPress}
title={translate('AutomaticSearch')}
/>
<IconButton
name={icons.INTERACTIVE}
onPress={this.onManualSearchPress}
title={translate('InteractiveSearch')}
/>
<MovieInteractiveSearchModalConnector
isOpen={this.state.isInteractiveSearchModalOpen}
movieId={movieId}
movieTitle={movieTitle}
onModalClose={this.onInteractiveSearchModalClose}
{...otherProps}
/>
</TableRowCell>
);
}
}
MovieSearchCell.propTypes = {
movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string.isRequired,
isSearching: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default MovieSearchCell;

View File

@ -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);

View File

@ -0,0 +1,4 @@
.center {
display: flex;
justify-content: center;
}

View File

@ -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;

View File

@ -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 (
<div className={styles.center}>
<QueueDetails
{...queueItem}
progressBar={
<ProgressBar
progress={progress}
kind={kinds.PURPLE}
size={sizes.MEDIUM}
/>
}
/>
</div>
);
}
if (grabbed) {
return (
<div className={styles.center}>
<Icon
name={icons.DOWNLOADING}
title={translate('MovieIsDownloading')}
/>
</div>
);
}
if (hasMovieFile) {
const quality = movieFile.quality;
const isCutoffNotMet = movieFile.qualityCutoffNotMet;
return (
<div className={styles.center}>
<MovieQuality
quality={quality}
size={movieFile.size}
isCutoffNotMet={isCutoffNotMet}
title={translate('MovieDownloaded')}
/>
</div>
);
}
if (!monitored) {
return (
<div className={styles.center}>
<Icon
name={icons.UNMONITORED}
kind={kinds.DISABLED}
title={translate('MovieIsNotMonitored')}
/>
</div>
);
}
if (isAvailable) {
return (
<div className={styles.center}>
<Icon
name={icons.MISSING}
title={translate('MovieMissingFromDisk')}
/>
</div>
);
}
return (
<div className={styles.center}>
<Icon
name={icons.NOT_AIRED}
title={translate('MovieIsNotAvailable')}
/>
</div>
);
}
MovieStatus.propTypes = {
isAvailable: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
grabbed: PropTypes.bool,
queueItem: PropTypes.object,
movieFile: PropTypes.object
};
export default MovieStatus;

View File

@ -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 (
<MovieStatus
{...this.props}
/>
);
}
}
MovieStatusConnector.propTypes = {
movieId: PropTypes.number.isRequired,
movieFileId: PropTypes.number.isRequired
};
export default connect(createMapStateToProps, null)(MovieStatusConnector);

View File

@ -8,6 +8,7 @@ function MovieInteractiveSearchModal(props) {
const { const {
isOpen, isOpen,
movieId, movieId,
movieTitle,
onModalClose onModalClose
} = props; } = props;
@ -20,6 +21,7 @@ function MovieInteractiveSearchModal(props) {
> >
<MovieInteractiveSearchModalContent <MovieInteractiveSearchModalContent
movieId={movieId} movieId={movieId}
movieTitle={movieTitle}
onModalClose={onModalClose} onModalClose={onModalClose}
/> />
</Modal> </Modal>
@ -29,6 +31,7 @@ function MovieInteractiveSearchModal(props) {
MovieInteractiveSearchModal.propTypes = { MovieInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
movieId: PropTypes.number.isRequired, movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired
}; };

View File

@ -12,13 +12,17 @@ import translate from 'Utilities/String/translate';
function MovieInteractiveSearchModalContent(props) { function MovieInteractiveSearchModalContent(props) {
const { const {
movieId, movieId,
movieTitle,
onModalClose onModalClose
} = props; } = props;
return ( return (
<ModalContent onModalClose={onModalClose}> <ModalContent onModalClose={onModalClose}>
<ModalHeader> <ModalHeader>
{translate('InteractiveSearchModalHeader')} {movieTitle === undefined ?
translate('InteractiveSearchModalHeader') :
translate('InteractiveSearchModalHeaderTitle', { title: movieTitle })
}
</ModalHeader> </ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}> <ModalBody scrollDirection={scrollDirections.BOTH}>
@ -38,6 +42,7 @@ function MovieInteractiveSearchModalContent(props) {
MovieInteractiveSearchModalContent.propTypes = { MovieInteractiveSearchModalContent.propTypes = {
movieId: PropTypes.number.isRequired, movieId: PropTypes.number.isRequired,
movieTitle: PropTypes.string,
onModalClose: PropTypes.func.isRequired onModalClose: PropTypes.func.isRequired
}; };

View File

@ -1,9 +1,13 @@
export const CALENDAR = 'calendar'; export const CALENDAR = 'calendar';
export const MOVIES = 'movies'; export const MOVIES = 'movies';
export const INTERACTIVE_IMPORT = 'interactiveImport.movies'; export const INTERACTIVE_IMPORT = 'interactiveImport.movies';
export const WANTED_CUTOFF_UNMET = 'wanted.cutoffUnmet';
export const WANTED_MISSING = 'wanted.missing';
export default { export default {
CALENDAR, CALENDAR,
MOVIES, MOVIES,
INTERACTIVE_IMPORT INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
}; };

View File

@ -8,7 +8,7 @@ function createMapStateToProps() {
createMovieFileSelector(), createMovieFileSelector(),
(movieFile) => { (movieFile) => {
return { return {
language: movieFile ? movieFile.language : undefined languages: movieFile ? movieFile.languages : undefined
}; };
} }
); );

View File

@ -1,29 +1,29 @@
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import updateEpisodes from 'Utilities/Episode/updateEpisodes'; import updateMovies from 'Utilities/Movie/updateMovies';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) { function createBatchToggleMovieMonitoredHandler(section, fetchHandler) {
return function(getState, payload, dispatch) { return function(getState, payload, dispatch) {
const { const {
episodeIds, movieIds,
monitored monitored
} = payload; } = payload;
const state = getSectionState(getState(), section, true); const state = getSectionState(getState(), section, true);
dispatch(updateEpisodes(section, state.items, episodeIds, { dispatch(updateMovies(section, state.items, movieIds, {
isSaving: true isSaving: true
})); }));
const promise = createAjaxRequest({ const promise = createAjaxRequest({
url: '/episode/monitor', url: '/movie/editor',
method: 'PUT', method: 'PUT',
data: JSON.stringify({ episodeIds, monitored }), data: JSON.stringify({ movieIds, monitored }),
dataType: 'json' dataType: 'json'
}).request; }).request;
promise.done(() => { promise.done(() => {
dispatch(updateEpisodes(section, state.items, episodeIds, { dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false, isSaving: false,
monitored monitored
})); }));
@ -32,11 +32,11 @@ function createBatchToggleEpisodeMonitoredHandler(section, fetchHandler) {
}); });
promise.fail(() => { promise.fail(() => {
dispatch(updateEpisodes(section, state.items, episodeIds, { dispatch(updateMovies(section, state.items, movieIds, {
isSaving: false isSaving: false
})); }));
}); });
}; };
} }
export default createBatchToggleEpisodeMonitoredHandler; export default createBatchToggleMovieMonitoredHandler;

View File

@ -28,6 +28,7 @@ import * as rootFolders from './rootFolderActions';
import * as settings from './settingsActions'; import * as settings from './settingsActions';
import * as system from './systemActions'; import * as system from './systemActions';
import * as tags from './tagActions'; import * as tags from './tagActions';
import * as wanted from './wantedActions';
export default [ export default [
addMovie, addMovie,
@ -59,5 +60,6 @@ export default [
movieCredits, movieCredits,
settings, settings,
system, system,
tags tags,
wanted
]; ];

View File

@ -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);

View File

@ -1,4 +1,6 @@
import _ from 'lodash';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import movieEntities from 'Movie/movieEntities';
export function createMovieSelectorForHook(movieId) { export function createMovieSelectorForHook(movieId) {
return createSelector( 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() { function createMovieSelector() {
return createSelector( return createSelector(
(state, { movieId }) => movieId, (state, { movieId }) => movieId,

View File

@ -1,9 +1,9 @@
import _ from 'lodash'; import _ from 'lodash';
import { update } from 'Store/Actions/baseActions'; import { update } from 'Store/Actions/baseActions';
function updateEpisodes(section, episodes, episodeIds, options) { function updateMovies(section, movies, movieIds, options) {
const data = _.reduce(episodes, (result, item) => { const data = _.reduce(movies, (result, item) => {
if (episodeIds.indexOf(item.id) > -1) { if (movieIds.indexOf(item.id) > -1) {
result.push({ result.push({
...item, ...item,
...options ...options
@ -18,4 +18,4 @@ function updateEpisodes(section, episodes, episodeIds, options) {
return update({ section, data }); return update({ section, data });
} }
export default updateEpisodes; export default updateMovies;

View File

@ -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 (
<PageContent title={translate('CutoffUnmet')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForCutoffUnmetMovies}
onPress={this.onSearchSelectedPress}
/>
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
isDisabled={!itemsSelected}
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForCutoffUnmetMovies}
onPress={this.onSearchAllCutoffUnmetPress}
/>
<PageToolbarSeparator />
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('CutoffUnmetLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('CutoffUnmetNoItems')}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<CutoffUnmetRow
key={item.id}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
<ConfirmModal
isOpen={isConfirmSearchAllCutoffUnmetModalOpen}
kind={kinds.DANGER}
title={translate('SearchForCutoffUnmetMovies')}
message={
<div>
<div>
{translate('SearchForCutoffUnmetMoviesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchAllCutoffUnmetConfirmed}
onCancel={this.onConfirmSearchAllCutoffUnmetModalClose}
/>
</div>
}
</PageContentBody>
</PageContent>
);
}
}
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;

View File

@ -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 (
<CutoffUnmet
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onSearchSelectedPress={this.onSearchSelectedPress}
onToggleSelectedPress={this.onToggleSelectedPress}
onSearchAllCutoffUnmetPress={this.onSearchAllCutoffUnmetPress}
{...this.props}
/>
);
}
}
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)
);

View File

@ -0,0 +1,6 @@
.languages,
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View File

@ -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;

View File

@ -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 (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'movieMetadata.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
);
}
if (name === 'movieMetadata.year') {
return (
<TableRowCell key={name}>
{year}
</TableRowCell>
);
}
if (name === 'languages') {
return (
<TableRowCell
key={name}
className={styles.languages}
>
<MovieFileLanguageConnector
movieFileId={movieFileId}
/>
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<MovieStatusConnector
movieId={id}
movieFileId={movieFileId}
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<MovieSearchCellConnector
key={name}
movieId={id}
movieTitle={title}
movieEntity={movieEntities.WANTED_CUTOFF_UNMET}
/>
);
}
return null;
})
}
</TableRow>
);
}
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;

View File

@ -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 (
<PageContent title={translate('Missing')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('SearchSelected')}
iconName={icons.SEARCH}
isDisabled={!itemsSelected || isSearchingForMissingMovies}
onPress={this.onSearchSelectedPress}
/>
<PageToolbarButton
label={isShowingMonitored ? translate('UnmonitorSelected') : translate('MonitorSelected')}
iconName={icons.MONITORED}
isDisabled={!itemsSelected}
isSpinning={isSaving}
onPress={this.onToggleSelectedPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('SearchAll')}
iconName={icons.SEARCH}
isDisabled={!items.length}
isSpinning={isSearchingForMissingMovies}
onPress={this.onSearchAllMissingPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('ManualImport')}
iconName={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={[]}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('MissingLoadError')}
</Alert>
}
{
isPopulated && !error && !items.length &&
<Alert kind={kinds.INFO}>
{translate('MissingNoItems')}
</Alert>
}
{
isPopulated && !error && !!items.length &&
<div>
<Table
columns={columns}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
{...otherProps}
onSelectAllChange={this.onSelectAllChange}
>
<TableBody>
{
items.map((item) => {
return (
<MissingRow
key={item.id}
isSelected={selectedState[item.id]}
columns={columns}
{...item}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</TableBody>
</Table>
<TablePager
totalRecords={totalRecords}
isFetching={isFetching}
{...otherProps}
/>
<ConfirmModal
isOpen={isConfirmSearchAllMissingModalOpen}
kind={kinds.DANGER}
title={translate('SearchForAllMissingMovies')}
message={
<div>
<div>
{translate('SearchForAllMissingMoviesConfirmationCount', { totalRecords })}
</div>
<div>
{translate('MassSearchCancelWarning')}
</div>
</div>
}
confirmLabel={translate('Search')}
onConfirm={this.onSearchAllMissingConfirmed}
onCancel={this.onConfirmSearchAllMissingModalClose}
/>
</div>
}
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
onModalClose={this.onInteractiveImportModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
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;

View File

@ -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 (
<Missing
onFirstPagePress={this.onFirstPagePress}
onPreviousPagePress={this.onPreviousPagePress}
onNextPagePress={this.onNextPagePress}
onLastPagePress={this.onLastPagePress}
onPageSelect={this.onPageSelect}
onSortPress={this.onSortPress}
onFilterSelect={this.onFilterSelect}
onTableOptionChange={this.onTableOptionChange}
onSearchSelectedPress={this.onSearchSelectedPress}
onSearchAllMissingPress={this.onSearchAllMissingPress}
{...this.props}
/>
);
}
}
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)
);

View File

@ -0,0 +1,5 @@
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}

View File

@ -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;

View File

@ -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 (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'movieMetadata.sortTitle') {
return (
<TableRowCell key={name}>
<MovieTitleLink
titleSlug={titleSlug}
title={title}
/>
</TableRowCell>
);
}
if (name === 'movieMetadata.year') {
return (
<TableRowCell key={name}>
{year}
</TableRowCell>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<MovieStatusConnector
movieId={id}
movieFileId={movieFileId}
movieEntity={movieEntities.WANTED_MISSING}
/>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<MovieSearchCellConnector
key={name}
movieId={id}
movieTitle={title}
movieEntity={movieEntities.WANTED_MISSING}
/>
);
}
return null;
})
}
</TableRow>
);
}
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;

View File

@ -267,6 +267,8 @@
"Cutoff": "Cutoff", "Cutoff": "Cutoff",
"CutoffNotMet": "Cutoff Not Met", "CutoffNotMet": "Cutoff Not Met",
"CutoffUnmet": "Cut-off Unmet", "CutoffUnmet": "Cut-off Unmet",
"CutoffUnmetLoadError": "Error loading cutoff unmet items",
"CutoffUnmetNoItems": "No cutoff unmet items",
"Dash": "Dash", "Dash": "Dash",
"Database": "Database", "Database": "Database",
"DatabaseMigration": "Database Migration", "DatabaseMigration": "Database Migration",
@ -783,6 +785,7 @@
"InteractiveImportNoQuality": "Quality must be chosen for each selected file", "InteractiveImportNoQuality": "Quality must be chosen for each selected file",
"InteractiveSearch": "Interactive Search", "InteractiveSearch": "Interactive Search",
"InteractiveSearchModalHeader": "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.", "InteractiveSearchResultsFailedErrorMessage": "Search failed because its {message}. Try refreshing the movie info and verify the necessary information is present before searching again.",
"Interval": "Interval", "Interval": "Interval",
"InvalidFormat": "Invalid Format", "InvalidFormat": "Invalid Format",
@ -854,6 +857,7 @@
"MarkAsFailed": "Mark as Failed", "MarkAsFailed": "Mark as Failed",
"MarkAsFailedMessageText": "Are you sure you want to mark '{0}' as failed?", "MarkAsFailedMessageText": "Are you sure you want to mark '{0}' as failed?",
"MassMovieSearch": "Mass Movie Search", "MassMovieSearch": "Mass Movie Search",
"MassSearchCancelWarning": "This cannot be cancelled once started without restarting {appName} or disabling all of your indexers.",
"MatchedToMovie": "Matched to Movie", "MatchedToMovie": "Matched to Movie",
"Max": "Max", "Max": "Max",
"MaximumLimits": "Maximum Limits", "MaximumLimits": "Maximum Limits",
@ -889,7 +893,9 @@
"MinutesNinety": "90 Minutes: {ninety}", "MinutesNinety": "90 Minutes: {ninety}",
"MinutesSixty": "60 Minutes: {sixty}", "MinutesSixty": "60 Minutes: {sixty}",
"Missing": "Missing", "Missing": "Missing",
"MissingLoadError": "Error loading missing items",
"MissingMonitoredAndConsideredAvailable": "Missing (Monitored)", "MissingMonitoredAndConsideredAvailable": "Missing (Monitored)",
"MissingNoItems": "No missing items",
"MissingNotMonitored": "Missing (Unmonitored)", "MissingNotMonitored": "Missing (Unmonitored)",
"Mode": "Mode", "Mode": "Mode",
"Monday": "Monday", "Monday": "Monday",
@ -897,6 +903,7 @@
"MonitorCollection": "Monitor Collection", "MonitorCollection": "Monitor Collection",
"MonitorMovie": "Monitor Movie", "MonitorMovie": "Monitor Movie",
"MonitorMovies": "Monitor Movies", "MonitorMovies": "Monitor Movies",
"MonitorSelected": "Monitor Selected",
"Monitored": "Monitored", "Monitored": "Monitored",
"MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library", "MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library",
"MonitoredHelpText": "Download movie if available", "MonitoredHelpText": "Download movie if available",
@ -922,6 +929,7 @@
"MovieDetailsPreviousMovie": "Movie Details: Previous Movie", "MovieDetailsPreviousMovie": "Movie Details: Previous Movie",
"MovieDownloadFailedTooltip": "Movie download failed", "MovieDownloadFailedTooltip": "Movie download failed",
"MovieDownloadIgnoredTooltip": "Movie Download Ignored", "MovieDownloadIgnoredTooltip": "Movie Download Ignored",
"MovieDownloaded": "Movie Downloaded",
"MovieEditor": "Movie Editor", "MovieEditor": "Movie Editor",
"MovieExcludedFromAutomaticAdd": "Movie Excluded From Automatic Add", "MovieExcludedFromAutomaticAdd": "Movie Excluded From Automatic Add",
"MovieFileDeleted": "Movie File Deleted", "MovieFileDeleted": "Movie File Deleted",
@ -948,12 +956,15 @@
"MovieInvalidFormat": "Movie: Invalid Format", "MovieInvalidFormat": "Movie: Invalid Format",
"MovieIsDownloading": "Movie is downloading", "MovieIsDownloading": "Movie is downloading",
"MovieIsMonitored": "Movie is monitored", "MovieIsMonitored": "Movie is monitored",
"MovieIsNotAvailable": "Movie is not available",
"MovieIsNotMonitored": "Movie is not monitored",
"MovieIsOnImportExclusionList": "Movie is on Import Exclusion List", "MovieIsOnImportExclusionList": "Movie is on Import Exclusion List",
"MovieIsPopular": "Movie is Popular on TMDb", "MovieIsPopular": "Movie is Popular on TMDb",
"MovieIsRecommend": "Movie is recommended based on recent addition", "MovieIsRecommend": "Movie is recommended based on recent addition",
"MovieIsTrending": "Movie is Trending on TMDb", "MovieIsTrending": "Movie is Trending on TMDb",
"MovieIsUnmonitored": "Movie is unmonitored", "MovieIsUnmonitored": "Movie is unmonitored",
"MovieMatchType": "Movie Match Type", "MovieMatchType": "Movie Match Type",
"MovieMissingFromDisk": "Movie missing from disk",
"MovieNaming": "Movie Naming", "MovieNaming": "Movie Naming",
"MovieOnly": "Movie Only", "MovieOnly": "Movie Only",
"MovieSearchResultsLoadError": "Unable to load results for this movie search. Try again later", "MovieSearchResultsLoadError": "Unable to load results for this movie search. Try again later",
@ -1490,6 +1501,10 @@
"SearchCutoffUnmet": "Search Cutoff Unmet", "SearchCutoffUnmet": "Search Cutoff Unmet",
"SearchFailedPleaseTryAgainLater": "Search failed, please try again later.", "SearchFailedPleaseTryAgainLater": "Search failed, please try again later.",
"SearchFiltered": "Search Filtered", "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", "SearchForMissing": "Search for Missing",
"SearchForMovie": "Search for movie", "SearchForMovie": "Search for movie",
"SearchIsNotSupportedWithThisIndexer": "Search is not supported with this indexer", "SearchIsNotSupportedWithThisIndexer": "Search is not supported with this indexer",
@ -1700,6 +1715,7 @@
"Unlimited": "Unlimited", "Unlimited": "Unlimited",
"UnmappedFilesOnly": "Unmapped Files Only", "UnmappedFilesOnly": "Unmapped Files Only",
"UnmappedFolders": "Unmapped Folders", "UnmappedFolders": "Unmapped Folders",
"UnmonitorSelected": "Unmonitor Selected",
"Unmonitored": "Unmonitored", "Unmonitored": "Unmonitored",
"Unreleased": "Unreleased", "Unreleased": "Unreleased",
"UnsavedChanges": "Unsaved Changes", "UnsavedChanges": "Unsaved Changes",

View File

@ -48,6 +48,13 @@ public MovieRepository(IMainDatabase database,
_alternativeTitleRepository = alternativeTitleRepository; _alternativeTitleRepository = alternativeTitleRepository;
} }
protected override IEnumerable<Movie> PagedQuery(SqlBuilder builder) =>
_database.QueryJoined<Movie, MovieMetadata>(builder, (movie, movieMetadata) =>
{
movie.MovieMetadata = movieMetadata;
return movie;
});
protected override SqlBuilder Builder() => new SqlBuilder(_database.DatabaseType) protected override SqlBuilder Builder() => new SqlBuilder(_database.DatabaseType)
.Join<Movie, QualityProfile>((m, p) => m.QualityProfileId == p.Id) .Join<Movie, QualityProfile>((m, p) => m.QualityProfileId == p.Id)
.Join<Movie, MovieMetadata>((m, p) => m.MovieMetadataId == p.Id) .Join<Movie, MovieMetadata>((m, p) => m.MovieMetadataId == p.Id)
@ -242,24 +249,26 @@ public List<Movie> MoviesBetweenDates(DateTime start, DateTime end, bool include
} }
public SqlBuilder MoviesWithoutFilesBuilder() => Builder() public SqlBuilder MoviesWithoutFilesBuilder() => Builder()
.Where<Movie>(x => x.MovieFileId == 0); .Where<Movie>(x => x.MovieFileId == 0)
.GroupBy<Movie>(e => e.Id);
public PagingSpec<Movie> MoviesWithoutFiles(PagingSpec<Movie> pagingSpec) public PagingSpec<Movie> MoviesWithoutFiles(PagingSpec<Movie> pagingSpec)
{ {
pagingSpec.Records = GetPagedRecords(MoviesWithoutFilesBuilder(), pagingSpec, PagedQuery); pagingSpec.Records = GetPagedRecords(MoviesWithoutFilesBuilder(), pagingSpec, PagedQuery);
pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWithoutFilesBuilder().SelectCount(), pagingSpec); pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWithoutFilesBuilder().SelectCountDistinct<Movie>(x => x.Id), pagingSpec);
return pagingSpec; return pagingSpec;
} }
public SqlBuilder MoviesWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff) => Builder() public SqlBuilder MoviesWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff) => Builder()
.Where<Movie>(x => x.MovieFileId != 0) .Where<Movie>(x => x.MovieFileId != 0)
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff)); .Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff))
.GroupBy<Movie>(e => e.Id);
public PagingSpec<Movie> MoviesWhereCutoffUnmet(PagingSpec<Movie> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff) public PagingSpec<Movie> MoviesWhereCutoffUnmet(PagingSpec<Movie> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff)
{ {
pagingSpec.Records = GetPagedRecords(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff), pagingSpec, PagedQuery); pagingSpec.Records = GetPagedRecords(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff), pagingSpec, PagedQuery);
pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff).SelectCount(), pagingSpec); pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWhereCutoffUnmetBuilder(qualitiesBelowCutoff).SelectCountDistinct<Movie>(x => x.Id), pagingSpec);
return pagingSpec; return pagingSpec;
} }

View File

@ -4,8 +4,8 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Movies; using NzbDrone.Core.Movies;
using NzbDrone.Core.Movies.Translations; using NzbDrone.Core.Movies.Translations;
using NzbDrone.Core.MovieStats; using NzbDrone.Core.MovieStats;
@ -13,35 +13,27 @@
using NzbDrone.SignalR; using NzbDrone.SignalR;
using Radarr.Api.V3.Movies; using Radarr.Api.V3.Movies;
using Radarr.Http; using Radarr.Http;
using Radarr.Http.REST;
namespace Radarr.Api.V3.Calendar namespace Radarr.Api.V3.Calendar
{ {
[V3ApiController] [V3ApiController]
public class CalendarController : RestControllerWithSignalR<MovieResource, Movie> public class CalendarController : MovieControllerWithSignalR
{ {
private readonly IMovieService _moviesService; private readonly IMovieService _moviesService;
private readonly IMovieTranslationService _movieTranslationService;
private readonly IMovieStatisticsService _movieStatisticsService;
private readonly IUpgradableSpecification _qualityUpgradableSpecification;
private readonly ITagService _tagService; private readonly ITagService _tagService;
private readonly IConfigService _configService;
public CalendarController(IBroadcastSignalRMessage signalR, public CalendarController(IBroadcastSignalRMessage signalR,
IMovieService moviesService, IMovieService movieService,
IMovieTranslationService movieTranslationService, IMovieTranslationService movieTranslationService,
IMovieStatisticsService movieStatisticsService, IMovieStatisticsService movieStatisticsService,
IUpgradableSpecification qualityUpgradableSpecification, IUpgradableSpecification upgradableSpecification,
ICustomFormatCalculationService formatCalculator,
ITagService tagService, ITagService tagService,
IConfigService configService) IConfigService configService)
: base(signalR) : base(movieService, movieTranslationService, movieStatisticsService, upgradableSpecification, formatCalculator, configService, signalR)
{ {
_moviesService = moviesService; _moviesService = movieService;
_movieTranslationService = movieTranslationService;
_movieStatisticsService = movieStatisticsService;
_qualityUpgradableSpecification = qualityUpgradableSpecification;
_tagService = tagService; _tagService = tagService;
_configService = configService;
} }
[NonAction] [NonAction]
@ -84,56 +76,5 @@ public List<MovieResource> GetCalendar(DateTime? start, DateTime? end, bool unmo
return resources.OrderBy(e => e.InCinemas).ToList(); return resources.OrderBy(e => e.InCinemas).ToList();
} }
protected List<MovieResource> MapToResource(List<Movie> movies)
{
var resources = new List<MovieResource>();
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<MovieTranslation> 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;
}
} }
} }

View File

@ -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<MovieResource, Movie>,
IHandle<MovieGrabbedEvent>,
IHandle<MovieFileImportedEvent>,
IHandle<MovieFileDeletedEvent>
{
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<MovieResource> MapToResource(List<Movie> movies)
{
var resources = new List<MovieResource>();
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<MovieTranslation> 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);
}
}
}

View File

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json.Serialization;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats; using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications; using NzbDrone.Core.DecisionEngine.Specifications;
@ -56,6 +57,7 @@ public MovieResource()
// Compatibility // Compatibility
public bool? HasFile { get; set; } public bool? HasFile { get; set; }
public int MovieFileId { get; set; }
// Editing Only // Editing Only
public bool Monitored { get; set; } public bool Monitored { get; set; }
@ -80,6 +82,10 @@ public MovieResource()
public MovieCollectionResource Collection { get; set; } public MovieCollectionResource Collection { get; set; }
public float Popularity { get; set; } public float Popularity { get; set; }
public MovieStatisticsResource Statistics { 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 public static class MovieResourceMapper
@ -118,6 +124,8 @@ public static MovieResource ToResource(this Movie model, int availDelay, MovieTr
Year = model.Year, Year = model.Year,
SecondaryYear = model.MovieMetadata.Value.SecondaryYear, SecondaryYear = model.MovieMetadata.Value.SecondaryYear,
MovieFileId = model.MovieFileId,
Path = model.Path, Path = model.Path,
QualityProfileId = model.QualityProfileId, QualityProfileId = model.QualityProfileId,

View File

@ -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<MovieResource> GetCutoffUnmetMovies([FromQuery] PagingRequestResource paging, bool monitored = true)
{
var pagingResource = new PagingResource<MovieResource>(paging);
var pagingSpec = new PagingSpec<Movie>
{
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;
}
}
}

View File

@ -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<MovieResource> GetMissingMovies([FromQuery] PagingRequestResource paging, bool monitored = true)
{
var pagingResource = new PagingResource<MovieResource>(paging);
var pagingSpec = new PagingSpec<Movie>
{
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;
}
}
}