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 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
*/}
<Route
path="/wanted/missing"
component={MissingConnector}
/>
<Route
path="/wanted/cutoffunmet"
component={CutoffUnmetConnector}
/>
{/*
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,
title: () => translate('Settings'),

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 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,

View File

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

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",
"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",

View File

@ -48,6 +48,13 @@ namespace NzbDrone.Core.Movies
_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)
.Join<Movie, QualityProfile>((m, p) => m.QualityProfileId == p.Id)
.Join<Movie, MovieMetadata>((m, p) => m.MovieMetadataId == p.Id)
@ -242,24 +249,26 @@ namespace NzbDrone.Core.Movies
}
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)
{
pagingSpec.Records = GetPagedRecords(MoviesWithoutFilesBuilder(), pagingSpec, PagedQuery);
pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWithoutFilesBuilder().SelectCount(), pagingSpec);
pagingSpec.TotalRecords = GetPagedRecordCount(MoviesWithoutFilesBuilder().SelectCountDistinct<Movie>(x => x.Id), pagingSpec);
return pagingSpec;
}
public SqlBuilder MoviesWhereCutoffUnmetBuilder(List<QualitiesBelowCutoff> qualitiesBelowCutoff) => Builder()
.Where<Movie>(x => x.MovieFileId != 0)
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff));
.Where<Movie>(x => x.MovieFileId != 0)
.Where(BuildQualityCutoffWhereClause(qualitiesBelowCutoff))
.GroupBy<Movie>(e => e.Id);
public PagingSpec<Movie> MoviesWhereCutoffUnmet(PagingSpec<Movie> pagingSpec, List<QualitiesBelowCutoff> qualitiesBelowCutoff)
{
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;
}

View File

@ -4,8 +4,8 @@ using System.Linq;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Movies.Translations;
using NzbDrone.Core.MovieStats;
@ -13,35 +13,27 @@ using NzbDrone.Core.Tags;
using NzbDrone.SignalR;
using Radarr.Api.V3.Movies;
using Radarr.Http;
using Radarr.Http.REST;
namespace Radarr.Api.V3.Calendar
{
[V3ApiController]
public class CalendarController : RestControllerWithSignalR<MovieResource, Movie>
public class CalendarController : MovieControllerWithSignalR
{
private readonly IMovieService _moviesService;
private readonly IMovieTranslationService _movieTranslationService;
private readonly IMovieStatisticsService _movieStatisticsService;
private readonly IUpgradableSpecification _qualityUpgradableSpecification;
private readonly ITagService _tagService;
private readonly IConfigService _configService;
public CalendarController(IBroadcastSignalRMessage signalR,
IMovieService moviesService,
IMovieService movieService,
IMovieTranslationService movieTranslationService,
IMovieStatisticsService movieStatisticsService,
IUpgradableSpecification qualityUpgradableSpecification,
IUpgradableSpecification upgradableSpecification,
ICustomFormatCalculationService formatCalculator,
ITagService tagService,
IConfigService configService)
: base(signalR)
: base(movieService, movieTranslationService, movieStatisticsService, upgradableSpecification, formatCalculator, configService, signalR)
{
_moviesService = moviesService;
_movieTranslationService = movieTranslationService;
_movieStatisticsService = movieStatisticsService;
_qualityUpgradableSpecification = qualityUpgradableSpecification;
_moviesService = movieService;
_tagService = tagService;
_configService = configService;
}
[NonAction]
@ -84,56 +76,5 @@ namespace Radarr.Api.V3.Calendar
return resources.OrderBy(e => e.InCinemas).ToList();
}
protected List<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.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.DecisionEngine.Specifications;
@ -56,6 +57,7 @@ namespace Radarr.Api.V3.Movies
// Compatibility
public bool? HasFile { get; set; }
public int MovieFileId { get; set; }
// Editing Only
public bool Monitored { get; set; }
@ -80,6 +82,10 @@ namespace Radarr.Api.V3.Movies
public MovieCollectionResource Collection { get; set; }
public float Popularity { get; set; }
public MovieStatisticsResource Statistics { get; set; }
// Hiding this so people don't think its usable (only used to set the initial state)
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public bool Grabbed { get; set; }
}
public static class MovieResourceMapper
@ -118,6 +124,8 @@ namespace Radarr.Api.V3.Movies
Year = model.Year,
SecondaryYear = model.MovieMetadata.Value.SecondaryYear,
MovieFileId = model.MovieFileId,
Path = model.Path,
QualityProfileId = model.QualityProfileId,

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