mirror of
https://github.com/Radarr/Radarr.git
synced 2026-03-06 13:31:28 -05:00
Compare commits
56 Commits
v5.20.0.97
...
v5.22.0.98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74d2259f67 | ||
|
|
6e68a91922 | ||
|
|
a962de776b | ||
|
|
e8afde2e90 | ||
|
|
4633a834f3 | ||
|
|
cd021961f0 | ||
|
|
456ea3d57c | ||
|
|
d09fa6f880 | ||
|
|
bcd4fe1f08 | ||
|
|
8efce68922 | ||
|
|
4b3c29ed93 | ||
|
|
7ea9161779 | ||
|
|
f5c66c5093 | ||
|
|
a3515db9f7 | ||
|
|
d4bb318253 | ||
|
|
64e865f296 | ||
|
|
982f9062bd | ||
|
|
48075e33ac | ||
|
|
91f08a83cd | ||
|
|
886db23c58 | ||
|
|
b646386e77 | ||
|
|
4aa259a666 | ||
|
|
35f1a61bf8 | ||
|
|
1d855aed00 | ||
|
|
f7da5b0866 | ||
|
|
682cc70acf | ||
|
|
9d624b07ce | ||
|
|
2afb41498d | ||
|
|
a0679fcf11 | ||
|
|
df4a69ac02 | ||
|
|
2c8d8ff2d6 | ||
|
|
0593568065 | ||
|
|
25aa719ad6 | ||
|
|
3ab61a2fee | ||
|
|
954a040d6e | ||
|
|
905b23618a | ||
|
|
8decd5d8e1 | ||
|
|
8b5b177d16 | ||
|
|
e6c6fceff8 | ||
|
|
dcc8b28a07 | ||
|
|
02baf4d7a4 | ||
|
|
22ec1fe492 | ||
|
|
a7dbdadd21 | ||
|
|
93581e4a2f | ||
|
|
4c8da09df6 | ||
|
|
89666175a6 | ||
|
|
7a33e156a3 | ||
|
|
c7c07404b0 | ||
|
|
abeeee9363 | ||
|
|
23c30734d2 | ||
|
|
939e45e646 | ||
|
|
16ceba2392 | ||
|
|
94d620d878 | ||
|
|
ee0db93a0a | ||
|
|
f815b31c33 | ||
|
|
c078191b3d |
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.20.0'
|
||||
majorVersion: '5.22.0'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from 'typings/History';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryDetails.css';
|
||||
@@ -50,6 +51,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishedDate,
|
||||
size,
|
||||
} = data as GrabbedHistoryData;
|
||||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
@@ -160,12 +162,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('Size')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message } = data as DownloadFailedHistory;
|
||||
const { message, indexer } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
@@ -179,6 +188,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{indexer ? (
|
||||
<DescriptionListItem title={translate('Indexer')} data={indexer} />
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
@@ -187,7 +200,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFolderImported') {
|
||||
const { customFormatScore, droppedPath, importedPath } =
|
||||
const { customFormatScore, droppedPath, importedPath, size } =
|
||||
data as DownloadFolderImportedHistory;
|
||||
|
||||
return (
|
||||
@@ -220,12 +233,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'movieFileDeleted') {
|
||||
const { reason, customFormatScore } = data as MovieFileDeletedHistory;
|
||||
const { reason, customFormatScore, size } = data as MovieFileDeletedHistory;
|
||||
|
||||
let reasonMessage = '';
|
||||
|
||||
@@ -255,6 +275,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{size ? (
|
||||
<DescriptionListItem
|
||||
title={translate('FileSize')}
|
||||
data={formatBytes(size)}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,8 +82,7 @@ class AddNewMovie extends Component {
|
||||
const {
|
||||
error,
|
||||
items,
|
||||
hasExistingMovies,
|
||||
colorImpairedMode
|
||||
hasExistingMovies
|
||||
} = this.props;
|
||||
|
||||
const term = this.state.term;
|
||||
@@ -150,7 +149,6 @@ class AddNewMovie extends Component {
|
||||
return (
|
||||
<AddNewMovieSearchResultConnector
|
||||
key={item.tmdbId}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
@@ -223,8 +221,7 @@ AddNewMovie.propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasExistingMovies: PropTypes.bool.isRequired,
|
||||
onMovieLookupChange: PropTypes.func.isRequired,
|
||||
onClearMovieLookup: PropTypes.func.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
onClearMovieLookup: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AddNewMovie;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions';
|
||||
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import parseUrl from 'Utilities/String/parseUrl';
|
||||
@@ -17,15 +16,13 @@ function createMapStateToProps() {
|
||||
(state) => state.addMovie,
|
||||
(state) => state.movies.items.length,
|
||||
(state) => state.router.location,
|
||||
createUISettingsSelector(),
|
||||
(addMovie, existingMoviesCount, location, uiSettings) => {
|
||||
(addMovie, existingMoviesCount, location) => {
|
||||
const { params } = parseUrl(location.search);
|
||||
|
||||
return {
|
||||
...addMovie,
|
||||
term: params.term,
|
||||
hasExistingMovies: existingMoviesCount > 0,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
hasExistingMovies: existingMoviesCount > 0
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -74,12 +74,9 @@ class AddNewMovieSearchResult extends Component {
|
||||
isExistingMovie,
|
||||
isExcluded,
|
||||
isSmallScreen,
|
||||
colorImpairedMode,
|
||||
id,
|
||||
monitored,
|
||||
isAvailable,
|
||||
movieFile,
|
||||
queueItem,
|
||||
runtime,
|
||||
movieRuntimeFormat,
|
||||
certification
|
||||
@@ -285,14 +282,12 @@ class AddNewMovieSearchResult extends Component {
|
||||
{
|
||||
isExistingMovie && isSmallScreen &&
|
||||
<MovieStatusLabel
|
||||
status={status}
|
||||
hasMovieFiles={hasMovieFile}
|
||||
movieId={existingMovieId}
|
||||
monitored={monitored}
|
||||
isAvailable={isAvailable}
|
||||
queueItem={queueItem}
|
||||
id={id}
|
||||
hasMovieFiles={hasMovieFile}
|
||||
status={status}
|
||||
useLabel={true}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -337,12 +332,9 @@ AddNewMovieSearchResult.propTypes = {
|
||||
isExistingMovie: PropTypes.bool.isRequired,
|
||||
isExcluded: PropTypes.bool,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
id: PropTypes.number,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
movieFile: PropTypes.object,
|
||||
queueItem: PropTypes.object,
|
||||
colorImpairedMode: PropTypes.bool,
|
||||
runtime: PropTypes.number.isRequired,
|
||||
movieRuntimeFormat: PropTypes.string.isRequired,
|
||||
certification: PropTypes.string
|
||||
|
||||
@@ -8,19 +8,16 @@ function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createExistingMovieSelector(),
|
||||
createDimensionsSelector(),
|
||||
(state) => state.queue.details.items,
|
||||
(state) => state.movieFiles.items,
|
||||
(state, { internalId }) => internalId,
|
||||
(state) => state.settings.ui.item.movieRuntimeFormat,
|
||||
(isExistingMovie, dimensions, queueItems, movieFiles, internalId, movieRuntimeFormat) => {
|
||||
const queueItem = queueItems.find((item) => internalId > 0 && item.movieId === internalId);
|
||||
(isExistingMovie, dimensions, movieFiles, internalId, movieRuntimeFormat) => {
|
||||
const movieFile = movieFiles.find((item) => internalId > 0 && item.movieId === internalId);
|
||||
|
||||
return {
|
||||
existingMovieId: internalId,
|
||||
isExistingMovie,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
queueItem,
|
||||
movieFile,
|
||||
movieRuntimeFormat
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import CollectionConnector from 'Collection/CollectionConnector';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
|
||||
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
|
||||
import MovieDetailsPage from 'Movie/Details/MovieDetailsPage';
|
||||
import MovieIndex from 'Movie/Index/MovieIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||
@@ -67,7 +67,7 @@ function AppRoutes() {
|
||||
|
||||
<Route path="/add/discover" component={DiscoverMovieConnector} />
|
||||
|
||||
<Route path="/movie/:titleSlug" component={MovieDetailsPageConnector} />
|
||||
<Route path="/movie/:titleSlug" component={MovieDetailsPage} />
|
||||
|
||||
{/*
|
||||
Calendar
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import ExtraFilesAppState from './ExtraFilesAppState';
|
||||
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
import MovieBlocklistAppState from './MovieBlocklistAppState';
|
||||
@@ -53,6 +54,7 @@ export interface CustomFilter {
|
||||
export interface AppSectionState {
|
||||
isConnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
prevVersion?: string;
|
||||
dimensions: {
|
||||
@@ -67,6 +69,7 @@ interface AppState {
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
commands: CommandAppState;
|
||||
extraFiles: ExtraFilesAppState;
|
||||
history: HistoryAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
movieBlocklist: MovieBlocklistAppState;
|
||||
|
||||
6
frontend/src/App/State/ExtraFilesAppState.ts
Normal file
6
frontend/src/App/State/ExtraFilesAppState.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import { ExtraFile } from 'MovieFile/ExtraFile';
|
||||
|
||||
type ExtraFilesAppState = AppSectionState<ExtraFile>;
|
||||
|
||||
export default ExtraFilesAppState;
|
||||
@@ -11,7 +11,7 @@ import IconButton from 'Components/Link/IconButton';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import { icons, sizes } from 'Helpers/Props';
|
||||
import MovieGenres from 'Movie/MovieGenres';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -212,7 +212,7 @@ class CollectionOverview extends Component {
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{
|
||||
<QualityProfileNameConnector
|
||||
<QualityProfileName
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import StackTrace from 'stacktrace-js';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ErrorBoundaryError.css';
|
||||
|
||||
interface ErrorBoundaryErrorProps {
|
||||
@@ -18,7 +19,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
|
||||
className = styles.container,
|
||||
messageClassName = styles.message,
|
||||
detailsClassName = styles.details,
|
||||
message = 'There was an error loading this content',
|
||||
message = translate('ErrorLoadingContent'),
|
||||
error,
|
||||
info,
|
||||
} = props;
|
||||
|
||||
@@ -3,7 +3,10 @@ import translate from 'Utilities/String/translate';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
|
||||
const statusTagList = [
|
||||
{ id: 'tba', name: 'TBA' },
|
||||
{ id: 'tba',
|
||||
get name() {
|
||||
return translate('Tba');
|
||||
} },
|
||||
{
|
||||
id: 'announced',
|
||||
get name() {
|
||||
|
||||
@@ -53,7 +53,7 @@ function CustomFiltersModalContent(props) {
|
||||
|
||||
<div className={styles.addButtonContainer}>
|
||||
<Button onPress={onAddCustomFilter}>
|
||||
Add Custom Filter
|
||||
{translate('AddCustomFilter')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
const MINIMUM_DISTANCE_FROM_EDGE = 10;
|
||||
|
||||
function isArrowKey(keyCode) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
@@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component {
|
||||
// Listeners
|
||||
|
||||
onComputeMaxHeight = (data) => {
|
||||
const {
|
||||
top,
|
||||
bottom
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^botton/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
|
||||
|
||||
return data;
|
||||
};
|
||||
@@ -463,6 +456,10 @@ class EnhancedSelectInput extends Component {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.onComputeMaxHeight
|
||||
},
|
||||
preventOverflow: {
|
||||
enabled: true,
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchIndexers } from 'Store/Actions/settingsActions';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput from './EnhancedSelectInput';
|
||||
|
||||
function createMapStateToProps() {
|
||||
@@ -18,15 +20,17 @@ function createMapStateToProps() {
|
||||
items
|
||||
} = indexers;
|
||||
|
||||
const values = items.sort(sortByProp('name')).map((indexer) => ({
|
||||
key: indexer.id,
|
||||
value: indexer.name
|
||||
}));
|
||||
const values = _.map(items.sort(sortByProp('name')), (indexer) => {
|
||||
return {
|
||||
key: indexer.id,
|
||||
value: indexer.name
|
||||
};
|
||||
});
|
||||
|
||||
if (includeAny) {
|
||||
values.unshift({
|
||||
key: 0,
|
||||
value: '(Any)'
|
||||
value: `(${translate('Any')})`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ function createMapStateToProps() {
|
||||
if (includeMixed) {
|
||||
values.unshift({
|
||||
key: 'mixed',
|
||||
value: '(Mixed)',
|
||||
value: `(${translate('Mixed')})`,
|
||||
isDisabled: true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ function createMapStateToProps() {
|
||||
|
||||
values.push({
|
||||
key: ADD_NEW_KEY,
|
||||
value: 'Add a new path'
|
||||
value: translate('AddANewPath')
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@ import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
|
||||
import styles from './RootFolderSelectInputOption.css';
|
||||
|
||||
@@ -47,14 +48,14 @@ function RootFolderSelectInputOption(props) {
|
||||
freeSpace == null ?
|
||||
null :
|
||||
<div className={styles.freeSpace}>
|
||||
{formatBytes(freeSpace)} Free
|
||||
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isMissing ?
|
||||
<div className={styles.isMissing}>
|
||||
Missing
|
||||
{translate('Missing')}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
@@ -67,8 +68,8 @@ RootFolderSelectInputOption.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
freeSpace: PropTypes.number,
|
||||
movieFolder: PropTypes.string,
|
||||
isMissing: PropTypes.bool,
|
||||
movieFolder: PropTypes.string,
|
||||
isMobile: PropTypes.bool.isRequired,
|
||||
isWindows: PropTypes.bool
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
|
||||
import styles from './RootFolderSelectInputSelectedValue.css';
|
||||
|
||||
@@ -39,7 +40,7 @@ function RootFolderSelectInputSelectedValue(props) {
|
||||
{
|
||||
freeSpace != null && includeFreeSpace &&
|
||||
<div className={styles.freeSpace}>
|
||||
{formatBytes(freeSpace)} Free
|
||||
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
|
||||
</div>
|
||||
}
|
||||
</EnhancedSelectInputSelectedValue>
|
||||
|
||||
@@ -8,27 +8,37 @@ import styles from './UMaskInput.css';
|
||||
const umaskOptions = [
|
||||
{
|
||||
key: '755',
|
||||
value: '755 - Owner write, Everyone else read',
|
||||
get value() {
|
||||
return translate('Umask755Description', { octal: '755' });
|
||||
},
|
||||
hint: 'drwxr-xr-x'
|
||||
},
|
||||
{
|
||||
key: '775',
|
||||
value: '775 - Owner & Group write, Other read',
|
||||
get value() {
|
||||
return translate('Umask775Description', { octal: '775' });
|
||||
},
|
||||
hint: 'drwxrwxr-x'
|
||||
},
|
||||
{
|
||||
key: '770',
|
||||
value: '770 - Owner & Group write',
|
||||
get value() {
|
||||
return translate('Umask770Description', { octal: '770' });
|
||||
},
|
||||
hint: 'drwxrwx---'
|
||||
},
|
||||
{
|
||||
key: '750',
|
||||
value: '750 - Owner write, Group read',
|
||||
get value() {
|
||||
return translate('Umask750Description', { octal: '750' });
|
||||
},
|
||||
hint: 'drwxr-x---'
|
||||
},
|
||||
{
|
||||
key: '777',
|
||||
value: '777 - Everyone write',
|
||||
get value() {
|
||||
return translate('Umask777Description', { octal: '777' });
|
||||
},
|
||||
hint: 'drwxrwxrwx'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -8,17 +8,20 @@ import { kinds } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import styles from './Icon.css';
|
||||
|
||||
export type IconName = FontAwesomeIconProps['icon'];
|
||||
export type IconKind = Extract<Kind, keyof typeof styles>;
|
||||
|
||||
export interface IconProps
|
||||
extends Omit<
|
||||
FontAwesomeIconProps,
|
||||
'icon' | 'spin' | 'name' | 'title' | 'size'
|
||||
> {
|
||||
containerClassName?: ComponentProps<'span'>['className'];
|
||||
name: FontAwesomeIconProps['icon'];
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
name: IconName;
|
||||
kind?: IconKind;
|
||||
size?: number;
|
||||
isSpinning?: FontAwesomeIconProps['spin'];
|
||||
title?: string | (() => string);
|
||||
title?: string | (() => string) | null;
|
||||
}
|
||||
|
||||
export default function Icon({
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
|
||||
/** Kinds **/
|
||||
|
||||
.default {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/** Sizes **/
|
||||
|
||||
.small {
|
||||
|
||||
1
frontend/src/Components/InfoLabel.css.d.ts
vendored
1
frontend/src/Components/InfoLabel.css.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'default': string;
|
||||
'label': string;
|
||||
'large': string;
|
||||
'medium': string;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import styles from './InfoLabel.css';
|
||||
|
||||
function InfoLabel(props) {
|
||||
const {
|
||||
className,
|
||||
name,
|
||||
kind,
|
||||
size,
|
||||
outline,
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div>
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
InfoLabel.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
outline: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
InfoLabel.defaultProps = {
|
||||
className: styles.label,
|
||||
kind: kinds.DEFAULT,
|
||||
size: sizes.SMALL,
|
||||
outline: false
|
||||
};
|
||||
|
||||
export default InfoLabel;
|
||||
41
frontend/src/Components/InfoLabel.tsx
Normal file
41
frontend/src/Components/InfoLabel.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps, ReactNode } from 'react';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import styles from './InfoLabel.css';
|
||||
|
||||
interface InfoLabelProps extends ComponentProps<'span'> {
|
||||
className?: string;
|
||||
name: string;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
outline?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function InfoLabel({
|
||||
className = styles.label,
|
||||
name,
|
||||
kind = 'default',
|
||||
size = 'small',
|
||||
outline = false,
|
||||
children,
|
||||
...otherProps
|
||||
}: InfoLabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.name}>{name}</div>
|
||||
<div>{children}</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default InfoLabel;
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
@@ -7,26 +8,15 @@ const TIMEOUT = 1 / FPS * 1000;
|
||||
|
||||
class Marquee extends Component {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
hoverToStop: PropTypes.bool,
|
||||
loop: PropTypes.bool,
|
||||
className: PropTypes.string
|
||||
};
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
static defaultProps = {
|
||||
text: '',
|
||||
title: '',
|
||||
hoverToStop: true,
|
||||
loop: false
|
||||
};
|
||||
|
||||
state = {
|
||||
animatedWidth: 0,
|
||||
overflowWidth: 0,
|
||||
direction: 0
|
||||
};
|
||||
this.state = {
|
||||
animatedWidth: 0,
|
||||
overflowWidth: 0,
|
||||
direction: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.measureText();
|
||||
@@ -138,7 +128,7 @@ class Marquee extends Component {
|
||||
ref={(el) => {
|
||||
this.container = el;
|
||||
}}
|
||||
className={`ui-marquee ${this.props.className}`}
|
||||
className={classNames('ui-marquee', this.props.className)}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<span
|
||||
@@ -159,7 +149,7 @@ class Marquee extends Component {
|
||||
ref={(el) => {
|
||||
this.container = el;
|
||||
}}
|
||||
className={`ui-marquee ${this.props.className}`.trim()}
|
||||
className={classNames('ui-marquee', this.props.className)}
|
||||
style={{ overflow: 'hidden' }}
|
||||
onMouseEnter={this.onHandleMouseEnter}
|
||||
onMouseLeave={this.onHandleMouseLeave}
|
||||
@@ -178,4 +168,20 @@ class Marquee extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
Marquee.propTypes = {
|
||||
text: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
hoverToStop: PropTypes.bool,
|
||||
loop: PropTypes.bool,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
Marquee.defaultProps = {
|
||||
text: '',
|
||||
title: '',
|
||||
hoverToStop: true,
|
||||
loop: false,
|
||||
className: ''
|
||||
};
|
||||
|
||||
export default Marquee;
|
||||
|
||||
@@ -58,9 +58,9 @@ class FilterMenu extends Component {
|
||||
>
|
||||
<ButtonComponent
|
||||
iconName={icons.FILTER}
|
||||
showIndicator={selectedFilterKey !== 'all'}
|
||||
text={translate('Filter')}
|
||||
isDisabled={isDisabled}
|
||||
showIndicator={selectedFilterKey !== 'all'}
|
||||
/>
|
||||
|
||||
<FilterMenuContent
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { Component } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import { align } from 'Helpers/Props';
|
||||
import getUniqueElememtId from 'Utilities/getUniqueElementId';
|
||||
import getUniqueElementId from 'Utilities/getUniqueElementId';
|
||||
import styles from './Menu.css';
|
||||
|
||||
const sharedPopperOptions = {
|
||||
@@ -38,8 +38,8 @@ class Menu extends Component {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
this._menuButtonId = getUniqueElememtId();
|
||||
this._menuContentId = getUniqueElememtId();
|
||||
this._menuButtonId = getUniqueElementId();
|
||||
this._menuContentId = getUniqueElementId();
|
||||
|
||||
this.state = {
|
||||
isMenuOpen: false,
|
||||
|
||||
@@ -82,13 +82,6 @@
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.modal.small,
|
||||
.modal.medium {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.modalContainer {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function ModalError(props) {
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Error
|
||||
{translate('Error')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
@@ -26,7 +26,7 @@ function ModalError(props) {
|
||||
messageClassName={styles.message}
|
||||
detailsClassName={styles.details}
|
||||
{...otherProps}
|
||||
message={translate('ThereWasAnErrorLoadingThisItem')}
|
||||
message={translate('ErrorLoadingItem')}
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function KeyboardShortcutsModalContent(props) {
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Keyboard Shortcuts
|
||||
{translate('KeyboardShortcuts')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
|
||||
@@ -92,7 +92,7 @@ class MovieSearchInput extends Component {
|
||||
if (item.type === ADD_NEW_TYPE) {
|
||||
return (
|
||||
<div className={styles.addNewMovieSuggestion}>
|
||||
Search for {query}
|
||||
{translate('SearchForQuery', { query })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class PageHeader extends Component {
|
||||
<IconButton
|
||||
className={styles.donate}
|
||||
name={icons.HEART}
|
||||
aria-label="Donate"
|
||||
aria-label={translate('Donate')}
|
||||
to="https://radarr.video/donate"
|
||||
size={14}
|
||||
title={translate('Donate')}
|
||||
|
||||
@@ -10,7 +10,7 @@ function PageContentError(props) {
|
||||
<PageContentBody>
|
||||
<ErrorBoundaryError
|
||||
{...props}
|
||||
message={translate('ThereWasAnErrorLoadingThisPage')}
|
||||
message={translate('ErrorLoadingPage')}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
composes: link;
|
||||
|
||||
padding: 10px 24px;
|
||||
padding-left: 35px;
|
||||
}
|
||||
|
||||
.isActiveLink {
|
||||
@@ -41,10 +42,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noIcon {
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
.status {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ interface CssExports {
|
||||
'isActiveParentLink': string;
|
||||
'item': string;
|
||||
'link': string;
|
||||
'noIcon': string;
|
||||
'status': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
|
||||
@@ -63,9 +63,7 @@ class PageSidebarItem extends Component {
|
||||
</span>
|
||||
}
|
||||
|
||||
<span className={isChildItem ? styles.noIcon : null}>
|
||||
{typeof title === 'function' ? title() : title}
|
||||
</span>
|
||||
{typeof title === 'function' ? title() : title}
|
||||
|
||||
{
|
||||
!!StatusComponent &&
|
||||
|
||||
@@ -23,11 +23,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 3px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
color: var(--toolbarLabelColor);
|
||||
font-size: $extraSmallFontSize;
|
||||
line-height: calc($extraSmallFontSize + 1px);
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './PageToolbarButton.css';
|
||||
|
||||
function PageToolbarButton(props) {
|
||||
const {
|
||||
label,
|
||||
iconName,
|
||||
spinningName,
|
||||
isDisabled,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.toolbarButton,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
name={isSpinning ? (spinningName || iconName) : iconName}
|
||||
isSpinning={isSpinning}
|
||||
size={21}
|
||||
/>
|
||||
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
PageToolbarButton.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
iconName: PropTypes.object.isRequired,
|
||||
spinningName: PropTypes.object,
|
||||
isSpinning: PropTypes.bool,
|
||||
isDisabled: PropTypes.bool,
|
||||
onPress: PropTypes.func
|
||||
};
|
||||
|
||||
PageToolbarButton.defaultProps = {
|
||||
spinningName: icons.SPINNER,
|
||||
isDisabled: false,
|
||||
isSpinning: false
|
||||
};
|
||||
|
||||
export default PageToolbarButton;
|
||||
50
frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx
Normal file
50
frontend/src/Components/Page/Toolbar/PageToolbarButton.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon, { IconName } from 'Components/Icon';
|
||||
import Link, { LinkProps } from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './PageToolbarButton.css';
|
||||
|
||||
export interface PageToolbarButtonProps extends LinkProps {
|
||||
label: string;
|
||||
iconName: IconName;
|
||||
spinningName?: IconName;
|
||||
isSpinning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
overflowComponent?: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
function PageToolbarButton({
|
||||
label,
|
||||
iconName,
|
||||
spinningName = icons.SPINNER,
|
||||
isDisabled = false,
|
||||
isSpinning = false,
|
||||
overflowComponent,
|
||||
...otherProps
|
||||
}: PageToolbarButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.toolbarButton,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
title={label}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
name={isSpinning ? spinningName || iconName : iconName}
|
||||
isSpinning={isSpinning}
|
||||
size={21}
|
||||
/>
|
||||
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageToolbarButton;
|
||||
@@ -4,7 +4,7 @@
|
||||
line-height: 1.52857143;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.tableContainer {
|
||||
min-width: 100%;
|
||||
width: fit-content;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.headerCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -170,11 +170,11 @@ class TableOptionsModal extends Component {
|
||||
{
|
||||
canModifyColumns ?
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Columns')}</FormLabel>
|
||||
<FormLabel>{translate('TableColumns')}</FormLabel>
|
||||
|
||||
<div>
|
||||
<FormInputHelpText
|
||||
text={translate('TableOptionsColumnsMessage')}
|
||||
text={translate('TableColumnsHelpText')}
|
||||
/>
|
||||
|
||||
<div className={styles.columns}>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.pager {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './TablePager.css';
|
||||
|
||||
class TablePager extends Component {
|
||||
@@ -156,7 +157,7 @@ class TablePager extends Component {
|
||||
|
||||
<div className={styles.recordsContainer}>
|
||||
<div className={styles.records}>
|
||||
Total records: {totalRecords}
|
||||
{translate('TotalRecords', { totalRecords })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.headerCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -7,63 +7,63 @@ export const shortcuts = {
|
||||
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
|
||||
key: '?',
|
||||
get name() {
|
||||
return translate('OpenThisModal');
|
||||
return translate('KeyboardShortcutsOpenModal');
|
||||
}
|
||||
},
|
||||
|
||||
CLOSE_MODAL: {
|
||||
key: 'Esc',
|
||||
get name() {
|
||||
return translate('CloseCurrentModal');
|
||||
return translate('KeyboardShortcutsCloseModal');
|
||||
}
|
||||
},
|
||||
|
||||
ACCEPT_CONFIRM_MODAL: {
|
||||
key: 'Enter',
|
||||
get name() {
|
||||
return translate('AcceptConfirmationModal');
|
||||
return translate('KeyboardShortcutsConfirmModal');
|
||||
}
|
||||
},
|
||||
|
||||
MOVIE_SEARCH_INPUT: {
|
||||
key: 's',
|
||||
get name() {
|
||||
return translate('FocusSearchBox');
|
||||
return translate('KeyboardShortcutsFocusSearchBox');
|
||||
}
|
||||
},
|
||||
|
||||
SAVE_SETTINGS: {
|
||||
key: 'mod+s',
|
||||
get name() {
|
||||
return translate('SaveSettings');
|
||||
return translate('KeyboardShortcutsSaveSettings');
|
||||
}
|
||||
},
|
||||
|
||||
SCROLL_TOP: {
|
||||
key: 'mod+home',
|
||||
get name() {
|
||||
return translate('MovieIndexScrollTop');
|
||||
return translate('KeyboardShortcutsMovieIndexScrollTop');
|
||||
}
|
||||
},
|
||||
|
||||
SCROLL_BOTTOM: {
|
||||
key: 'mod+end',
|
||||
get name() {
|
||||
return translate('MovieIndexScrollBottom');
|
||||
return translate('KeyboardShortcutsMovieIndexScrollBottom');
|
||||
}
|
||||
},
|
||||
|
||||
DETAILS_NEXT: {
|
||||
key: '→',
|
||||
get name() {
|
||||
return translate('MovieDetailsNextMovie');
|
||||
return translate('KeyboardShortcutsMovieDetailsNextMovie');
|
||||
}
|
||||
},
|
||||
|
||||
DETAILS_PREVIOUS: {
|
||||
key: '←',
|
||||
get name() {
|
||||
return translate('MovieDetailsPreviousMovie');
|
||||
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import translate from 'Utilities/String/translate';
|
||||
import * as filterTypes from './filterTypes';
|
||||
|
||||
export const ARRAY = 'array';
|
||||
@@ -20,49 +21,127 @@ export const all = [
|
||||
|
||||
export const possibleFilterTypes = {
|
||||
[ARRAY]: [
|
||||
{ key: filterTypes.CONTAINS, value: 'contains' },
|
||||
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
|
||||
{
|
||||
key: filterTypes.CONTAINS,
|
||||
value: () => translate('FilterContains')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_CONTAINS,
|
||||
value: () => translate('FilterDoesNotContain')
|
||||
}
|
||||
],
|
||||
|
||||
[CONTAINS]: [
|
||||
{ key: filterTypes.CONTAINS, value: 'contains' }
|
||||
{
|
||||
key: filterTypes.CONTAINS,
|
||||
value: () => translate('FilterContains')
|
||||
}
|
||||
],
|
||||
|
||||
[DATE]: [
|
||||
{ key: filterTypes.LESS_THAN, value: 'is before' },
|
||||
{ key: filterTypes.GREATER_THAN, value: 'is after' },
|
||||
{ key: filterTypes.IN_LAST, value: 'in the last' },
|
||||
{ key: filterTypes.NOT_IN_LAST, value: 'not in the last' },
|
||||
{ key: filterTypes.IN_NEXT, value: 'in the next' },
|
||||
{ key: filterTypes.NOT_IN_NEXT, value: 'not in the next' }
|
||||
{
|
||||
key: filterTypes.LESS_THAN,
|
||||
value: () => translate('FilterIsBefore')
|
||||
},
|
||||
{
|
||||
key: filterTypes.GREATER_THAN,
|
||||
value: () => translate('FilterIsAfter')
|
||||
},
|
||||
{
|
||||
key: filterTypes.IN_LAST,
|
||||
value: () => translate('FilterInLast')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_IN_LAST,
|
||||
value: () => translate('FilterNotInLast')
|
||||
},
|
||||
{
|
||||
key: filterTypes.IN_NEXT,
|
||||
value: () => translate('FilterInNext')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_IN_NEXT,
|
||||
value: () => translate('FilterNotInNext')
|
||||
}
|
||||
],
|
||||
|
||||
[EQUAL]: [
|
||||
{ key: filterTypes.EQUAL, value: 'is' }
|
||||
{
|
||||
key: filterTypes.EQUAL,
|
||||
value: () => translate('FilterIs')
|
||||
}
|
||||
],
|
||||
|
||||
[EXACT]: [
|
||||
{ key: filterTypes.EQUAL, value: 'is' },
|
||||
{ key: filterTypes.NOT_EQUAL, value: 'is not' }
|
||||
{
|
||||
key: filterTypes.EQUAL,
|
||||
value: () => translate('FilterIs')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_EQUAL,
|
||||
value: () => translate('FilterIsNot')
|
||||
}
|
||||
],
|
||||
|
||||
[NUMBER]: [
|
||||
{ key: filterTypes.EQUAL, value: 'equal' },
|
||||
{ key: filterTypes.GREATER_THAN, value: 'greater than' },
|
||||
{ key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' },
|
||||
{ key: filterTypes.LESS_THAN, value: 'less than' },
|
||||
{ key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' },
|
||||
{ key: filterTypes.NOT_EQUAL, value: 'not equal' }
|
||||
{
|
||||
key: filterTypes.EQUAL,
|
||||
value: () => translate('FilterEqual')
|
||||
},
|
||||
{
|
||||
key: filterTypes.GREATER_THAN,
|
||||
value: () => translate('FilterGreaterThan')
|
||||
},
|
||||
{
|
||||
key: filterTypes.GREATER_THAN_OR_EQUAL,
|
||||
value: () => translate('FilterGreaterThanOrEqual')
|
||||
},
|
||||
{
|
||||
key: filterTypes.LESS_THAN,
|
||||
value: () => translate('FilterLessThan')
|
||||
},
|
||||
{
|
||||
key: filterTypes.LESS_THAN_OR_EQUAL,
|
||||
value: () => translate('FilterLessThanOrEqual')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_EQUAL,
|
||||
value: () => translate('FilterNotEqual')
|
||||
}
|
||||
],
|
||||
|
||||
[STRING]: [
|
||||
{ key: filterTypes.CONTAINS, value: 'contains' },
|
||||
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' },
|
||||
{ key: filterTypes.EQUAL, value: 'equal' },
|
||||
{ key: filterTypes.NOT_EQUAL, value: 'not equal' },
|
||||
{ key: filterTypes.STARTS_WITH, value: 'starts with' },
|
||||
{ key: filterTypes.NOT_STARTS_WITH, value: 'does not start with' },
|
||||
{ key: filterTypes.ENDS_WITH, value: 'ends with' },
|
||||
{ key: filterTypes.NOT_ENDS_WITH, value: 'does not end with' }
|
||||
{
|
||||
key: filterTypes.CONTAINS,
|
||||
value: () => translate('FilterContains')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_CONTAINS,
|
||||
value: () => translate('FilterDoesNotContain')
|
||||
},
|
||||
{
|
||||
key: filterTypes.EQUAL,
|
||||
value: () => translate('FilterEqual')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_EQUAL,
|
||||
value: () => translate('FilterNotEqual')
|
||||
},
|
||||
{
|
||||
key: filterTypes.STARTS_WITH,
|
||||
value: () => translate('FilterStartsWith')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_STARTS_WITH,
|
||||
value: () => translate('FilterDoesNotStartWith')
|
||||
},
|
||||
{
|
||||
key: filterTypes.ENDS_WITH,
|
||||
value: () => translate('FilterEndsWith')
|
||||
},
|
||||
{
|
||||
key: filterTypes.NOT_ENDS_WITH,
|
||||
value: () => translate('FilterDoesNotEndWith')
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -36,4 +36,5 @@ export type Kind =
|
||||
| 'primary'
|
||||
| 'purple'
|
||||
| 'success'
|
||||
| 'warning';
|
||||
| 'warning'
|
||||
| 'queue';
|
||||
|
||||
@@ -192,10 +192,9 @@ const importModeSelector = createSelector(
|
||||
}
|
||||
);
|
||||
|
||||
interface InteractiveImportModalContentProps {
|
||||
export interface InteractiveImportModalContentProps {
|
||||
downloadId?: string;
|
||||
movieId?: number;
|
||||
seasonNumber?: number;
|
||||
showMovie?: boolean;
|
||||
allowMovieChange?: boolean;
|
||||
showDelete?: boolean;
|
||||
@@ -217,7 +216,6 @@ function InteractiveImportModalContent(
|
||||
const {
|
||||
downloadId,
|
||||
movieId,
|
||||
seasonNumber,
|
||||
allowMovieChange = true,
|
||||
showMovie = true,
|
||||
showFilterExistingFiles = false,
|
||||
@@ -343,7 +341,6 @@ function InteractiveImportModalContent(
|
||||
fetchInteractiveImportItems({
|
||||
downloadId,
|
||||
movieId,
|
||||
seasonNumber,
|
||||
folder,
|
||||
filterExistingFiles,
|
||||
})
|
||||
|
||||
@@ -4,9 +4,12 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent';
|
||||
import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent';
|
||||
import InteractiveImportModalContent, {
|
||||
InteractiveImportModalContentProps,
|
||||
} from './Interactive/InteractiveImportModalContent';
|
||||
|
||||
interface InteractiveImportModalProps {
|
||||
interface InteractiveImportModalProps
|
||||
extends Omit<InteractiveImportModalContentProps, 'modalTitle'> {
|
||||
isOpen: boolean;
|
||||
folder?: string;
|
||||
downloadId?: string;
|
||||
|
||||
@@ -179,7 +179,9 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{modalTitle} - Select Movie</ModalHeader>
|
||||
<ModalHeader>
|
||||
{translate('SelectMovieModalTitle', { modalTitle })}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
@@ -187,7 +189,7 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
|
||||
>
|
||||
<TextInput
|
||||
className={styles.filterInput}
|
||||
placeholder="Filter movies"
|
||||
placeholder={translate('FilterMoviePlaceholder')}
|
||||
name="filter"
|
||||
value={filter}
|
||||
autoFocus={true}
|
||||
@@ -223,7 +225,7 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>Cancel</Button>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,16 @@ function DeleteMovieModalContent({
|
||||
addImportExclusion,
|
||||
})
|
||||
);
|
||||
}, [movieId, collection, addImportExclusion, deleteFiles, dispatch]);
|
||||
|
||||
onModalClose();
|
||||
}, [
|
||||
movieId,
|
||||
collection,
|
||||
addImportExclusion,
|
||||
deleteFiles,
|
||||
dispatch,
|
||||
onModalClose,
|
||||
]);
|
||||
|
||||
const handleDeleteOptionChange = useCallback(
|
||||
({ name, value }: CheckInputChanged) => {
|
||||
|
||||
@@ -160,10 +160,10 @@
|
||||
}
|
||||
|
||||
.overview {
|
||||
flex: 1 0 auto;
|
||||
flex: 1 0 0;
|
||||
margin-top: 8px;
|
||||
padding-left: 7px;
|
||||
min-height: 0;
|
||||
text-wrap: balance;
|
||||
font-size: $intermediateFontSize;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,835 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import ImdbRating from 'Components/ImdbRating';
|
||||
import InfoLabel from 'Components/InfoLabel';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Marquee from 'Components/Marquee';
|
||||
import Measure from 'Components/Measure';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
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 RottenTomatoRating from 'Components/RottenTomatoRating';
|
||||
import TmdbRating from 'Components/TmdbRating';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import TraktRating from 'Components/TraktRating';
|
||||
import { icons, kinds, sizes, sortDirections, tooltipPositions } from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
|
||||
import EditMovieModal from 'Movie/Edit/EditMovieModal';
|
||||
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
|
||||
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
|
||||
import MovieCollectionLabel from 'Movie/MovieCollectionLabel';
|
||||
import MovieGenres from 'Movie/MovieGenres';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
|
||||
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
|
||||
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import * as keyCodes from 'Utilities/Constants/keyCodes';
|
||||
import formatRuntime from 'Utilities/Date/formatRuntime';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
|
||||
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
|
||||
import MovieDetailsLinks from './MovieDetailsLinks';
|
||||
import MovieReleaseDates from './MovieReleaseDates';
|
||||
import MovieStatusLabel from './MovieStatusLabel';
|
||||
import MovieTagsConnector from './MovieTagsConnector';
|
||||
import MovieTitlesTable from './Titles/MovieTitlesTable';
|
||||
import styles from './MovieDetails.css';
|
||||
|
||||
const defaultFontSize = parseInt(fonts.defaultFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images) {
|
||||
const image = images.find((img) => img.coverType === 'fanart');
|
||||
return image?.url ?? image?.remoteUrl;
|
||||
}
|
||||
|
||||
class MovieDetails extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isOrganizeModalOpen: false,
|
||||
isEditMovieModalOpen: false,
|
||||
isDeleteMovieModalOpen: false,
|
||||
isInteractiveImportModalOpen: false,
|
||||
isInteractiveSearchModalOpen: false,
|
||||
isMovieHistoryModalOpen: false,
|
||||
overviewHeight: 0,
|
||||
titleWidth: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('touchstart', this.onTouchStart);
|
||||
window.addEventListener('touchend', this.onTouchEnd);
|
||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
||||
window.addEventListener('touchmove', this.onTouchMove);
|
||||
window.addEventListener('keyup', this.onKeyUp);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('touchstart', this.onTouchStart);
|
||||
window.removeEventListener('touchend', this.onTouchEnd);
|
||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
||||
window.removeEventListener('touchmove', this.onTouchMove);
|
||||
window.removeEventListener('keyup', this.onKeyUp);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOrganizePress = () => {
|
||||
this.setState({ isOrganizeModalOpen: true });
|
||||
};
|
||||
|
||||
onOrganizeModalClose = () => {
|
||||
this.setState({ isOrganizeModalOpen: false });
|
||||
};
|
||||
|
||||
onInteractiveImportPress = () => {
|
||||
this.setState({ isInteractiveImportModalOpen: true });
|
||||
};
|
||||
|
||||
onInteractiveImportModalClose = () => {
|
||||
this.setState({ isInteractiveImportModalOpen: false });
|
||||
};
|
||||
|
||||
onEditMoviePress = () => {
|
||||
this.setState({ isEditMovieModalOpen: true });
|
||||
};
|
||||
|
||||
onEditMovieModalClose = () => {
|
||||
this.setState({ isEditMovieModalOpen: false });
|
||||
};
|
||||
|
||||
onInteractiveSearchPress = () => {
|
||||
this.setState({ isInteractiveSearchModalOpen: true });
|
||||
};
|
||||
|
||||
onInteractiveSearchModalClose = () => {
|
||||
this.setState({ isInteractiveSearchModalOpen: false });
|
||||
};
|
||||
|
||||
onDeleteMoviePress = () => {
|
||||
this.setState({
|
||||
isEditMovieModalOpen: false,
|
||||
isDeleteMovieModalOpen: true
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteMovieModalClose = () => {
|
||||
this.setState({ isDeleteMovieModalOpen: false });
|
||||
};
|
||||
|
||||
onMovieHistoryPress = () => {
|
||||
this.setState({ isMovieHistoryModalOpen: true });
|
||||
};
|
||||
|
||||
onMovieHistoryModalClose = () => {
|
||||
this.setState({ isMovieHistoryModalOpen: false });
|
||||
};
|
||||
|
||||
onMeasure = ({ height }) => {
|
||||
this.setState({ overviewHeight: height });
|
||||
};
|
||||
|
||||
onTitleMeasure = ({ width }) => {
|
||||
this.setState({ titleWidth: width });
|
||||
};
|
||||
|
||||
onKeyUp = (event) => {
|
||||
if (event.composedPath && event.composedPath().length === 4) {
|
||||
if (event.keyCode === keyCodes.LEFT_ARROW) {
|
||||
this.props.onGoToMovie(this.props.previousMovie.titleSlug);
|
||||
}
|
||||
if (event.keyCode === keyCodes.RIGHT_ARROW) {
|
||||
this.props.onGoToMovie(this.props.nextMovie.titleSlug);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onTouchStart = (event) => {
|
||||
const touches = event.touches;
|
||||
const touchStart = touches[0].pageX;
|
||||
const touchY = touches[0].pageY;
|
||||
|
||||
// Only change when swipe is on header, we need horizontal scroll on tables
|
||||
if (touchY > 470) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
touchStart < 50 ||
|
||||
this.props.isSidebarVisible ||
|
||||
this.state.isOrganizeModalOpen ||
|
||||
this.state.isEditMovieModalOpen ||
|
||||
this.state.isDeleteMovieModalOpen ||
|
||||
this.state.isInteractiveImportModalOpen ||
|
||||
this.state.isInteractiveSearchModalOpen ||
|
||||
this.state.isMovieHistoryModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchStart = touchStart;
|
||||
};
|
||||
|
||||
onTouchEnd = (event) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
|
||||
this.props.onGoToMovie(this.props.previousMovie.titleSlug);
|
||||
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
|
||||
this.props.onGoToMovie(this.props.nextMovie.titleSlug);
|
||||
}
|
||||
|
||||
this._touchStart = null;
|
||||
};
|
||||
|
||||
onTouchCancel = (event) => {
|
||||
this._touchStart = null;
|
||||
};
|
||||
|
||||
onTouchMove = (event) => {
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
tmdbId,
|
||||
imdbId,
|
||||
title,
|
||||
originalTitle,
|
||||
year,
|
||||
inCinemas,
|
||||
physicalRelease,
|
||||
digitalRelease,
|
||||
runtime,
|
||||
certification,
|
||||
ratings,
|
||||
path,
|
||||
statistics,
|
||||
qualityProfileId,
|
||||
monitored,
|
||||
studio,
|
||||
originalLanguage,
|
||||
genres,
|
||||
collection,
|
||||
overview,
|
||||
status,
|
||||
youTubeTrailerId,
|
||||
isAvailable,
|
||||
images,
|
||||
tags,
|
||||
isSaving,
|
||||
isRefreshing,
|
||||
isSearching,
|
||||
isFetching,
|
||||
isSmallScreen,
|
||||
movieFilesError,
|
||||
movieCreditsError,
|
||||
extraFilesError,
|
||||
hasMovieFiles,
|
||||
previousMovie,
|
||||
nextMovie,
|
||||
onMonitorTogglePress,
|
||||
onRefreshPress,
|
||||
onSearchPress,
|
||||
queueItem,
|
||||
movieRuntimeFormat
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
sizeOnDisk = 0
|
||||
} = statistics;
|
||||
|
||||
const {
|
||||
isOrganizeModalOpen,
|
||||
isEditMovieModalOpen,
|
||||
isDeleteMovieModalOpen,
|
||||
isInteractiveImportModalOpen,
|
||||
isInteractiveSearchModalOpen,
|
||||
isMovieHistoryModalOpen,
|
||||
overviewHeight,
|
||||
titleWidth
|
||||
} = this.state;
|
||||
|
||||
const statusDetails = getMovieStatusDetails(status);
|
||||
|
||||
const fanartUrl = getFanartUrl(images);
|
||||
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
|
||||
|
||||
const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`;
|
||||
|
||||
return (
|
||||
<PageContent title={titleWithYear}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshInformationAndScanDisk')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={onRefreshPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMovie')}
|
||||
iconName={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
title={undefined}
|
||||
onPress={onSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('InteractiveSearch')}
|
||||
iconName={icons.INTERACTIVE}
|
||||
isSpinning={isSearching}
|
||||
title={undefined}
|
||||
onPress={this.onInteractiveSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('PreviewRename')}
|
||||
iconName={icons.ORGANIZE}
|
||||
isDisabled={!hasMovieFiles}
|
||||
onPress={this.onOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageFiles')}
|
||||
iconName={icons.MOVIE_FILE}
|
||||
onPress={this.onInteractiveImportPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('History')}
|
||||
iconName={icons.HISTORY}
|
||||
onPress={this.onMovieHistoryPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Edit')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={this.onEditMoviePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Delete')}
|
||||
iconName={icons.DELETE}
|
||||
onPress={this.onDeleteMoviePress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
style={
|
||||
fanartUrl ?
|
||||
{ backgroundImage: `url(${fanartUrl})` } :
|
||||
null
|
||||
}
|
||||
>
|
||||
<div className={styles.backdropOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={styles.headerContent}>
|
||||
<MoviePoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={500}
|
||||
lazy={false}
|
||||
/>
|
||||
|
||||
<div className={styles.info}>
|
||||
<Measure onMeasure={this.onTitleMeasure}>
|
||||
<div className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={onMonitorTogglePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title} style={{ width: marqueeWidth }}>
|
||||
<Marquee text={title} title={originalTitle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.movieNavigationButtons}>
|
||||
<IconButton
|
||||
className={styles.movieNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('GoToInterp', [previousMovie.title])}
|
||||
to={`/movie/${previousMovie.titleSlug}`}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles.movieNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('GoToInterp', [nextMovie.title])}
|
||||
to={`/movie/${nextMovie.titleSlug}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Measure>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{
|
||||
certification ?
|
||||
<span className={styles.certification} title={translate('Certification')}>
|
||||
{certification}
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
<span className={styles.year}>
|
||||
<Popover
|
||||
anchor={
|
||||
year > 0 ? (
|
||||
year
|
||||
) : (
|
||||
<Icon
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
size={20}
|
||||
/>
|
||||
)
|
||||
}
|
||||
title={translate('ReleaseDates')}
|
||||
body={
|
||||
<MovieReleaseDates
|
||||
tmdbId={tmdbId}
|
||||
inCinemas={inCinemas}
|
||||
digitalRelease={digitalRelease}
|
||||
physicalRelease={physicalRelease}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{
|
||||
runtime ?
|
||||
<span className={styles.runtime} title={translate('Runtime')}>
|
||||
{formatRuntime(runtime, movieRuntimeFormat)}
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
<span className={styles.links}>
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.EXTERNAL_LINK}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
tooltip={
|
||||
<MovieDetailsLinks
|
||||
tmdbId={tmdbId}
|
||||
imdbId={imdbId}
|
||||
youTubeTrailerId={youTubeTrailerId}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
!!tags.length &&
|
||||
<span>
|
||||
<Tooltip
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.TAGS}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
tooltip={
|
||||
<MovieTagsConnector movieId={id} />
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
{
|
||||
ratings.tmdb ?
|
||||
<span className={styles.rating}>
|
||||
<TmdbRating
|
||||
ratings={ratings}
|
||||
iconSize={20}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
{
|
||||
ratings.imdb ?
|
||||
<span className={styles.rating}>
|
||||
<ImdbRating
|
||||
ratings={ratings}
|
||||
iconSize={20}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
{
|
||||
ratings.rottenTomatoes ?
|
||||
<span className={styles.rating}>
|
||||
<RottenTomatoRating
|
||||
ratings={ratings}
|
||||
iconSize={20}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
{
|
||||
ratings.trakt ?
|
||||
<span className={styles.rating}>
|
||||
<TraktRating
|
||||
ratings={ratings}
|
||||
iconSize={20}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className={styles.detailsLabels}>
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Path')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.path}>
|
||||
{path}
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Status')}
|
||||
title={statusDetails.message}
|
||||
kind={kinds.DELETE}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.statusName}>
|
||||
<MovieStatusLabel
|
||||
status={status}
|
||||
hasMovieFiles={hasMovieFiles}
|
||||
monitored={monitored}
|
||||
isAvailable={isAvailable}
|
||||
queueItem={queueItem}
|
||||
/>
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('QualityProfile')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.qualityProfileName}>
|
||||
{
|
||||
<QualityProfileNameConnector
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
}
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Size')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
{
|
||||
collection ?
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Collection')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div className={styles.collection}>
|
||||
<MovieCollectionLabel
|
||||
tmdbId={collection.tmdbId}
|
||||
/>
|
||||
</div>
|
||||
</InfoLabel> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
originalLanguage?.name && !isSmallScreen ?
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('OriginalLanguage')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.originalLanguage}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</InfoLabel> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
studio && !isSmallScreen ?
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Studio')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.studio}>
|
||||
{studio}
|
||||
</span>
|
||||
</InfoLabel> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
genres.length && !isSmallScreen ?
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Genres')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<MovieGenres className={styles.genres} genres={genres} />
|
||||
</InfoLabel> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
|
||||
<Measure onMeasure={this.onMeasure}>
|
||||
<div className={styles.overview}>
|
||||
<TextTruncate
|
||||
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
|
||||
text={overview}
|
||||
/>
|
||||
</div>
|
||||
</Measure>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{
|
||||
!isFetching && movieFilesError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('LoadingMovieFilesFailed')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && movieCreditsError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('LoadingMovieCreditsFailed')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && extraFilesError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('LoadingMovieExtraFilesFailed')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
<FieldSet legend={translate('Files')}>
|
||||
<MovieFileEditorTable
|
||||
movieId={id}
|
||||
/>
|
||||
|
||||
<ExtraFileTable
|
||||
movieId={id}
|
||||
/>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Cast')}>
|
||||
<MovieCastPosters
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Crew')}>
|
||||
<MovieCrewPosters
|
||||
isSmallScreen={isSmallScreen}
|
||||
/>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Titles')}>
|
||||
<MovieTitlesTable
|
||||
movieId={id}
|
||||
/>
|
||||
</FieldSet>
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={this.onOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<EditMovieModal
|
||||
isOpen={isEditMovieModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={this.onEditMovieModalClose}
|
||||
onDeleteMoviePress={this.onDeleteMoviePress}
|
||||
/>
|
||||
|
||||
<MovieHistoryModal
|
||||
isOpen={isMovieHistoryModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={this.onMovieHistoryModalClose}
|
||||
/>
|
||||
|
||||
<DeleteMovieModal
|
||||
isOpen={isDeleteMovieModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={this.onDeleteMovieModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isInteractiveImportModalOpen}
|
||||
movieId={id}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.ASCENDING}
|
||||
showMovie={false}
|
||||
allowMovieChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageFiles')}
|
||||
onModalClose={this.onInteractiveImportModalClose}
|
||||
/>
|
||||
|
||||
<MovieInteractiveSearchModal
|
||||
isOpen={isInteractiveSearchModalOpen}
|
||||
movieId={id}
|
||||
movieTitle={title}
|
||||
onModalClose={this.onInteractiveSearchModalClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MovieDetails.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
tmdbId: PropTypes.number.isRequired,
|
||||
imdbId: PropTypes.string,
|
||||
title: PropTypes.string.isRequired,
|
||||
originalTitle: PropTypes.string,
|
||||
year: PropTypes.number.isRequired,
|
||||
runtime: PropTypes.number.isRequired,
|
||||
certification: PropTypes.string,
|
||||
ratings: PropTypes.object.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
statistics: PropTypes.object.isRequired,
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
studio: PropTypes.string,
|
||||
originalLanguage: PropTypes.object,
|
||||
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
collection: PropTypes.object,
|
||||
youTubeTrailerId: PropTypes.string,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
inCinemas: PropTypes.string,
|
||||
physicalRelease: PropTypes.string,
|
||||
digitalRelease: PropTypes.string,
|
||||
overview: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
isRefreshing: PropTypes.bool.isRequired,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
isSidebarVisible: PropTypes.bool.isRequired,
|
||||
movieFilesError: PropTypes.object,
|
||||
movieCreditsError: PropTypes.object,
|
||||
extraFilesError: PropTypes.object,
|
||||
hasMovieFiles: PropTypes.bool.isRequired,
|
||||
previousMovie: PropTypes.object.isRequired,
|
||||
nextMovie: PropTypes.object.isRequired,
|
||||
onMonitorTogglePress: PropTypes.func.isRequired,
|
||||
onRefreshPress: PropTypes.func.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired,
|
||||
onGoToMovie: PropTypes.func.isRequired,
|
||||
queueItem: PropTypes.object,
|
||||
movieRuntimeFormat: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
MovieDetails.defaultProps = {
|
||||
genres: [],
|
||||
statistics: {},
|
||||
tags: [],
|
||||
isSaving: false
|
||||
};
|
||||
|
||||
export default MovieDetails;
|
||||
981
frontend/src/Movie/Details/MovieDetails.tsx
Normal file
981
frontend/src/Movie/Details/MovieDetails.tsx
Normal file
@@ -0,0 +1,981 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import ImdbRating from 'Components/ImdbRating';
|
||||
import InfoLabel from 'Components/InfoLabel';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Marquee from 'Components/Marquee';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
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 RottenTomatoRating from 'Components/RottenTomatoRating';
|
||||
import TmdbRating from 'Components/TmdbRating';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import TraktRating from 'Components/TraktRating';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import {
|
||||
icons,
|
||||
kinds,
|
||||
sizes,
|
||||
sortDirections,
|
||||
tooltipPositions,
|
||||
} from 'Helpers/Props';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
|
||||
import EditMovieModal from 'Movie/Edit/EditMovieModal';
|
||||
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
|
||||
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
|
||||
import { Image, Statistics } from 'Movie/Movie';
|
||||
import MovieCollectionLabel from 'Movie/MovieCollectionLabel';
|
||||
import MovieGenres from 'Movie/MovieGenres';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
|
||||
import useMovie from 'Movie/useMovie';
|
||||
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
|
||||
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
|
||||
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
|
||||
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import {
|
||||
clearExtraFiles,
|
||||
fetchExtraFiles,
|
||||
} from 'Store/Actions/extraFileActions';
|
||||
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
|
||||
import {
|
||||
clearMovieCredits,
|
||||
fetchMovieCredits,
|
||||
} from 'Store/Actions/movieCreditsActions';
|
||||
import {
|
||||
clearMovieFiles,
|
||||
fetchMovieFiles,
|
||||
} from 'Store/Actions/movieFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import {
|
||||
cancelFetchReleases,
|
||||
clearReleases,
|
||||
} from 'Store/Actions/releaseActions';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import { fetchImportListSchema } from 'Store/Actions/Settings/importLists';
|
||||
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import fonts from 'Styles/Variables/fonts';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import { findCommand, isCommandExecuting } from 'Utilities/Command';
|
||||
import formatRuntime from 'Utilities/Date/formatRuntime';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
|
||||
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
|
||||
import MovieDetailsLinks from './MovieDetailsLinks';
|
||||
import MovieReleaseDates from './MovieReleaseDates';
|
||||
import MovieStatusLabel from './MovieStatusLabel';
|
||||
import MovieTags from './MovieTags';
|
||||
import MovieTitlesTable from './Titles/MovieTitlesTable';
|
||||
import styles from './MovieDetails.css';
|
||||
|
||||
const defaultFontSize = parseInt(fonts.defaultFontSize);
|
||||
const lineHeight = parseFloat(fonts.lineHeight);
|
||||
|
||||
function getFanartUrl(images: Image[]) {
|
||||
const image = images.find((image) => image.coverType === 'fanart');
|
||||
return image?.url ?? image?.remoteUrl;
|
||||
}
|
||||
|
||||
function createMovieFilesSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.movieFiles,
|
||||
({ items, isFetching, isPopulated, error }) => {
|
||||
const hasMovieFiles = !!items.length;
|
||||
|
||||
return {
|
||||
isMovieFilesFetching: isFetching,
|
||||
isMovieFilesPopulated: isPopulated,
|
||||
movieFilesError: error,
|
||||
hasMovieFiles,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createExtraFilesSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.extraFiles,
|
||||
({ isFetching, isPopulated, error }) => {
|
||||
return {
|
||||
isExtraFilesFetching: isFetching,
|
||||
isExtraFilesPopulated: isPopulated,
|
||||
extraFilesError: error,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMovieCreditsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.movieCredits,
|
||||
({ isFetching, isPopulated, error }) => {
|
||||
return {
|
||||
isMovieCreditsFetching: isFetching,
|
||||
isMovieCreditsPopulated: isPopulated,
|
||||
movieCreditsError: error,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface MovieDetailsProps {
|
||||
movieId: number;
|
||||
}
|
||||
|
||||
function MovieDetails({ movieId }: MovieDetailsProps) {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
|
||||
const movie = useMovie(movieId);
|
||||
const allMovies = useSelector(createAllMoviesSelector());
|
||||
|
||||
const { isMovieFilesFetching, movieFilesError, hasMovieFiles } = useSelector(
|
||||
createMovieFilesSelector()
|
||||
);
|
||||
const { isExtraFilesFetching, extraFilesError } = useSelector(
|
||||
createExtraFilesSelector()
|
||||
);
|
||||
const { isMovieCreditsFetching, movieCreditsError } = useSelector(
|
||||
createMovieCreditsSelector()
|
||||
);
|
||||
const { movieRuntimeFormat } = useSelector(createUISettingsSelector());
|
||||
const isSidebarVisible = useSelector(
|
||||
(state: AppState) => state.app.isSidebarVisible
|
||||
);
|
||||
const { isSmallScreen } = useSelector(createDimensionsSelector());
|
||||
|
||||
const commands = useSelector(createCommandsSelector());
|
||||
|
||||
const { isRefreshing, isRenaming, isSearching } = useMemo(() => {
|
||||
const movieRefreshingCommand = findCommand(commands, {
|
||||
name: commandNames.REFRESH_MOVIE,
|
||||
});
|
||||
|
||||
const isMovieRefreshingCommandExecuting = isCommandExecuting(
|
||||
movieRefreshingCommand
|
||||
);
|
||||
|
||||
const allMoviesRefreshing =
|
||||
isMovieRefreshingCommandExecuting &&
|
||||
!movieRefreshingCommand?.body.movieIds?.length;
|
||||
|
||||
const isMovieRefreshing =
|
||||
isMovieRefreshingCommandExecuting &&
|
||||
movieRefreshingCommand?.body.movieIds?.includes(movieId);
|
||||
|
||||
const isSearchingExecuting = isCommandExecuting(
|
||||
findCommand(commands, {
|
||||
name: commandNames.MOVIE_SEARCH,
|
||||
movieIds: [movieId],
|
||||
})
|
||||
);
|
||||
|
||||
const isRenamingFiles = isCommandExecuting(
|
||||
findCommand(commands, {
|
||||
name: commandNames.RENAME_FILES,
|
||||
movieId,
|
||||
})
|
||||
);
|
||||
|
||||
const isRenamingMovieCommand = findCommand(commands, {
|
||||
name: commandNames.RENAME_MOVIE,
|
||||
});
|
||||
|
||||
const isRenamingMovie =
|
||||
isCommandExecuting(isRenamingMovieCommand) &&
|
||||
isRenamingMovieCommand?.body?.movieIds?.includes(movieId);
|
||||
|
||||
return {
|
||||
isRefreshing: isMovieRefreshing || allMoviesRefreshing,
|
||||
isRenaming: isRenamingFiles || isRenamingMovie,
|
||||
isSearching: isSearchingExecuting,
|
||||
};
|
||||
}, [movieId, commands]);
|
||||
|
||||
const { nextMovie, previousMovie } = useMemo(() => {
|
||||
const sortedMovies = [...allMovies].sort(sortByProp('sortTitle'));
|
||||
const movieIndex = sortedMovies.findIndex((movie) => movie.id === movieId);
|
||||
|
||||
if (movieIndex === -1) {
|
||||
return {
|
||||
nextMovie: undefined,
|
||||
previousMovie: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const nextMovie = sortedMovies[movieIndex + 1] ?? sortedMovies[0];
|
||||
const previousMovie =
|
||||
sortedMovies[movieIndex - 1] ?? sortedMovies[sortedMovies.length - 1];
|
||||
|
||||
return {
|
||||
nextMovie: {
|
||||
title: nextMovie.title,
|
||||
titleSlug: nextMovie.titleSlug,
|
||||
},
|
||||
previousMovie: {
|
||||
title: previousMovie.title,
|
||||
titleSlug: previousMovie.titleSlug,
|
||||
},
|
||||
};
|
||||
}, [movieId, allMovies]);
|
||||
|
||||
const touchStart = useRef<number | null>(null);
|
||||
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
|
||||
const [isManageMoviesModalOpen, setIsManageMoviesModalOpen] = useState(false);
|
||||
const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] =
|
||||
useState(false);
|
||||
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
|
||||
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);
|
||||
const [isMovieHistoryModalOpen, setIsMovieHistoryModalOpen] = useState(false);
|
||||
const [titleRef, { width: titleWidth }] = useMeasure();
|
||||
const [overviewRef, { height: overviewHeight }] = useMeasure();
|
||||
const wasRefreshing = usePrevious(isRefreshing);
|
||||
const wasRenaming = usePrevious(isRenaming);
|
||||
|
||||
const handleOrganizePress = useCallback(() => {
|
||||
setIsOrganizeModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOrganizeModalClose = useCallback(() => {
|
||||
setIsOrganizeModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleManageMoviesPress = useCallback(() => {
|
||||
setIsManageMoviesModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleManageMoviesModalClose = useCallback(() => {
|
||||
setIsManageMoviesModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInteractiveSearchPress = useCallback(() => {
|
||||
setIsInteractiveSearchModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleInteractiveSearchModalClose = useCallback(() => {
|
||||
setIsInteractiveSearchModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleEditMoviePress = useCallback(() => {
|
||||
setIsEditMovieModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditMovieModalClose = useCallback(() => {
|
||||
setIsEditMovieModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleDeleteMoviePress = useCallback(() => {
|
||||
setIsEditMovieModalOpen(false);
|
||||
setIsDeleteMovieModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteMovieModalClose = useCallback(() => {
|
||||
setIsDeleteMovieModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleMovieHistoryPress = useCallback(() => {
|
||||
setIsMovieHistoryModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleMovieHistoryModalClose = useCallback(() => {
|
||||
setIsMovieHistoryModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleMonitorTogglePress = useCallback(
|
||||
(value: boolean) => {
|
||||
dispatch(
|
||||
toggleMovieMonitored({
|
||||
movieId,
|
||||
monitored: value,
|
||||
})
|
||||
);
|
||||
},
|
||||
[movieId, dispatch]
|
||||
);
|
||||
|
||||
const handleRefreshPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.REFRESH_MOVIE,
|
||||
movieIds: [movieId],
|
||||
})
|
||||
);
|
||||
}, [movieId, dispatch]);
|
||||
|
||||
const handleSearchPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.MOVIE_SEARCH,
|
||||
movieIds: [movieId],
|
||||
})
|
||||
);
|
||||
}, [movieId, dispatch]);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.touches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
const touchY = touches[0].pageY;
|
||||
|
||||
// Only change when swipe is on header, we need horizontal scroll on tables
|
||||
if (touchY > 470) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
currentTouch < 50 ||
|
||||
isSidebarVisible ||
|
||||
isOrganizeModalOpen ||
|
||||
isEditMovieModalOpen ||
|
||||
isDeleteMovieModalOpen ||
|
||||
isManageMoviesModalOpen ||
|
||||
isInteractiveSearchModalOpen ||
|
||||
isMovieHistoryModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStart.current = currentTouch;
|
||||
},
|
||||
[
|
||||
isSidebarVisible,
|
||||
isOrganizeModalOpen,
|
||||
isEditMovieModalOpen,
|
||||
isDeleteMovieModalOpen,
|
||||
isManageMoviesModalOpen,
|
||||
isInteractiveSearchModalOpen,
|
||||
isMovieHistoryModalOpen,
|
||||
]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!touchStart.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
currentTouch > touchStart.current &&
|
||||
currentTouch - touchStart.current > 100 &&
|
||||
previousMovie !== undefined
|
||||
) {
|
||||
history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`));
|
||||
} else if (
|
||||
currentTouch < touchStart.current &&
|
||||
touchStart.current - currentTouch > 100 &&
|
||||
nextMovie !== undefined
|
||||
) {
|
||||
history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`));
|
||||
}
|
||||
|
||||
touchStart.current = null;
|
||||
},
|
||||
[previousMovie, nextMovie, history]
|
||||
);
|
||||
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
touchStart.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(() => {
|
||||
if (!touchStart.current) {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (
|
||||
isOrganizeModalOpen ||
|
||||
isManageMoviesModalOpen ||
|
||||
isInteractiveSearchModalOpen ||
|
||||
isEditMovieModalOpen ||
|
||||
isDeleteMovieModalOpen ||
|
||||
isMovieHistoryModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.composedPath && event.composedPath().length === 4) {
|
||||
if (event.key === 'ArrowLeft' && previousMovie !== undefined) {
|
||||
history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`));
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowRight' && nextMovie !== undefined) {
|
||||
history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isOrganizeModalOpen,
|
||||
isManageMoviesModalOpen,
|
||||
isInteractiveSearchModalOpen,
|
||||
isEditMovieModalOpen,
|
||||
isDeleteMovieModalOpen,
|
||||
isMovieHistoryModalOpen,
|
||||
previousMovie,
|
||||
nextMovie,
|
||||
history,
|
||||
]
|
||||
);
|
||||
|
||||
const populate = useCallback(() => {
|
||||
dispatch(fetchMovieFiles({ movieId }));
|
||||
dispatch(fetchExtraFiles({ movieId }));
|
||||
dispatch(fetchMovieCredits({ movieId }));
|
||||
dispatch(fetchQueueDetails({ movieId }));
|
||||
dispatch(fetchImportListSchema());
|
||||
dispatch(fetchRootFolders());
|
||||
}, [movieId, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
populate();
|
||||
}, [populate]);
|
||||
|
||||
useEffect(() => {
|
||||
registerPagePopulator(populate, ['movieUpdated']);
|
||||
|
||||
return () => {
|
||||
unregisterPagePopulator(populate);
|
||||
dispatch(clearMovieFiles());
|
||||
dispatch(clearExtraFiles());
|
||||
dispatch(clearMovieCredits());
|
||||
dispatch(clearQueueDetails());
|
||||
dispatch(cancelFetchReleases());
|
||||
dispatch(clearReleases());
|
||||
};
|
||||
}, [populate, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!isRefreshing && wasRefreshing) || (!isRenaming && wasRenaming)) {
|
||||
populate();
|
||||
}
|
||||
}, [isRefreshing, wasRefreshing, isRenaming, wasRenaming, populate]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('touchstart', handleTouchStart);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
window.addEventListener('touchcancel', handleTouchCancel);
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', handleTouchStart);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
window.removeEventListener('touchcancel', handleTouchCancel);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [
|
||||
handleTouchStart,
|
||||
handleTouchEnd,
|
||||
handleTouchCancel,
|
||||
handleTouchMove,
|
||||
handleKeyUp,
|
||||
]);
|
||||
|
||||
if (!movie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
id,
|
||||
tmdbId,
|
||||
imdbId,
|
||||
title,
|
||||
originalTitle,
|
||||
year,
|
||||
inCinemas,
|
||||
physicalRelease,
|
||||
digitalRelease,
|
||||
runtime,
|
||||
certification,
|
||||
ratings,
|
||||
path,
|
||||
statistics = {} as Statistics,
|
||||
qualityProfileId,
|
||||
monitored,
|
||||
studio,
|
||||
originalLanguage,
|
||||
genres = [],
|
||||
collection,
|
||||
overview,
|
||||
status,
|
||||
youTubeTrailerId,
|
||||
isAvailable,
|
||||
images,
|
||||
tags,
|
||||
isSaving = false,
|
||||
} = movie;
|
||||
|
||||
const { sizeOnDisk = 0 } = statistics;
|
||||
|
||||
const statusDetails = getMovieStatusDetails(status);
|
||||
|
||||
const fanartUrl = getFanartUrl(images);
|
||||
const isFetching =
|
||||
isMovieFilesFetching || isExtraFilesFetching || isMovieCreditsFetching;
|
||||
|
||||
const marqueeWidth = isSmallScreen ? titleWidth : titleWidth - 150;
|
||||
|
||||
const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`;
|
||||
|
||||
return (
|
||||
<PageContent title={titleWithYear}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('RefreshAndScan')}
|
||||
iconName={icons.REFRESH}
|
||||
spinningName={icons.REFRESH}
|
||||
title={translate('RefreshInformationAndScanDisk')}
|
||||
isSpinning={isRefreshing}
|
||||
onPress={handleRefreshPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchMovie')}
|
||||
iconName={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
title={undefined}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('InteractiveSearch')}
|
||||
iconName={icons.INTERACTIVE}
|
||||
isSpinning={isSearching}
|
||||
title={undefined}
|
||||
onPress={handleInteractiveSearchPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('PreviewRename')}
|
||||
iconName={icons.ORGANIZE}
|
||||
isDisabled={!hasMovieFiles}
|
||||
onPress={handleOrganizePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('ManageFiles')}
|
||||
iconName={icons.MOVIE_FILE}
|
||||
onPress={handleManageMoviesPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('History')}
|
||||
iconName={icons.HISTORY}
|
||||
onPress={handleMovieHistoryPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Edit')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={handleEditMoviePress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('Delete')}
|
||||
iconName={icons.DELETE}
|
||||
onPress={handleDeleteMoviePress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody innerClassName={styles.innerContentBody}>
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
style={
|
||||
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
|
||||
}
|
||||
>
|
||||
<div className={styles.backdropOverlay} />
|
||||
</div>
|
||||
|
||||
<div className={styles.headerContent}>
|
||||
<MoviePoster
|
||||
className={styles.poster}
|
||||
images={images}
|
||||
size={500}
|
||||
lazy={false}
|
||||
/>
|
||||
|
||||
<div className={styles.info}>
|
||||
<div ref={titleRef} className={styles.titleRow}>
|
||||
<div className={styles.titleContainer}>
|
||||
<div className={styles.toggleMonitoredContainer}>
|
||||
<MonitorToggleButton
|
||||
className={styles.monitorToggleButton}
|
||||
monitored={monitored}
|
||||
isSaving={isSaving}
|
||||
size={40}
|
||||
onPress={handleMonitorTogglePress}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.title} style={{ width: marqueeWidth }}>
|
||||
<Marquee text={title} title={originalTitle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.movieNavigationButtons}>
|
||||
{previousMovie ? (
|
||||
<IconButton
|
||||
className={styles.movieNavigationButton}
|
||||
name={icons.ARROW_LEFT}
|
||||
size={30}
|
||||
title={translate('MovieDetailsGoTo', {
|
||||
title: previousMovie.title,
|
||||
})}
|
||||
to={`/movie/${previousMovie.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{nextMovie ? (
|
||||
<IconButton
|
||||
className={styles.movieNavigationButton}
|
||||
name={icons.ARROW_RIGHT}
|
||||
size={30}
|
||||
title={translate('MovieDetailsGoTo', {
|
||||
title: nextMovie.title,
|
||||
})}
|
||||
to={`/movie/${nextMovie.titleSlug}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{certification ? (
|
||||
<span
|
||||
className={styles.certification}
|
||||
title={translate('Certification')}
|
||||
>
|
||||
{certification}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className={styles.year}>
|
||||
<Popover
|
||||
anchor={
|
||||
year > 0 ? (
|
||||
year
|
||||
) : (
|
||||
<Icon
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
size={20}
|
||||
/>
|
||||
)
|
||||
}
|
||||
title={translate('ReleaseDates')}
|
||||
body={
|
||||
<MovieReleaseDates
|
||||
tmdbId={tmdbId}
|
||||
inCinemas={inCinemas}
|
||||
digitalRelease={digitalRelease}
|
||||
physicalRelease={physicalRelease}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{runtime ? (
|
||||
<span
|
||||
className={styles.runtime}
|
||||
title={translate('Runtime')}
|
||||
>
|
||||
{formatRuntime(runtime, movieRuntimeFormat)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className={styles.links}>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.EXTERNAL_LINK} size={20} />}
|
||||
tooltip={
|
||||
<MovieDetailsLinks
|
||||
tmdbId={tmdbId}
|
||||
imdbId={imdbId}
|
||||
youTubeTrailerId={youTubeTrailerId}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{!!tags.length && (
|
||||
<span>
|
||||
<Tooltip
|
||||
anchor={<Icon name={icons.TAGS} size={20} />}
|
||||
tooltip={<MovieTags movieId={id} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.details}>
|
||||
{ratings.tmdb ? (
|
||||
<span className={styles.rating}>
|
||||
<TmdbRating ratings={ratings} iconSize={20} />
|
||||
</span>
|
||||
) : null}
|
||||
{ratings.imdb ? (
|
||||
<span className={styles.rating}>
|
||||
<ImdbRating ratings={ratings} iconSize={20} />
|
||||
</span>
|
||||
) : null}
|
||||
{ratings.rottenTomatoes ? (
|
||||
<span className={styles.rating}>
|
||||
<RottenTomatoRating ratings={ratings} iconSize={20} />
|
||||
</span>
|
||||
) : null}
|
||||
{ratings.trakt ? (
|
||||
<span className={styles.rating}>
|
||||
<TraktRating ratings={ratings} iconSize={20} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Path')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.path}>{path}</span>
|
||||
</InfoLabel>
|
||||
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Status')}
|
||||
title={statusDetails.message}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.statusName}>
|
||||
<MovieStatusLabel
|
||||
movieId={id}
|
||||
monitored={monitored}
|
||||
isAvailable={isAvailable}
|
||||
hasMovieFiles={hasMovieFiles}
|
||||
status={status}
|
||||
/>
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('QualityProfile')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.qualityProfileName}>
|
||||
<QualityProfileName qualityProfileId={qualityProfileId} />
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Size')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.sizeOnDisk}>
|
||||
{formatBytes(sizeOnDisk)}
|
||||
</span>
|
||||
</InfoLabel>
|
||||
|
||||
{collection ? (
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Collection')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<div className={styles.collection}>
|
||||
<MovieCollectionLabel tmdbId={collection.tmdbId} />
|
||||
</div>
|
||||
</InfoLabel>
|
||||
) : null}
|
||||
|
||||
{originalLanguage?.name && !isSmallScreen ? (
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('OriginalLanguage')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.originalLanguage}>
|
||||
{originalLanguage.name}
|
||||
</span>
|
||||
</InfoLabel>
|
||||
) : null}
|
||||
|
||||
{studio && !isSmallScreen ? (
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Studio')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<span className={styles.studio}>{studio}</span>
|
||||
</InfoLabel>
|
||||
) : null}
|
||||
|
||||
{genres.length && !isSmallScreen ? (
|
||||
<InfoLabel
|
||||
className={styles.detailsInfoLabel}
|
||||
name={translate('Genres')}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<MovieGenres className={styles.genres} genres={genres} />
|
||||
</InfoLabel>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div ref={overviewRef} className={styles.overview}>
|
||||
<TextTruncate
|
||||
line={Math.floor(
|
||||
overviewHeight / (defaultFontSize * lineHeight)
|
||||
)}
|
||||
text={overview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentContainer}>
|
||||
{!isFetching && movieFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('LoadingMovieFilesFailed')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!isFetching && extraFilesError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('LoadingMovieExtraFilesFailed')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{!isFetching && movieCreditsError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('LoadingMovieCreditsFailed')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<FieldSet legend={translate('Files')}>
|
||||
<MovieFileEditorTable movieId={id} />
|
||||
|
||||
<ExtraFileTable movieId={id} />
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Cast')}>
|
||||
<MovieCastPosters isSmallScreen={isSmallScreen} />
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Crew')}>
|
||||
<MovieCrewPosters isSmallScreen={isSmallScreen} />
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Titles')}>
|
||||
<MovieTitlesTable movieId={id} />
|
||||
</FieldSet>
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModal
|
||||
isOpen={isOrganizeModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={handleOrganizeModalClose}
|
||||
/>
|
||||
|
||||
<EditMovieModal
|
||||
isOpen={isEditMovieModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={handleEditMovieModalClose}
|
||||
onDeleteMoviePress={handleDeleteMoviePress}
|
||||
/>
|
||||
|
||||
<MovieHistoryModal
|
||||
isOpen={isMovieHistoryModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={handleMovieHistoryModalClose}
|
||||
/>
|
||||
|
||||
<DeleteMovieModal
|
||||
isOpen={isDeleteMovieModalOpen}
|
||||
movieId={id}
|
||||
onModalClose={handleDeleteMovieModalClose}
|
||||
/>
|
||||
|
||||
<InteractiveImportModal
|
||||
isOpen={isManageMoviesModalOpen}
|
||||
movieId={id}
|
||||
title={title}
|
||||
folder={path}
|
||||
initialSortKey="relativePath"
|
||||
initialSortDirection={sortDirections.ASCENDING}
|
||||
showMovie={false}
|
||||
allowMovieChange={false}
|
||||
showDelete={true}
|
||||
showImportMode={false}
|
||||
modalTitle={translate('ManageFiles')}
|
||||
onModalClose={handleManageMoviesModalClose}
|
||||
/>
|
||||
|
||||
<MovieInteractiveSearchModal
|
||||
isOpen={isInteractiveSearchModalOpen}
|
||||
movieId={id}
|
||||
movieTitle={title}
|
||||
onModalClose={handleInteractiveSearchModalClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default MovieDetails;
|
||||
@@ -1,334 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
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 { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { clearExtraFiles, fetchExtraFiles } from 'Store/Actions/extraFileActions';
|
||||
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
|
||||
import { clearMovieCredits, fetchMovieCredits } from 'Store/Actions/movieCreditsActions';
|
||||
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import { fetchImportListSchema } from 'Store/Actions/settingsActions';
|
||||
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import { findCommand, isCommandExecuting } from 'Utilities/Command';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import MovieDetails from './MovieDetails';
|
||||
|
||||
const selectMovieFiles = createSelector(
|
||||
(state) => state.movieFiles,
|
||||
(movieFiles) => {
|
||||
const {
|
||||
items,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
} = movieFiles;
|
||||
|
||||
const hasMovieFiles = !!items.length;
|
||||
|
||||
return {
|
||||
isMovieFilesFetching: isFetching,
|
||||
isMovieFilesPopulated: isPopulated,
|
||||
movieFilesError: error,
|
||||
hasMovieFiles
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const selectMovieCredits = createSelector(
|
||||
(state) => state.movieCredits,
|
||||
(movieCredits) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
} = movieCredits;
|
||||
|
||||
return {
|
||||
isMovieCreditsFetching: isFetching,
|
||||
isMovieCreditsPopulated: isPopulated,
|
||||
movieCreditsError: error
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const selectExtraFiles = createSelector(
|
||||
(state) => state.extraFiles,
|
||||
(extraFiles) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
} = extraFiles;
|
||||
|
||||
return {
|
||||
isExtraFilesFetching: isFetching,
|
||||
isExtraFilesPopulated: isPopulated,
|
||||
extraFilesError: error
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { titleSlug }) => titleSlug,
|
||||
selectMovieFiles,
|
||||
selectMovieCredits,
|
||||
selectExtraFiles,
|
||||
createAllMoviesSelector(),
|
||||
createCommandsSelector(),
|
||||
createDimensionsSelector(),
|
||||
(state) => state.queue.details.items,
|
||||
(state) => state.app.isSidebarVisible,
|
||||
(state) => state.settings.ui.item.movieRuntimeFormat,
|
||||
(titleSlug, movieFiles, movieCredits, extraFiles, allMovies, commands, dimensions, queueItems, isSidebarVisible, movieRuntimeFormat) => {
|
||||
const sortedMovies = _.orderBy(allMovies, 'sortTitle');
|
||||
const movieIndex = _.findIndex(sortedMovies, { titleSlug });
|
||||
const movie = sortedMovies[movieIndex];
|
||||
|
||||
if (!movie) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const {
|
||||
isMovieFilesFetching,
|
||||
isMovieFilesPopulated,
|
||||
movieFilesError,
|
||||
hasMovieFiles
|
||||
} = movieFiles;
|
||||
|
||||
const {
|
||||
isMovieCreditsFetching,
|
||||
isMovieCreditsPopulated,
|
||||
movieCreditsError
|
||||
} = movieCredits;
|
||||
|
||||
const {
|
||||
isExtraFilesFetching,
|
||||
isExtraFilesPopulated,
|
||||
extraFilesError
|
||||
} = extraFiles;
|
||||
|
||||
const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies);
|
||||
const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies);
|
||||
const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieIds: [movie.id] }));
|
||||
const movieRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_MOVIE });
|
||||
const allMoviesRefreshing = (
|
||||
isCommandExecuting(movieRefreshingCommand) &&
|
||||
!movieRefreshingCommand.body.movieId
|
||||
);
|
||||
const isRefreshing = isMovieRefreshing || allMoviesRefreshing;
|
||||
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.MOVIE_SEARCH, movieIds: [movie.id] }));
|
||||
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, movieId: movie.id }));
|
||||
const isRenamingMovieCommand = findCommand(commands, { name: commandNames.RENAME_MOVIE });
|
||||
const isRenamingMovie = (
|
||||
isCommandExecuting(isRenamingMovieCommand) &&
|
||||
isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1
|
||||
);
|
||||
|
||||
const isFetching = isMovieFilesFetching || isMovieCreditsFetching || isExtraFilesFetching;
|
||||
const isPopulated = isMovieFilesPopulated && isMovieCreditsPopulated && isExtraFilesPopulated;
|
||||
const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => {
|
||||
acc.push(alternateTitle.title);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const queueItem = queueItems.find((item) => item.movieId === movie.id);
|
||||
|
||||
return {
|
||||
...movie,
|
||||
alternateTitles,
|
||||
isMovieRefreshing,
|
||||
allMoviesRefreshing,
|
||||
isRefreshing,
|
||||
isSearching,
|
||||
isRenamingFiles,
|
||||
isRenamingMovie,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
movieFilesError,
|
||||
movieCreditsError,
|
||||
extraFilesError,
|
||||
hasMovieFiles,
|
||||
previousMovie,
|
||||
nextMovie,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
isSidebarVisible,
|
||||
queueItem,
|
||||
movieRuntimeFormat
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchFetchMovieFiles({ movieId }) {
|
||||
dispatch(fetchMovieFiles({ movieId }));
|
||||
},
|
||||
dispatchClearMovieFiles() {
|
||||
dispatch(clearMovieFiles());
|
||||
},
|
||||
dispatchFetchMovieCredits({ movieId }) {
|
||||
dispatch(fetchMovieCredits({ movieId }));
|
||||
},
|
||||
dispatchClearMovieCredits() {
|
||||
dispatch(clearMovieCredits());
|
||||
},
|
||||
dispatchFetchExtraFiles({ movieId }) {
|
||||
dispatch(fetchExtraFiles({ movieId }));
|
||||
},
|
||||
dispatchClearExtraFiles() {
|
||||
dispatch(clearExtraFiles());
|
||||
},
|
||||
dispatchFetchQueueDetails({ movieId }) {
|
||||
dispatch(fetchQueueDetails({ movieId }));
|
||||
},
|
||||
dispatchClearQueueDetails() {
|
||||
dispatch(clearQueueDetails());
|
||||
},
|
||||
dispatchFetchImportListSchema() {
|
||||
dispatch(fetchImportListSchema());
|
||||
},
|
||||
dispatchToggleMovieMonitored(payload) {
|
||||
dispatch(toggleMovieMonitored(payload));
|
||||
},
|
||||
dispatchExecuteCommand(payload) {
|
||||
dispatch(executeCommand(payload));
|
||||
},
|
||||
onGoToMovie(titleSlug) {
|
||||
dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class MovieDetailsConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
registerPagePopulator(this.populate, ['movieUpdated']);
|
||||
this.populate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
id,
|
||||
isMovieRefreshing,
|
||||
allMoviesRefreshing,
|
||||
isRenamingFiles,
|
||||
isRenamingMovie
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
(prevProps.isMovieRefreshing && !isMovieRefreshing) ||
|
||||
(prevProps.allMoviesRefreshing && !allMoviesRefreshing) ||
|
||||
(prevProps.isRenamingFiles && !isRenamingFiles) ||
|
||||
(prevProps.isRenamingMovie && !isRenamingMovie)
|
||||
) {
|
||||
this.populate();
|
||||
}
|
||||
|
||||
// If the id has changed we need to clear the episodes/episode
|
||||
// files and fetch from the server.
|
||||
|
||||
if (prevProps.id !== id) {
|
||||
this.unpopulate();
|
||||
this.populate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.populate);
|
||||
this.unpopulate();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
populate = () => {
|
||||
const movieId = this.props.id;
|
||||
|
||||
this.props.dispatchFetchMovieFiles({ movieId });
|
||||
this.props.dispatchFetchExtraFiles({ movieId });
|
||||
this.props.dispatchFetchMovieCredits({ movieId });
|
||||
this.props.dispatchFetchQueueDetails({ movieId });
|
||||
this.props.dispatchFetchImportListSchema();
|
||||
};
|
||||
|
||||
unpopulate = () => {
|
||||
this.props.dispatchClearMovieFiles();
|
||||
this.props.dispatchClearExtraFiles();
|
||||
this.props.dispatchClearMovieCredits();
|
||||
this.props.dispatchClearQueueDetails();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMonitorTogglePress = (monitored) => {
|
||||
this.props.dispatchToggleMovieMonitored({
|
||||
movieId: this.props.id,
|
||||
monitored
|
||||
});
|
||||
};
|
||||
|
||||
onRefreshPress = () => {
|
||||
this.props.dispatchExecuteCommand({
|
||||
name: commandNames.REFRESH_MOVIE,
|
||||
movieIds: [this.props.id]
|
||||
});
|
||||
};
|
||||
|
||||
onSearchPress = () => {
|
||||
this.props.dispatchExecuteCommand({
|
||||
name: commandNames.MOVIE_SEARCH,
|
||||
movieIds: [this.props.id]
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MovieDetails
|
||||
{...this.props}
|
||||
onMonitorTogglePress={this.onMonitorTogglePress}
|
||||
onRefreshPress={this.onRefreshPress}
|
||||
onSearchPress={this.onSearchPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MovieDetailsConnector.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
isMovieRefreshing: PropTypes.bool.isRequired,
|
||||
allMoviesRefreshing: PropTypes.bool.isRequired,
|
||||
isRefreshing: PropTypes.bool.isRequired,
|
||||
isRenamingFiles: PropTypes.bool.isRequired,
|
||||
isRenamingMovie: PropTypes.bool.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
dispatchFetchMovieFiles: PropTypes.func.isRequired,
|
||||
dispatchClearMovieFiles: PropTypes.func.isRequired,
|
||||
dispatchFetchExtraFiles: PropTypes.func.isRequired,
|
||||
dispatchClearExtraFiles: PropTypes.func.isRequired,
|
||||
dispatchFetchMovieCredits: PropTypes.func.isRequired,
|
||||
dispatchClearMovieCredits: PropTypes.func.isRequired,
|
||||
dispatchToggleMovieMonitored: PropTypes.func.isRequired,
|
||||
dispatchFetchQueueDetails: PropTypes.func.isRequired,
|
||||
dispatchClearQueueDetails: PropTypes.func.isRequired,
|
||||
dispatchFetchImportListSchema: PropTypes.func.isRequired,
|
||||
dispatchExecuteCommand: PropTypes.func.isRequired,
|
||||
onGoToMovie: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieDetailsConnector);
|
||||
38
frontend/src/Movie/Details/MovieDetailsPage.tsx
Normal file
38
frontend/src/Movie/Details/MovieDetailsPage.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useHistory, useParams } from 'react-router';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MovieDetails from './MovieDetails';
|
||||
|
||||
function MovieDetailsPage() {
|
||||
const allMovies = useSelector(createAllMoviesSelector());
|
||||
const { titleSlug } = useParams<{ titleSlug: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const movieIndex = allMovies.findIndex(
|
||||
(movie) => movie.titleSlug === titleSlug
|
||||
);
|
||||
|
||||
const previousIndex = usePrevious(movieIndex);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
movieIndex === -1 &&
|
||||
previousIndex !== -1 &&
|
||||
previousIndex !== undefined
|
||||
) {
|
||||
history.push(`${window.Radarr.urlBase}/`);
|
||||
}
|
||||
}, [movieIndex, previousIndex, history]);
|
||||
|
||||
if (movieIndex === -1) {
|
||||
return <NotFound message={translate('MovieCannotBeFound')} />;
|
||||
}
|
||||
|
||||
return <MovieDetails movieId={allMovies[movieIndex].id} />;
|
||||
}
|
||||
|
||||
export default MovieDetailsPage;
|
||||
@@ -1,125 +0,0 @@
|
||||
import { push } from 'connected-react-router';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MovieDetailsConnector from './MovieDetailsConnector';
|
||||
import styles from './MovieDetails.css';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { match }) => match,
|
||||
(state) => state.movies,
|
||||
(match, movies) => {
|
||||
const titleSlug = match.params.titleSlug;
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = movies;
|
||||
|
||||
const movieIndex = _.findIndex(items, { titleSlug });
|
||||
|
||||
if (movieIndex > -1) {
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
titleSlug
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
push,
|
||||
fetchRootFolders
|
||||
};
|
||||
|
||||
class MovieDetailsPageConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchRootFolders();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!this.props.titleSlug) {
|
||||
this.props.push(`${window.Radarr.urlBase}/`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
titleSlug,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error
|
||||
} = this.props;
|
||||
|
||||
if (isFetching && !isPopulated) {
|
||||
return (
|
||||
<PageContent title={translate('Loading')}>
|
||||
<PageContentBody>
|
||||
<LoadingIndicator />
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFetching && !!error) {
|
||||
return (
|
||||
<div className={styles.errorMessage}>
|
||||
{getErrorMessage(error, translate('FailedToLoadMovieFromAPI'))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!titleSlug) {
|
||||
return (
|
||||
<NotFound
|
||||
message={translate('SorryThatMovieCannotBeFound')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MovieDetailsConnector
|
||||
titleSlug={titleSlug}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MovieDetailsPageConnector.propTypes = {
|
||||
titleSlug: PropTypes.string,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
|
||||
push: PropTypes.func.isRequired,
|
||||
fetchRootFolders: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsPageConnector);
|
||||
@@ -1,13 +1,23 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { MovieStatus } from 'Movie/Movie';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import Queue from 'typings/Queue';
|
||||
import getQueueStatusText from 'Utilities/Movie/getQueueStatusText';
|
||||
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MovieStatusLabel.css';
|
||||
|
||||
function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = false) {
|
||||
function getMovieStatus(
|
||||
status: MovieStatus,
|
||||
isMonitored: boolean,
|
||||
isAvailable: boolean,
|
||||
hasFiles: boolean,
|
||||
queueItem: Queue | null = null
|
||||
) {
|
||||
if (queueItem) {
|
||||
const queueStatus = queueItem.status;
|
||||
const queueState = queueItem.trackedDownloadStatus;
|
||||
@@ -18,11 +28,11 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFile && !isMonitored) {
|
||||
if (hasFiles && !isMonitored) {
|
||||
return 'availNotMonitored';
|
||||
}
|
||||
|
||||
if (hasFile) {
|
||||
if (hasFiles) {
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
@@ -30,34 +40,52 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f
|
||||
return 'deleted';
|
||||
}
|
||||
|
||||
if (isAvailable && !isMonitored && !hasFile) {
|
||||
if (isAvailable && !isMonitored && !hasFiles) {
|
||||
return 'missingUnmonitored';
|
||||
}
|
||||
|
||||
if (isAvailable && !hasFile) {
|
||||
if (isAvailable && !hasFiles) {
|
||||
return 'missingMonitored';
|
||||
}
|
||||
|
||||
return 'continuing';
|
||||
}
|
||||
|
||||
function MovieStatusLabel(props) {
|
||||
const {
|
||||
interface MovieStatusLabelProps {
|
||||
movieId: number;
|
||||
monitored: boolean;
|
||||
isAvailable: boolean;
|
||||
hasMovieFiles: boolean;
|
||||
status: MovieStatus;
|
||||
useLabel?: boolean;
|
||||
}
|
||||
|
||||
function MovieStatusLabel({
|
||||
movieId,
|
||||
monitored,
|
||||
isAvailable,
|
||||
hasMovieFiles,
|
||||
status,
|
||||
useLabel = false,
|
||||
}: MovieStatusLabelProps) {
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(movieId));
|
||||
|
||||
let movieStatus = getMovieStatus(
|
||||
status,
|
||||
hasMovieFiles,
|
||||
monitored,
|
||||
isAvailable,
|
||||
queueItem,
|
||||
useLabel,
|
||||
colorImpairedMode
|
||||
} = props;
|
||||
hasMovieFiles,
|
||||
queueItem
|
||||
);
|
||||
|
||||
let movieStatus = getMovieStatus(status, hasMovieFiles, monitored, isAvailable, queueItem);
|
||||
let statusClass = movieStatus;
|
||||
|
||||
if (movieStatus === 'availNotMonitored' || movieStatus === 'ended') {
|
||||
movieStatus = 'downloaded';
|
||||
} else if (movieStatus === 'missingMonitored' || movieStatus === 'missingUnmonitored') {
|
||||
} else if (
|
||||
movieStatus === 'missingMonitored' ||
|
||||
movieStatus === 'missingUnmonitored'
|
||||
) {
|
||||
movieStatus = 'missing';
|
||||
} else if (movieStatus === 'continuing') {
|
||||
movieStatus = 'notAvailable';
|
||||
@@ -68,7 +96,7 @@ function MovieStatusLabel(props) {
|
||||
}
|
||||
|
||||
if (useLabel) {
|
||||
let kind = kinds.SUCCESS;
|
||||
let kind: Kind = kinds.SUCCESS;
|
||||
|
||||
switch (statusClass) {
|
||||
case 'queue':
|
||||
@@ -93,11 +121,7 @@ function MovieStatusLabel(props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
kind={kind}
|
||||
size={sizes.LARGE}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
>
|
||||
<Label kind={kind} size={sizes.LARGE}>
|
||||
{translate(firstCharToUpper(movieStatus))}
|
||||
</Label>
|
||||
);
|
||||
@@ -105,6 +129,8 @@ function MovieStatusLabel(props) {
|
||||
|
||||
return (
|
||||
<span
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
className={styles[statusClass]}
|
||||
>
|
||||
{translate(firstCharToUpper(movieStatus))}
|
||||
@@ -112,19 +138,4 @@ function MovieStatusLabel(props) {
|
||||
);
|
||||
}
|
||||
|
||||
MovieStatusLabel.propTypes = {
|
||||
status: PropTypes.string.isRequired,
|
||||
hasMovieFiles: PropTypes.bool.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
isAvailable: PropTypes.bool.isRequired,
|
||||
queueItem: PropTypes.object,
|
||||
useLabel: PropTypes.bool,
|
||||
colorImpairedMode: PropTypes.bool
|
||||
};
|
||||
|
||||
MovieStatusLabel.defaultProps = {
|
||||
useLabel: false,
|
||||
colorImpairedMode: false
|
||||
};
|
||||
|
||||
export default MovieStatusLabel;
|
||||
@@ -1,30 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
|
||||
function MovieTags({ tags }) {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
tags.map((tag) => {
|
||||
return (
|
||||
<Label
|
||||
key={tag}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
{tag}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MovieTags.propTypes = {
|
||||
tags: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
};
|
||||
|
||||
export default MovieTags;
|
||||
35
frontend/src/Movie/Details/MovieTags.tsx
Normal file
35
frontend/src/Movie/Details/MovieTags.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import useMovie from 'Movie/useMovie';
|
||||
import useTags from 'Tags/useTags';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
|
||||
interface MovieTagsProps {
|
||||
movieId: number;
|
||||
}
|
||||
|
||||
function MovieTags({ movieId }: MovieTagsProps) {
|
||||
const movie = useMovie(movieId)!;
|
||||
const tagList = useTags();
|
||||
|
||||
const tags = movie.tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
.sort(sortByProp('label'))
|
||||
.map((tag) => tag.label);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{tags.map((tag) => {
|
||||
return (
|
||||
<Label key={tag} kind={kinds.INFO} size={sizes.LARGE}>
|
||||
{tag}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MovieTags;
|
||||
@@ -1,26 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createMovieSelector from 'Store/Selectors/createMovieSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import MovieTags from './MovieTags';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createMovieSelector(),
|
||||
createTagsSelector(),
|
||||
(movie, tagList) => {
|
||||
const tags = movie.tags
|
||||
.map((tagId) => tagList.find((tag) => tag.id === tagId))
|
||||
.filter((tag) => !!tag)
|
||||
.sort(sortByProp('label'))
|
||||
.map((tag) => tag.label);
|
||||
|
||||
return {
|
||||
tags
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(MovieTags);
|
||||
@@ -1,53 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import SearchMenuItem from 'Components/Menu/SearchMenuItem';
|
||||
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
class MovieIndexSearchMenu extends Component {
|
||||
|
||||
render() {
|
||||
const {
|
||||
isDisabled,
|
||||
onSearchPress
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
isDisabled={isDisabled}
|
||||
alignMenu={align.RIGHT}
|
||||
>
|
||||
<ToolbarMenuButton
|
||||
iconName={icons.SEARCH}
|
||||
text="Search"
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<MenuContent>
|
||||
<SearchMenuItem
|
||||
name="missingMoviesSearch"
|
||||
onPress={onSearchPress}
|
||||
>
|
||||
{translate('SearchMissing')}
|
||||
</SearchMenuItem>
|
||||
|
||||
<SearchMenuItem
|
||||
name="cutoffUnmetMoviesSearch"
|
||||
onPress={onSearchPress}
|
||||
>
|
||||
{translate('SearchCutoffUnmet')}
|
||||
</SearchMenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MovieIndexSearchMenu.propTypes = {
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default MovieIndexSearchMenu;
|
||||
@@ -6,6 +6,7 @@ import React, {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useHistory } from 'react-router';
|
||||
import { SelectProvider } from 'App/SelectContext';
|
||||
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
|
||||
import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState';
|
||||
@@ -26,6 +27,7 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import NoMovie from 'Movie/NoMovie';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { fetchMovies } from 'Store/Actions/movieActions';
|
||||
import {
|
||||
setMovieFilter,
|
||||
setMovieSort,
|
||||
@@ -75,6 +77,8 @@ interface MovieIndexProps {
|
||||
}
|
||||
|
||||
const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
|
||||
const history = useHistory();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
@@ -105,6 +109,12 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
|
||||
);
|
||||
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (history.action === 'PUSH') {
|
||||
dispatch(fetchMovies());
|
||||
}
|
||||
}, [history, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchQueueDetails({ all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
@@ -14,13 +14,15 @@ function createMovieIndexItemSelector(movieId: number) {
|
||||
(movie: Movie, qualityProfile, executingCommands: Command[]) => {
|
||||
const isRefreshingMovie = executingCommands.some((command) => {
|
||||
return (
|
||||
command.name === REFRESH_MOVIE && command.body.movieId === movieId
|
||||
command.name === REFRESH_MOVIE &&
|
||||
command.body.movieIds?.includes(movieId)
|
||||
);
|
||||
});
|
||||
|
||||
const isSearchingMovie = executingCommands.some((command) => {
|
||||
return (
|
||||
command.name === MOVIE_SEARCH && command.body.movieId === movieId
|
||||
command.name === MOVIE_SEARCH &&
|
||||
command.body.movieIds?.includes(movieId)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import { toggleCollectionMonitored } from 'Store/Actions/movieCollectionActions';
|
||||
import { createCollectionSelectorForHook } from 'Store/Selectors/createCollectionSelector';
|
||||
import MovieCollection from 'typings/MovieCollection';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MovieCollectionLabel.css';
|
||||
|
||||
interface MovieCollectionLabelProps {
|
||||
@@ -16,7 +17,8 @@ function MovieCollectionLabel({ tmdbId }: MovieCollectionLabelProps) {
|
||||
monitored,
|
||||
title,
|
||||
isSaving = false,
|
||||
} = useSelector(createCollectionSelectorForHook(tmdbId)) as MovieCollection;
|
||||
} = useSelector(createCollectionSelectorForHook(tmdbId)) ||
|
||||
({} as MovieCollection);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -29,6 +31,10 @@ function MovieCollectionLabel({ tmdbId }: MovieCollectionLabelProps) {
|
||||
[id, dispatch]
|
||||
);
|
||||
|
||||
if (!id) {
|
||||
return translate('Unknown');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<MonitorToggleButton
|
||||
|
||||
@@ -9,5 +9,5 @@ export default {
|
||||
MOVIES,
|
||||
INTERACTIVE_IMPORT,
|
||||
WANTED_CUTOFF_UNMET,
|
||||
WANTED_MISSING
|
||||
};
|
||||
WANTED_MISSING,
|
||||
} as const;
|
||||
@@ -86,18 +86,18 @@ class FileEditModalContentConnector extends Component {
|
||||
real: real ? 1 : 0
|
||||
};
|
||||
|
||||
const movieFileIds = [this.props.movieFileId];
|
||||
|
||||
this.props.dispatchUpdateMovieFiles({
|
||||
movieFileIds,
|
||||
languages,
|
||||
indexerFlags,
|
||||
edition,
|
||||
releaseGroup,
|
||||
quality: {
|
||||
quality,
|
||||
revision
|
||||
}
|
||||
files: [{
|
||||
id: this.props.movieFileId,
|
||||
languages,
|
||||
indexerFlags,
|
||||
edition,
|
||||
releaseGroup,
|
||||
quality: {
|
||||
quality,
|
||||
revision
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
this.props.onModalClose(true);
|
||||
|
||||
@@ -1 +1,11 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export type ExtraFileType = 'subtitle' | 'metadata' | 'other';
|
||||
|
||||
export interface ExtraFile extends ModelBase {
|
||||
movieId: number;
|
||||
movieFileId?: number;
|
||||
relativePath: string;
|
||||
extension: string;
|
||||
type: ExtraFileType;
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector';
|
||||
|
||||
class SelectLanguageModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<SelectLanguageModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectLanguageModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SelectLanguageModal;
|
||||
@@ -1,97 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import SelectLanguageModalContent from 'InteractiveImport/Language/SelectLanguageModalContent';
|
||||
import { updateMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { fetchLanguages } from 'Store/Actions/settingsActions';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.languages,
|
||||
(languages) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = languages;
|
||||
|
||||
const filterItems = ['Any', 'Original'];
|
||||
const filteredLanguages = items.filter((lang) => !filterItems.includes(lang.name));
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items: filteredLanguages
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchLanguages: fetchLanguages,
|
||||
dispatchupdateMovieFiles: updateMovieFiles
|
||||
};
|
||||
|
||||
class SelectLanguageModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount = () => {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.dispatchFetchLanguages();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onLanguageSelect = ({ languageIds }) => {
|
||||
const languages = [];
|
||||
|
||||
languageIds.forEach((languageId) => {
|
||||
const language = _.find(this.props.items,
|
||||
(item) => item.id === parseInt(languageId));
|
||||
|
||||
if (language !== undefined) {
|
||||
languages.push(language);
|
||||
}
|
||||
});
|
||||
|
||||
this.props.dispatchupdateMovieFiles({
|
||||
movieFileIds: this.props.ids,
|
||||
languages
|
||||
});
|
||||
|
||||
this.props.onModalClose(true);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SelectLanguageModalContent
|
||||
{...this.props}
|
||||
onLanguageSelect={this.onLanguageSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectLanguageModalContentConnector.propTypes = {
|
||||
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
dispatchFetchLanguages: PropTypes.func.isRequired,
|
||||
dispatchupdateMovieFiles: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector);
|
||||
@@ -15,6 +15,7 @@ export interface MovieFile extends ModelBase {
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
indexerFlags: number;
|
||||
mediaInfo: MediaInfo;
|
||||
qualityCutoffNotMet: boolean;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import SelectQualityModalContentConnector from './SelectQualityModalContentConnector';
|
||||
|
||||
class SelectQualityModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<SelectQualityModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectQualityModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SelectQualityModal;
|
||||
@@ -1,170 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
class SelectQualityModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const {
|
||||
qualityId,
|
||||
proper,
|
||||
real
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
qualityId,
|
||||
proper,
|
||||
real
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onQualityChange = ({ value }) => {
|
||||
this.setState({ qualityId: parseInt(value) });
|
||||
};
|
||||
|
||||
onProperChange = ({ value }) => {
|
||||
this.setState({ proper: value });
|
||||
};
|
||||
|
||||
onRealChange = ({ value }) => {
|
||||
this.setState({ real: value });
|
||||
};
|
||||
|
||||
onQualitySelect = () => {
|
||||
this.props.onQualitySelect(this.state);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
qualityId,
|
||||
proper,
|
||||
real
|
||||
} = this.state;
|
||||
|
||||
const qualityOptions = items.map(({ id, name }) => {
|
||||
return {
|
||||
key: id,
|
||||
value: name
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('ManualImportSelectQuality')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('QualitiesLoadError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error &&
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Quality')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="quality"
|
||||
value={qualityId}
|
||||
values={qualityOptions}
|
||||
onChange={this.onQualityChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Proper')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="proper"
|
||||
value={proper}
|
||||
onChange={this.onProperChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Real')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="real"
|
||||
value={real}
|
||||
onChange={this.onRealChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.SUCCESS}
|
||||
onPress={this.onQualitySelect}
|
||||
>
|
||||
{translate('SelectQuality')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectQualityModalContent.propTypes = {
|
||||
qualityId: PropTypes.number.isRequired,
|
||||
proper: PropTypes.bool.isRequired,
|
||||
real: PropTypes.bool.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onQualitySelect: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SelectQualityModalContent;
|
||||
@@ -1,97 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { updateMovieFiles } from 'Store/Actions/movieFileActions';
|
||||
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
|
||||
import getQualities from 'Utilities/Quality/getQualities';
|
||||
import SelectQualityModalContent from './SelectQualityModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.qualityProfiles,
|
||||
(qualityProfiles) => {
|
||||
const {
|
||||
isSchemaFetching: isFetching,
|
||||
isSchemaPopulated: isPopulated,
|
||||
schemaError: error,
|
||||
schema
|
||||
} = qualityProfiles;
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items: getQualities(schema.items)
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema,
|
||||
dispatchupdateMovieFiles: updateMovieFiles
|
||||
};
|
||||
|
||||
class SelectQualityModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount = () => {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.dispatchFetchQualityProfileSchema();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onQualitySelect = ({ qualityId, proper, real }) => {
|
||||
const quality = _.find(this.props.items,
|
||||
(item) => item.id === qualityId);
|
||||
|
||||
const revision = {
|
||||
version: proper ? 2 : 1,
|
||||
real: real ? 1 : 0
|
||||
};
|
||||
|
||||
const movieFileIds = this.props.ids;
|
||||
|
||||
this.props.dispatchupdateMovieFiles({
|
||||
movieFileIds,
|
||||
quality: {
|
||||
quality,
|
||||
revision
|
||||
}
|
||||
});
|
||||
|
||||
this.props.onModalClose(true);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SelectQualityModalContent
|
||||
{...this.props}
|
||||
onQualitySelect={this.onQualitySelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SelectQualityModalContentConnector.propTypes = {
|
||||
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
|
||||
dispatchupdateMovieFiles: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);
|
||||
@@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import {
|
||||
bulkDeleteCustomFormats,
|
||||
bulkEditCustomFormats,
|
||||
@@ -34,7 +34,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
|
||||
typeof ManageCustomFormatsModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
@@ -56,8 +56,6 @@ const COLUMNS = [
|
||||
|
||||
interface ManageCustomFormatsModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageCustomFormatsModalContent(
|
||||
|
||||
@@ -48,7 +48,6 @@ class EditDownloadClientModalContent extends Component {
|
||||
implementationName,
|
||||
name,
|
||||
enable,
|
||||
protocol,
|
||||
priority,
|
||||
removeCompletedDownloads,
|
||||
removeFailedDownloads,
|
||||
@@ -171,20 +170,17 @@ class EditDownloadClientModalContent extends Component {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
protocol.value !== 'torrent' &&
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RemoveFailed')}</FormLabel>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('RemoveFailed')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="removeFailedDownloads"
|
||||
helpText={translate('RemoveFailedDownloadsHelpText')}
|
||||
{...removeFailedDownloads}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="removeFailedDownloads"
|
||||
helpText={translate('RemoveFailedDownloadsHelpText')}
|
||||
{...removeFailedDownloads}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
</Form>
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import {
|
||||
bulkDeleteDownloadClients,
|
||||
bulkEditDownloadClients,
|
||||
@@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
|
||||
typeof ManageDownloadClientsModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
@@ -82,8 +82,6 @@ const COLUMNS = [
|
||||
|
||||
interface ManageDownloadClientsModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageDownloadClientsModalContent(
|
||||
|
||||
@@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import {
|
||||
bulkDeleteIndexers,
|
||||
bulkEditIndexers,
|
||||
@@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
|
||||
typeof ManageIndexersModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
@@ -82,8 +82,6 @@ const COLUMNS = [
|
||||
|
||||
interface ManageIndexersModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface QualityProfileNameProps {
|
||||
qualityProfileId: number;
|
||||
}
|
||||
|
||||
function QualityProfileName({ qualityProfileId }: QualityProfileNameProps) {
|
||||
const qualityProfile = useSelector(
|
||||
createQualityProfileSelectorForHook(qualityProfileId)
|
||||
);
|
||||
|
||||
return <span>{qualityProfile?.name ?? translate('Unknown')}</span>;
|
||||
}
|
||||
|
||||
export default QualityProfileName;
|
||||
@@ -1,31 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createQualityProfileSelector(),
|
||||
(qualityProfile) => {
|
||||
return {
|
||||
name: qualityProfile.name
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function QualityProfileNameConnector({ name, ...otherProps }) {
|
||||
return (
|
||||
<span>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
QualityProfileNameConnector.propTypes = {
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(QualityProfileNameConnector);
|
||||
@@ -29,6 +29,7 @@ export const defaultState = {
|
||||
isReprocessing: false,
|
||||
error: null,
|
||||
items: [],
|
||||
originalItems: [],
|
||||
sortKey: 'relativePath',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
favoriteFolders: [],
|
||||
@@ -119,7 +120,8 @@ export const actionHandlers = handleThunks({
|
||||
section,
|
||||
isFetching: false,
|
||||
isPopulated: true,
|
||||
error: null
|
||||
error: null,
|
||||
originalItems: data
|
||||
})
|
||||
]));
|
||||
});
|
||||
@@ -228,13 +230,13 @@ export const reducers = createHandleActions({
|
||||
},
|
||||
|
||||
[UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => {
|
||||
const ids = payload.ids;
|
||||
const { ids, ...otherPayload } = payload;
|
||||
const newState = Object.assign({}, state);
|
||||
const items = [...newState.items];
|
||||
|
||||
ids.forEach((id) => {
|
||||
const index = items.findIndex((item) => item.id === id);
|
||||
const item = Object.assign({}, items[index], payload);
|
||||
const item = Object.assign({}, items[index], otherPayload);
|
||||
|
||||
items.splice(index, 1, item);
|
||||
});
|
||||
|
||||
@@ -248,44 +248,14 @@ export const actionHandlers = handleThunks({
|
||||
},
|
||||
|
||||
[UPDATE_MOVIE_FILES]: function(getState, payload, dispatch) {
|
||||
|
||||
const {
|
||||
movieFileIds,
|
||||
languages,
|
||||
indexerFlags,
|
||||
quality,
|
||||
edition,
|
||||
releaseGroup
|
||||
} = payload;
|
||||
const { files } = payload;
|
||||
|
||||
dispatch(set({ section, isSaving: true }));
|
||||
|
||||
const requestData = {
|
||||
movieFileIds
|
||||
};
|
||||
|
||||
if (languages) {
|
||||
requestData.languages = languages;
|
||||
}
|
||||
|
||||
if (indexerFlags !== undefined) {
|
||||
requestData.indexerFlags = indexerFlags;
|
||||
}
|
||||
|
||||
if (quality) {
|
||||
requestData.quality = quality;
|
||||
}
|
||||
|
||||
if (releaseGroup !== undefined) {
|
||||
requestData.releaseGroup = releaseGroup;
|
||||
}
|
||||
|
||||
if (edition !== undefined) {
|
||||
requestData.edition = edition;
|
||||
}
|
||||
const requestData = files;
|
||||
|
||||
const promise = createAjaxRequest({
|
||||
url: '/movieFile/editor',
|
||||
url: '/movieFile/bulk',
|
||||
method: 'PUT',
|
||||
dataType: 'json',
|
||||
data: JSON.stringify(requestData)
|
||||
@@ -293,36 +263,25 @@ export const actionHandlers = handleThunks({
|
||||
|
||||
promise.done((data) => {
|
||||
dispatch(batchActions([
|
||||
...movieFileIds.map((id) => {
|
||||
const movieFile = data.find((file) => file.id === id);
|
||||
...files.map((file) => {
|
||||
const id = file.id;
|
||||
const props = {};
|
||||
const movieFile = data.find((f) => f.id === id);
|
||||
|
||||
const props = {
|
||||
customFormats: movieFile.customFormats,
|
||||
customFormatScore: movieFile.customFormatScore,
|
||||
qualityCutoffNotMet: movieFile.qualityCutoffNotMet
|
||||
};
|
||||
props.qualityCutoffNotMet = movieFile.qualityCutoffNotMet;
|
||||
props.customFormats = movieFile.customFormats;
|
||||
props.customFormatScore = movieFile.customFormatScore;
|
||||
props.languages = movieFile.languages;
|
||||
props.quality = movieFile.quality;
|
||||
props.edition = movieFile.edition;
|
||||
props.releaseGroup = movieFile.releaseGroup;
|
||||
props.indexerFlags = movieFile.indexerFlags;
|
||||
|
||||
if (languages) {
|
||||
props.languages = languages;
|
||||
}
|
||||
|
||||
if (indexerFlags !== undefined) {
|
||||
props.indexerFlags = indexerFlags;
|
||||
}
|
||||
|
||||
if (quality) {
|
||||
props.quality = quality;
|
||||
}
|
||||
|
||||
if (edition !== undefined) {
|
||||
props.edition = edition;
|
||||
}
|
||||
|
||||
if (releaseGroup !== undefined) {
|
||||
props.releaseGroup = releaseGroup;
|
||||
}
|
||||
|
||||
return updateItem({ section, id, ...props });
|
||||
return updateItem({
|
||||
section,
|
||||
id,
|
||||
...props
|
||||
});
|
||||
}),
|
||||
|
||||
set({
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function createHealthCheckSelector() {
|
||||
return createSelector(
|
||||
(state) => state.system.health,
|
||||
(state) => state.app,
|
||||
(health, app) => {
|
||||
const items = [...health.items];
|
||||
|
||||
if (!app.isConnected) {
|
||||
items.push({
|
||||
source: 'UI',
|
||||
type: 'warning',
|
||||
message: translate('CouldNotConnectSignalR'),
|
||||
wikiUrl: 'https://wiki.servarr.com/radarr/system#could-not-connect-to-signalr'
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createHealthCheckSelector;
|
||||
8
frontend/src/Tags/useTags.ts
Normal file
8
frontend/src/Tags/useTags.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
|
||||
const useTags = () => {
|
||||
return useSelector(createTagsSelector());
|
||||
};
|
||||
|
||||
export default useTags;
|
||||
@@ -1,11 +0,0 @@
|
||||
function getErrorMessage(xhr, fallbackErrorMessage) {
|
||||
if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
||||
const message = xhr.responseJSON.message;
|
||||
|
||||
return message || fallbackErrorMessage;
|
||||
}
|
||||
|
||||
export default getErrorMessage;
|
||||
15
frontend/src/Utilities/Object/getErrorMessage.ts
Normal file
15
frontend/src/Utilities/Object/getErrorMessage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Error } from 'App/State/AppSectionState';
|
||||
|
||||
function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) {
|
||||
if (!xhr || !xhr.responseJSON) {
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
||||
if ('message' in xhr.responseJSON && xhr.responseJSON.message) {
|
||||
return xhr.responseJSON.message;
|
||||
}
|
||||
|
||||
return fallbackErrorMessage;
|
||||
}
|
||||
|
||||
export default getErrorMessage;
|
||||
@@ -35,6 +35,7 @@ export interface GrabbedHistoryData {
|
||||
|
||||
export interface DownloadFailedHistory {
|
||||
message: string;
|
||||
indexer?: string;
|
||||
}
|
||||
|
||||
export interface DownloadFolderImportedHistory {
|
||||
@@ -43,11 +44,13 @@ export interface DownloadFolderImportedHistory {
|
||||
downloadClientName: string;
|
||||
droppedPath: string;
|
||||
importedPath: string;
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface MovieFileDeletedHistory {
|
||||
customFormatScore?: string;
|
||||
reason: 'Manual' | 'MissingFromDisk' | 'Upgrade';
|
||||
size: string;
|
||||
}
|
||||
|
||||
export interface MovieFileRenamedHistory {
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-react": "7.37.1",
|
||||
"eslint-plugin-react-hooks": "4.6.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"file-loader": "6.2.0",
|
||||
"filemanager-webpack-plugin": "8.0.0",
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnsureThat;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
@@ -316,9 +317,26 @@ namespace NzbDrone.Common.Disk
|
||||
{
|
||||
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
|
||||
|
||||
var files = GetFiles(path, recursive);
|
||||
var files = GetFiles(path, recursive).ToList();
|
||||
|
||||
files.ToList().ForEach(RemoveReadOnly);
|
||||
files.ForEach(RemoveReadOnly);
|
||||
|
||||
var attempts = 0;
|
||||
|
||||
while (attempts < 3 && files.Any())
|
||||
{
|
||||
EmptyFolder(path);
|
||||
|
||||
if (GetFiles(path, recursive).Any())
|
||||
{
|
||||
// Wait for IO operations to complete after emptying the folder since they aren't always
|
||||
// instantly removed and it can lead to false positives that files are still present.
|
||||
Thread.Sleep(3000);
|
||||
}
|
||||
|
||||
attempts++;
|
||||
files = GetFiles(path, recursive).ToList();
|
||||
}
|
||||
|
||||
Directory.Delete(path, recursive);
|
||||
}
|
||||
|
||||
@@ -9,15 +9,15 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.4" />
|
||||
<PackageReference Include="NLog" Version="5.4.0" />
|
||||
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.3" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.15" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.9" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" Version="5.4.0" />
|
||||
<PackageReference Include="Npgsql" Version="7.0.10" />
|
||||
<PackageReference Include="Sentry" Version="4.0.2" />
|
||||
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
|
||||
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="6.0.10" />
|
||||
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
|
||||
<PackageReference Include="System.ValueTuple" Version="4.6.0" />
|
||||
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />
|
||||
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
|
||||
|
||||
9
src/NzbDrone.Common/Utf8StringWriter.cs
Normal file
9
src/NzbDrone.Common/Utf8StringWriter.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace NzbDrone.Common;
|
||||
|
||||
public class Utf8StringWriter : StringWriter
|
||||
{
|
||||
public override Encoding Encoding => Encoding.UTF8;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.Torznab;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
@@ -14,13 +16,13 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
[Test]
|
||||
public void should_not_return_config_for_non_existent_indexer()
|
||||
{
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.Get(It.IsAny<int>()))
|
||||
.Throws(new ModelNotFoundException(typeof(IndexerDefinition), 0));
|
||||
Mocker.GetMock<ICachedIndexerSettingsProvider>()
|
||||
.Setup(v => v.GetSettings(It.IsAny<int>()))
|
||||
.Returns<CachedIndexerSettings>(null);
|
||||
|
||||
var result = Subject.GetSeedConfiguration(new RemoteMovie
|
||||
{
|
||||
Release = new ReleaseInfo()
|
||||
Release = new ReleaseInfo
|
||||
{
|
||||
DownloadProtocol = DownloadProtocol.Torrent,
|
||||
IndexerId = 0
|
||||
@@ -29,5 +31,53 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_return_config_for_invalid_indexer()
|
||||
{
|
||||
Mocker.GetMock<ICachedIndexerSettingsProvider>()
|
||||
.Setup(v => v.GetSettings(It.IsAny<int>()))
|
||||
.Returns<CachedIndexerSettings>(null);
|
||||
|
||||
var result = Subject.GetSeedConfiguration(new RemoteMovie
|
||||
{
|
||||
Release = new ReleaseInfo
|
||||
{
|
||||
DownloadProtocol = DownloadProtocol.Torrent,
|
||||
IndexerId = 1
|
||||
},
|
||||
ParsedMovieInfo = new ParsedMovieInfo()
|
||||
});
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_seed_time_for_movies()
|
||||
{
|
||||
var settings = new TorznabSettings();
|
||||
settings.SeedCriteria.SeedTime = 10;
|
||||
|
||||
Mocker.GetMock<ICachedIndexerSettingsProvider>()
|
||||
.Setup(v => v.GetSettings(It.IsAny<int>()))
|
||||
.Returns(new CachedIndexerSettings
|
||||
{
|
||||
FailDownloads = new HashSet<FailDownloads> { FailDownloads.Executables },
|
||||
SeedCriteriaSettings = settings.SeedCriteria
|
||||
});
|
||||
|
||||
var result = Subject.GetSeedConfiguration(new RemoteMovie
|
||||
{
|
||||
Release = new ReleaseInfo
|
||||
{
|
||||
DownloadProtocol = DownloadProtocol.Torrent,
|
||||
IndexerId = 1
|
||||
},
|
||||
ParsedMovieInfo = new ParsedMovieInfo()
|
||||
});
|
||||
|
||||
result.Should().NotBeNull();
|
||||
result.SeedTime.Should().Be(TimeSpan.FromMinutes(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,6 @@ namespace NzbDrone.Core.Test.IndexerTests
|
||||
public string BaseUrl { get; set; }
|
||||
|
||||
public IEnumerable<int> MultiLanguages { get; set; }
|
||||
public IEnumerable<int> FailDownloads { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace NzbDrone.Core.Download
|
||||
private readonly IParsingService _parsingService;
|
||||
private readonly IMovieService _movieService;
|
||||
private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported;
|
||||
private readonly IRejectedImportService _rejectedImportService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public CompletedDownloadService(IEventAggregator eventAggregator,
|
||||
@@ -42,6 +43,7 @@ namespace NzbDrone.Core.Download
|
||||
IParsingService parsingService,
|
||||
IMovieService movieService,
|
||||
ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported,
|
||||
IRejectedImportService rejectedImportService,
|
||||
Logger logger)
|
||||
{
|
||||
_eventAggregator = eventAggregator;
|
||||
@@ -51,6 +53,7 @@ namespace NzbDrone.Core.Download
|
||||
_parsingService = parsingService;
|
||||
_movieService = movieService;
|
||||
_trackedDownloadAlreadyImported = trackedDownloadAlreadyImported;
|
||||
_rejectedImportService = rejectedImportService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -159,10 +162,8 @@ namespace NzbDrone.Core.Download
|
||||
{
|
||||
var firstResult = importResults.First();
|
||||
|
||||
if (firstResult.Result == ImportResultType.Rejected && firstResult.ImportDecision.LocalMovie == null)
|
||||
if (_rejectedImportService.Process(trackedDownload, firstResult))
|
||||
{
|
||||
trackedDownload.Warn(new TrackedDownloadStatusMessage(firstResult.Errors.First(), new List<string>()));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,14 +55,18 @@ namespace NzbDrone.Core.Download
|
||||
{
|
||||
try
|
||||
{
|
||||
// Process completed items followed by failed, this allows failed imports to have
|
||||
// their state changed and be processed immediately instead of the next execution.
|
||||
|
||||
if (enableCompletedDownloadHandling && trackedDownload.State == TrackedDownloadState.ImportPending)
|
||||
{
|
||||
_completedDownloadService.Import(trackedDownload);
|
||||
}
|
||||
|
||||
if (trackedDownload.State == TrackedDownloadState.FailedPending)
|
||||
{
|
||||
_failedDownloadService.ProcessFailed(trackedDownload);
|
||||
}
|
||||
else if (enableCompletedDownloadHandling && trackedDownload.State == TrackedDownloadState.ImportPending)
|
||||
{
|
||||
_completedDownloadService.Import(trackedDownload);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
|
||||
@@ -70,7 +70,7 @@ namespace NzbDrone.Core.Download
|
||||
|
||||
if (grabbedItems.Empty())
|
||||
{
|
||||
trackedDownload.Warn("Download wasn't grabbed by Radarr, skipping");
|
||||
trackedDownload.Warn(trackedDownload.DownloadItem.IsEncrypted ? "Download is encrypted and wasn't grabbed by Radarr, skipping automatic download handling" : "Download has failed wasn't grabbed by Radarr, skipping automatic download handling");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user