Convert Movie Details to TypeScript

Co-authored-by: Mark McDowall <mark@mcdowall.ca>
This commit is contained in:
Bogdan 2025-03-09 14:08:20 +02:00
parent c078191b3d
commit f815b31c33
64 changed files with 1317 additions and 1701 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
import AppSectionState from 'App/State/AppSectionState';
import { ExtraFile } from 'MovieFile/ExtraFile';
type ExtraFilesAppState = AppSectionState<ExtraFile>;
export default ExtraFilesAppState;

View file

@ -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({

View file

@ -16,6 +16,10 @@
/** Kinds **/
.default {
color: inherit;
}
/** Sizes **/
.small {

View file

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

View file

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

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

View file

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

View file

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

View file

@ -0,0 +1,49 @@
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}
{...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;

View file

@ -36,4 +36,5 @@ export type Kind =
| 'primary'
| 'purple'
| 'success'
| 'warning';
| 'warning'
| 'queue';

View file

@ -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,
})

View file

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

View file

@ -160,9 +160,8 @@
}
.overview {
flex: 1 0 auto;
flex: 1 0 0;
margin-top: 8px;
padding-left: 7px;
min-height: 0;
font-size: $intermediateFontSize;
}

View file

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

View file

@ -0,0 +1,956 @@
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 MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
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 { 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,
};
}
);
}
function createMovieSelector(movieId: number) {
return createSelector(createAllMoviesSelector(), (allMovies) => {
const sortedMovies = [...allMovies].sort(sortByProp('sortTitle'));
const movieIndex = sortedMovies.findIndex((movie) => movie.id === movieId);
if (movieIndex === -1) {
return {
movie: undefined,
nextMovie: undefined,
previousMovie: undefined,
};
}
const movie = sortedMovies[movieIndex];
const nextMovie = sortedMovies[movieIndex + 1] ?? sortedMovies[0];
const previousMovie =
sortedMovies[movieIndex - 1] ?? sortedMovies[sortedMovies.length - 1];
return {
movie,
nextMovie: {
title: nextMovie.title,
titleSlug: nextMovie.titleSlug,
},
previousMovie: {
title: previousMovie.title,
titleSlug: previousMovie.titleSlug,
},
};
});
}
interface MovieDetailsProps {
movieId: number;
}
function MovieDetails({ movieId }: MovieDetailsProps) {
const dispatch = useDispatch();
const history = useHistory();
const { movie, nextMovie, previousMovie } = useSelector(
createMovieSelector(movieId)
);
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 isSaving = useSelector((state: AppState) => state.movies.isSaving);
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 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 (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}`));
}
}
},
[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());
};
}, [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,
} = 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}>
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('MovieDetailsGoTo', {
title: previousMovie.title,
})}
to={`/movie/${previousMovie.titleSlug}`}
/>
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('MovieDetailsGoTo', {
title: nextMovie.title,
})}
to={`/movie/${nextMovie.titleSlug}`}
/>
</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}>
<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>
<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;

View file

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

View file

@ -0,0 +1,39 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useHistory } from 'react-router-dom';
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;

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -9,5 +9,5 @@ export default {
MOVIES,
INTERACTIVE_IMPORT,
WANTED_CUTOFF_UNMET,
WANTED_MISSING
};
WANTED_MISSING,
} as const;

View file

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

View file

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

View file

@ -0,0 +1,8 @@
import { useSelector } from 'react-redux';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
const useTags = () => {
return useSelector(createTagsSelector());
};
export default useTags;

View file

@ -407,7 +407,7 @@
"GrabRelease": "انتزاع الإصدار",
"Grabbed": "اقتطف",
"Grab": "إختطاف",
"GoToInterp": "انتقل إلى {0}",
"MovieDetailsGoTo": "انتقل إلى {0}",
"Global": "عالمي",
"Genres": "الأنواع",
"GeneralSettingsSummary": "المنفذ ، SSL ، اسم المستخدم / كلمة المرور ، الوكيل ، التحليلات والتحديثات",
@ -516,7 +516,7 @@
"SourcePath": "مسار المصدر",
"Source": "مصدر",
"Sort": "فرز",
"SorryThatMovieCannotBeFound": "آسف ، لا يمكن العثور على هذا الفيلم.",
"MovieCannotBeFound": "آسف ، لا يمكن العثور على هذا الفيلم.",
"SomeResultsHiddenFilter": "بعض النتائج مخفية بواسطة عامل التصفية المطبق",
"Socks5": "Socks5 (دعم TOR)",
"Socks4": "الجوارب 4",

View file

@ -253,7 +253,7 @@
"GeneralSettingsSummary": "Порт, SSL, потребителско име / парола, прокси, анализи и актуализации",
"Genres": "Жанрове",
"Global": "Глобален",
"GoToInterp": "Отидете на {0}",
"MovieDetailsGoTo": "Отидете на {0}",
"Grab": "Грабнете",
"Grabbed": "Грабната",
"GrabRelease": "Grab Release",
@ -532,7 +532,7 @@
"Socks4": орапи4",
"Socks5": орапи5 (Поддръжка на TOR)",
"SomeResultsHiddenFilter": "Някои резултати са скрити от прилагания филтър",
"SorryThatMovieCannotBeFound": "За съжаление този филм не може да бъде намерен.",
"MovieCannotBeFound": "За съжаление този филм не може да бъде намерен.",
"Sort": "Вид",
"Source": "Източник",
"SourcePath": "Път на източника",

View file

@ -511,7 +511,7 @@
"General": "General",
"GeneralSettings": "Configuració general",
"Global": "Global",
"GoToInterp": "Vés a {0}",
"MovieDetailsGoTo": "Vés a {0}",
"Grab": "Captura",
"Grabbed": "Capturat",
"ICalFeedHelpText": "Copieu aquest URL als vostres clients o feu clic per a subscriure's si el vostre navegador és compatible amb webcal",
@ -965,7 +965,7 @@
"SetPermissions": "Estableix permisos",
"Small": "Petita",
"SomeResultsHiddenFilter": "Alguns resultats estan ocults pel filtre aplicat",
"SorryThatMovieCannotBeFound": "Ho sentim, aquesta pel·lícula no s'ha trobat.",
"MovieCannotBeFound": "Ho sentim, aquesta pel·lícula no s'ha trobat.",
"Sort": "Ordena",
"Source": "Font",
"SourcePath": "Camí de la font",

View file

@ -64,7 +64,7 @@
"SupportedDownloadClientsMoreInfo": "Další informace o jednotlivých klientech pro stahování získáte kliknutím na informační tlačítka.",
"SupportedListsMoreInfo": "Další informace o jednotlivých seznamech importů získáte kliknutím na informační tlačítka.",
"GeneralSettingsSummary": "Port, SSL, uživatelské jméno / heslo, proxy, analytika a aktualizace",
"GoToInterp": "Přejít na {0}",
"MovieDetailsGoTo": "Přejít na {0}",
"Interval": "Interval",
"LanguageHelpText": "Jazyk pro zprávy",
"LastWriteTime": "Čas posledního zápisu",
@ -832,7 +832,7 @@
"Socks4": "4. ponožky",
"Socks5": "Ponožky5 (podpora TOR)",
"SomeResultsHiddenFilter": "Některé výsledky jsou použitým filtrem skryty",
"SorryThatMovieCannotBeFound": "Je nám líto, ale tento film nelze najít.",
"MovieCannotBeFound": "Je nám líto, ale tento film nelze najít.",
"Sort": "Třídit",
"SourcePath": "Cesta zdroje",
"SourceRelativePath": "Cesta relativního zdroje",

View file

@ -578,7 +578,7 @@
"SupportedIndexersMoreInfo": "Klik på info-knapperne for at få flere oplysninger om de enkelte indeksatorer.",
"GeneralSettings": "Generelle indstillinger",
"Global": "Global",
"GoToInterp": "Gå til {0}",
"MovieDetailsGoTo": "Gå til {0}",
"Grab": "Tag fat",
"GrabRelease": "Hent udgivelse",
"GrabReleaseMessageText": "{appName} var ikke i stand til at bestemme, hvilken film denne udgivelse var til. {appName} kan muligvis ikke automatisk importere denne udgivelse. Vil du hente '{0}'?",
@ -807,7 +807,7 @@
"Socks4": "Strømper 4",
"Socks5": "Socks5 (Understøtter TOR)",
"SomeResultsHiddenFilter": "Nogle resultater skjules af det anvendte filter",
"SorryThatMovieCannotBeFound": "Beklager, den film kan ikke findes.",
"MovieCannotBeFound": "Beklager, den film kan ikke findes.",
"Sort": "Sortere",
"Source": "Kilde",
"SourcePath": "Kildesti",

View file

@ -483,7 +483,7 @@
"ShowTitleHelpText": "Filmtitel unter dem Plakat anzeigen",
"ShowUnknownMovieItems": "Unzugeordente Filmeinträge anzeigen",
"SkipFreeSpaceCheck": "Prüfung des freien Speichers überspringen",
"SorryThatMovieCannotBeFound": "Schade, dieser Film kann nicht gefunden werden.",
"MovieCannotBeFound": "Schade, dieser Film kann nicht gefunden werden.",
"SourcePath": "Quellpfad",
"SourceRelativePath": "Relativer Quellpfad",
"SslCertPassword": "SSL Zertifikat Passwort",
@ -610,7 +610,7 @@
"ImportFailed": "Import fehlgeschlagen: {0}",
"HiddenClickToShow": "Versteckt, zum Anzeigen anklicken",
"GrabReleaseMessageText": "{appName} konnte nicht bestimmen, für welchen Film dieser Release ist. {appName} könnte diesen Release möglicherweise nicht automatisch importieren. Möchtest du '{0}' grabben?",
"GoToInterp": "Zu {0} gehen",
"MovieDetailsGoTo": "Zu {0} gehen",
"ExistingTag": "Vorhandener Tag",
"ExcludeMovie": "Film ausschließen",
"SearchIsNotSupportedWithThisIndexer": "Suche wird von diesem Indexer nicht unterstützt",

View file

@ -130,7 +130,7 @@
"ProxyPasswordHelpText": "Πρέπει να εισαγάγετε ένα όνομα χρήστη και έναν κωδικό πρόσβασης μόνο εάν απαιτείται. Αφήστε τα κενά διαφορετικά.",
"RemovedFromTaskQueue": "Καταργήθηκε από την ουρά εργασιών",
"ReplaceIllegalCharactersHelpText": "Αντικαταστήστε τους παράνομους χαρακτήρες. Εάν δεν είναι επιλεγμένο, το {appName} θα τα καταργήσει",
"SorryThatMovieCannotBeFound": "Δυστυχώς, δεν είναι δυνατή η εύρεση αυτής της ταινίας.",
"MovieCannotBeFound": "Δυστυχώς, δεν είναι δυνατή η εύρεση αυτής της ταινίας.",
"Source": "Πηγή",
"ICalTagsMoviesHelpText": "Ισχύει για ταινίες με τουλάχιστον μία αντίστοιχη ετικέτα",
"TheLogLevelDefault": "Το επίπεδο καταγραφής είναι από προεπιλογή σε «Πληροφορίες» και μπορεί να αλλάξει",
@ -557,7 +557,7 @@
"SupportedIndexersMoreInfo": "Για περισσότερες πληροφορίες σχετικά με τους μεμονωμένους δείκτες, κάντε κλικ στα κουμπιά πληροφοριών.",
"GeneralSettings": "Γενικές Ρυθμίσεις",
"Global": "Παγκόσμια",
"GoToInterp": "Μετάβαση στο {0}",
"MovieDetailsGoTo": "Μετάβαση στο {0}",
"Grab": "Αρπάζω",
"Grabbed": "Αρπαξε",
"GrabRelease": "Πιάσε την απελευθέρωση",

View file

@ -651,7 +651,6 @@
"FailedDownloadHandling": "Failed Download Handling",
"FailedLoadingSearchResults": "Failed to load search results, please try again.",
"FailedToFetchUpdates": "Failed to fetch updates",
"FailedToLoadMovieFromAPI": "Failed to load movie from API",
"FailedToUpdateSettings": "Failed to update settings",
"Fallback": "Fallback",
"False": "False",
@ -703,7 +702,6 @@
"GeneralSettingsSummary": "Port, SSL, username/password, proxy, analytics and updates",
"Genres": "Genres",
"Global": "Global",
"GoToInterp": "Go to {0}",
"Grab": "Grab",
"GrabId": "Grab ID",
"GrabRelease": "Grab Release",
@ -1042,9 +1040,11 @@
"Movie": "Movie",
"MovieAlreadyExcluded": "Movie already Excluded",
"MovieAndCollection": "Movie and Collection",
"MovieCannotBeFound": "Sorry, that movie cannot be found.",
"MovieChat": "Movie Chat",
"MovieCollectionFolderMultipleMissingRootsHealthCheckMessage": "Multiple root folders are missing for movie collections: {rootFoldersInfo}",
"MovieCollectionRootFolderMissingRootHealthCheckMessage": "Missing root folder for movie collection: {rootFolderInfo}",
"MovieDetailsGoTo": "Go to {0}",
"MovieDetailsNextMovie": "Movie Details: Next Movie",
"MovieDetailsPreviousMovie": "Movie Details: Previous Movie",
"MovieDownloaded": "Movie Downloaded",
@ -1735,7 +1735,6 @@
"Socks4": "Socks4",
"Socks5": "Socks5 (Support TOR)",
"SomeResultsHiddenFilter": "Some results are hidden by the applied filter",
"SorryThatMovieCannotBeFound": "Sorry, that movie cannot be found.",
"Sort": "Sort",
"Source": "Source",
"SourcePath": "Source Path",

View file

@ -422,7 +422,7 @@
"SslCertPassword": "Contraseña del Certificado SSL",
"SourceRelativePath": "Ruta relativa de la fuente",
"SourcePath": "Ruta de la fuente",
"SorryThatMovieCannotBeFound": "Lo siento, no he encontrado esa película.",
"MovieCannotBeFound": "Lo siento, no he encontrado esa película.",
"SkipFreeSpaceCheck": "Saltar comprobación de espacio libre",
"ShowUnknownMovieItems": "Mostrar Elementos Desconocidos",
"ShowTitleHelpText": "Mostrar el título de la película debajo del poster",
@ -611,7 +611,7 @@
"ImportFailed": "Error de importación: {sourceTitle}",
"HiddenClickToShow": "Oculto, pulsa para mostrar",
"GrabReleaseMessageText": "{appName} no pudo determinar para qué película es este lanzamiento. {appName} no podrá importar este lanzamiento automáticamente. ¿Quieres descargar '{0}'?",
"GoToInterp": "Ir a {0}",
"MovieDetailsGoTo": "Ir a {0}",
"ExistingTag": "Etiquetas existentes",
"ExcludeMovie": "Excluir Película",
"SearchIsNotSupportedWithThisIndexer": "La búsqueda no está soportada con este indexador",

View file

@ -566,7 +566,7 @@
"GeneralSettings": "Yleiset asetukset",
"GeneralSettingsSummary": "Portti, SSL-salaus, käyttäjätunnus ja salasana, välityspalvelin, analytiikka ja päivitykset.",
"Global": "Yleiset",
"GoToInterp": "Siirry kohteeseen '{0}'",
"MovieDetailsGoTo": "Siirry kohteeseen '{0}'",
"Grab": "Kaappaa",
"Grabbed": "Kaapattu",
"GrabRelease": "Kaappaa julkaisu",
@ -811,7 +811,7 @@
"Socks4": "SOCKS4",
"Socks5": "SOCKS5 (TOR-tuki)",
"SomeResultsHiddenFilter": "Aktiivinen suodatin piilottaa joitakin tuloksia.",
"SorryThatMovieCannotBeFound": "Valitettavasti elokuvaa ei löydy.",
"MovieCannotBeFound": "Valitettavasti elokuvaa ei löydy.",
"Sort": "Järjestys",
"Source": "Lähde",
"SourcePath": "Lähdesijainti",

View file

@ -424,7 +424,7 @@
"DeleteRestrictionHelpText": "Voulez-vous vraiment supprimer cette restriction ?",
"DeleteIndexerMessageText": "Voulez-vous vraiment supprimer l'indexeur « {name} » ?",
"CopyToClipboard": "Copier dans le presse-papier",
"GoToInterp": "Aller à {0}",
"MovieDetailsGoTo": "Aller à {0}",
"SupportedListsMoreInfo": "Pour plus d'informations sur les listes d'importation individuelles, cliquez sur les boutons d'information.",
"SupportedDownloadClientsMoreInfo": "Pour plus d'informations sur chaque client de téléchargement, cliquez sur les boutons plus d'information.",
"FilterPlaceHolder": "Chercher les films",
@ -606,7 +606,7 @@
"SslCertPassword": "Mot de passe du certificat SSL",
"SourceRelativePath": "Chemin relatif de la source",
"SourcePath": "Chemin source",
"SorryThatMovieCannotBeFound": "Désolé, ce film est introuvable.",
"MovieCannotBeFound": "Désolé, ce film est introuvable.",
"SkipFreeSpaceCheck": "Ignorer la vérification de l'espace libre",
"ShowYear": "Afficher l'année",
"ShowUnknownMovieItems": "Afficher les éléments de film inconnus",

View file

@ -563,7 +563,7 @@
"GeneralSettingsSummary": "יציאה, SSL, שם משתמש / סיסמה, פרוקסי, ניתוחים ועדכונים",
"Genres": "ז'אנרים",
"Global": "גלוֹבָּלִי",
"GoToInterp": "עבור אל {0}",
"MovieDetailsGoTo": "עבור אל {0}",
"Grab": "לִתְפּוֹס",
"Grabbed": "תפס",
"GrabRelease": "שחרור תפוס",
@ -812,7 +812,7 @@
"Socks4": "גרביים 4",
"Socks5": "Socks5 (תמיכה ב- TOR)",
"SomeResultsHiddenFilter": "חלק מהתוצאות מוסתרות על ידי המסנן שהוחל",
"SorryThatMovieCannotBeFound": "מצטערים, הסרט הזה לא נמצא.",
"MovieCannotBeFound": "מצטערים, הסרט הזה לא נמצא.",
"Sort": "סוג",
"SourcePath": "נתיב מקור",
"SourceRelativePath": "נתיב יחסי מקור",

View file

@ -700,7 +700,7 @@
"GeneralSettings": "सामान्य सेटिंग्स",
"Genres": "शैलियां",
"Global": "वैश्विक",
"GoToInterp": "{0} पर जाएं",
"MovieDetailsGoTo": "{0} पर जाएं",
"Grab": "लपकना",
"Grabbed": "पकड़ा",
"GrabRelease": "पकड़ो रिलीज",
@ -861,7 +861,7 @@
"SkipFreeSpaceCheck": "फ्री स्पेस चेक छोड़ें",
"Small": "छोटा",
"SomeResultsHiddenFilter": "कुछ परिणाम लागू फ़िल्टर द्वारा छिपे हुए हैं",
"SorryThatMovieCannotBeFound": "क्षमा करें, वह फिल्म नहीं मिल रही है।",
"MovieCannotBeFound": "क्षमा करें, वह फिल्म नहीं मिल रही है।",
"Sort": "तरह",
"Source": "स्रोत",
"SourcePath": "स्रोत पथ",

View file

@ -188,7 +188,7 @@
"HardlinkCopyFiles": "Hardlinkelés/Fájl(ok) Másolása",
"Group": "Csoport",
"GrabReleaseMessageText": "{appName} nem tudta meghatározni, hogy melyik filmhez készült ez a kiadás. Lehet, hogy a {appName} nem tudja automatikusan importálni ezt a kiadást. Meg szeretnéd ragadni a (z) „{0}”-t?",
"GoToInterp": "Ugrás ide: {0}",
"MovieDetailsGoTo": "Ugrás ide: {0}",
"Genres": "Műfajok",
"GeneralSettingsSummary": "Port, SSL, felhasználónév/jelszó, proxy, elemzések és frissítések",
"FreeSpace": "Szabad hely",
@ -258,7 +258,7 @@
"SourcePath": "Forrás útvonala",
"Source": "Forrás",
"Sort": "Fajta",
"SorryThatMovieCannotBeFound": "Sajnáljuk, ez a film nem található.",
"MovieCannotBeFound": "Sajnáljuk, ez a film nem található.",
"SkipFreeSpaceCheck": "Kihagyja a szabad hely ellenőrzését",
"SizeOnDisk": "Méret a lemezen",
"Size": "Méret",

View file

@ -109,7 +109,7 @@
"ShowUnknownMovieItems": "Sýna óþekktar kvikmyndir",
"SkipFreeSpaceCheck": "Slepptu ókeypis plássathugun",
"Socks5": "Socks5 (stuðningur TOR)",
"SorryThatMovieCannotBeFound": "Því miður er ekki hægt að finna þá kvikmynd.",
"MovieCannotBeFound": "Því miður er ekki hægt að finna þá kvikmynd.",
"SslCertPassword": "SSL vottorð lykilorð",
"SslCertPathHelpText": "Leið að pfx skrá",
"SslPort": "SSL höfn",
@ -602,7 +602,7 @@
"GeneralSettingsSummary": "Gátt, SSL, notandanafn / lykilorð, umboð, greining og uppfærslur",
"Genres": "Tegundir",
"Global": "Alheimslegt",
"GoToInterp": "Farðu í {0}",
"MovieDetailsGoTo": "Farðu í {0}",
"Grab": "Grípa",
"Grabbed": "Greip",
"GrabRelease": "Grípa losun",

View file

@ -542,7 +542,7 @@
"HaveNotAddedMovies": "Non hai ancora aggiunto nessun film, vuoi prima importarne alcuni o tutti i tuoi film?",
"Group": "Gruppo",
"GrabReleaseMessageText": "{appName} non è stato in grado di determinare a quale film si riferisce questa release. {appName} potrebbe non essere in grado di importarla automaticamente. Vuoi catturare '{0}'?",
"GoToInterp": "Vai a {0}",
"MovieDetailsGoTo": "Vai a {0}",
"Global": "Globale",
"GeneralSettings": "Impostazioni Generali",
"SupportedIndexersMoreInfo": "Per maggiori informazioni sui singoli Indexer clicca sul pulsante info.",
@ -675,7 +675,7 @@
"SslCertPassword": "Password Certificato SSL",
"SourceRelativePath": "Percorso relativo origine",
"SourcePath": "Percorso origine",
"SorryThatMovieCannotBeFound": "Mi spiace, impossibile trovare il film.",
"MovieCannotBeFound": "Mi spiace, impossibile trovare il film.",
"SkipFreeSpaceCheck": "Salta controllo spazio libero",
"ShowYear": "Mostra anno",
"ShowUnknownMovieItems": "Mostra film sconosciuti",

View file

@ -550,7 +550,7 @@
"GeneralSettings": "一般設定",
"GeneralSettingsSummary": "ポート、SSL、ユーザー名/パスワード、プロキシ、分析、更新",
"Global": "グローバル",
"GoToInterp": "{0}に移動",
"MovieDetailsGoTo": "{0}に移動",
"Grab": "つかむ",
"Grabbed": "掴んだ",
"GrabRelease": "グラブリリース",
@ -807,7 +807,7 @@
"Socks4": "ソックス4",
"Socks5": "Socks5TORをサポート",
"SomeResultsHiddenFilter": "一部の結果は、適用されたフィルターによって非表示になります",
"SorryThatMovieCannotBeFound": "申し訳ありませんが、その映画は見つかりません。",
"MovieCannotBeFound": "申し訳ありませんが、その映画は見つかりません。",
"Sort": "ソート",
"Source": "ソース",
"SourcePath": "ソースパス",

View file

@ -561,7 +561,7 @@
"GeneralSettings": "일반 설정",
"GeneralSettingsSummary": "포트, SSL, 사용자 이름 / 암호, 프록시, 분석 및 업데이트",
"Global": "글로벌",
"GoToInterp": "{0}로 이동",
"MovieDetailsGoTo": "{0}로 이동",
"Grab": "잡아",
"Grabbed": "잡았다",
"GrabRelease": "그랩 출시",
@ -815,7 +815,7 @@
"Small": "작게",
"Socks4": "Socks4",
"Socks5": "Socks5 (TOR 지원)",
"SorryThatMovieCannotBeFound": "죄송합니다, 해당 영화를 찾을 수 없음",
"MovieCannotBeFound": "죄송합니다, 해당 영화를 찾을 수 없음",
"Sort": "정렬",
"Source": "원본",
"SourcePath": "원본 경로",

View file

@ -464,7 +464,7 @@
"SetPermissionsLinuxHelpText": "Moet chmod worden uitgevoerd wanneer bestanden worden geïmporteerd/hernoemd?",
"IconForCutoffUnmetHelpText": "Toon icoon voor bestanden waarbij de drempel niet behaald werd",
"ShowUnknownMovieItems": "Toon onbekende film items",
"SorryThatMovieCannotBeFound": "Sorry, deze film kan niet worden gevonden.",
"MovieCannotBeFound": "Sorry, deze film kan niet worden gevonden.",
"StandardMovieFormat": "Standaard Film Formaat",
"TestAllIndexers": "Test Alle Indexeerders",
"TorrentDelayHelpText": "Wachttijd in minuten alvorens een torrent op te halen",
@ -617,7 +617,7 @@
"ImportFailed": "Importeren mislukt: {sourceTitle}",
"HiddenClickToShow": "Verborgen, klik om te tonen",
"GrabReleaseMessageText": "{appName} was niet in staat om deze uitgave aan een film te koppelen. {appName} zal waarschijnlijk deze uitgave niet automatisch kunnen importeren. Wilt u '{0}' ophalen?",
"GoToInterp": "Ga naar {0}",
"MovieDetailsGoTo": "Ga naar {0}",
"ExistingTag": "Bestaande tag",
"ExcludeMovie": "Film Uitsluiten",
"SearchIsNotSupportedWithThisIndexer": "Zoeken wordt niet ondersteund door deze indexeerder",

View file

@ -577,7 +577,7 @@
"GeneralSettings": "Ustawienia główne",
"GeneralSettingsSummary": "Port, SSL, nazwa użytkownika / hasło, proxy, analizy i aktualizacje",
"Global": "Światowy",
"GoToInterp": "Idź do {0}",
"MovieDetailsGoTo": "Idź do {0}",
"Grab": "Chwycić",
"Grabbed": "Złapał",
"GrabRelease": "Pobierz Wydanie",
@ -822,7 +822,7 @@
"Socks4": "Skarpetki 4",
"Socks5": "Socks5 (Wsparcie TOR)",
"SomeResultsHiddenFilter": "Niektóre wyniki są ukrywane przez zastosowany filtr",
"SorryThatMovieCannotBeFound": "Przepraszamy, nie można znaleźć tego filmu.",
"MovieCannotBeFound": "Przepraszamy, nie można znaleźć tego filmu.",
"Sort": "Sortować",
"Source": "Źródło",
"SourcePath": "Ścieżka źródłowa",

View file

@ -561,7 +561,7 @@
"HiddenClickToShow": "Oculto, clique para mostrar",
"HaveNotAddedMovies": "Você ainda não adicionou nenhum filme. Quer importar alguns ou todos os seus filmes primeiro?",
"GrabReleaseMessageText": "O {appName} não pode determinar a que filme pertence esta versão. O {appName} pode ser incapaz de importar automaticamente esta versão. Deseja capturar \"{0}\"?",
"GoToInterp": "Ir para {0}",
"MovieDetailsGoTo": "Ir para {0}",
"SupportedListsMoreInfo": "Para obter mais informações sobre cada lista de importação, clique nos botões de informação.",
"SupportedDownloadClientsMoreInfo": "Para obter mais informações sobre cada cliente de transferências, clique nos botões de mais informação.",
"FilterPlaceHolder": "Pesquisar filmes",
@ -620,7 +620,7 @@
"SslCertPassword": "Palavra-passe do certificado SSL",
"SourceRelativePath": "Caminho relativo de origem",
"SourcePath": "Caminho de origem",
"SorryThatMovieCannotBeFound": "Desculpe, este filme não foi encontrado.",
"MovieCannotBeFound": "Desculpe, este filme não foi encontrado.",
"SkipFreeSpaceCheck": "Pular verificação de espaço livre",
"ShowYear": "Mostrar ano",
"ShowUnknownMovieItems": "Mostrar itens desconhecidos do filme",

View file

@ -84,7 +84,7 @@
"GrabRelease": "Baixar lançamento",
"Grabbed": "Obtido",
"Grab": "Obter",
"GoToInterp": "Ir para {0}",
"MovieDetailsGoTo": "Ir para {0}",
"Global": "Global",
"Genres": "Gêneros",
"GeneralSettingsSummary": "Porta, SSL, nome de usuário/senha, proxy, análises e atualizações",
@ -619,7 +619,7 @@
"SourcePath": "Caminho da Fonte",
"Source": "Origem",
"Sort": "Ordenar",
"SorryThatMovieCannotBeFound": "Desculpe, esse filme não pode ser encontrado.",
"MovieCannotBeFound": "Desculpe, esse filme não pode ser encontrado.",
"SomeResultsHiddenFilter": "Alguns resultados estão ocultos pelo filtro aplicado",
"Socks5": "Socks5 (Suporte à TOR)",
"Small": "Pequeno",

View file

@ -316,7 +316,7 @@
"ShowSearchHelpText": "Afișați butonul de căutare pe hover",
"Socks5": "Șosete5 (Suport TOR)",
"SomeResultsHiddenFilter": "Unele rezultate sunt ascunse de filtrul aplicat",
"SorryThatMovieCannotBeFound": "Ne pare rău, filmul respectiv nu poate fi găsit.",
"MovieCannotBeFound": "Ne pare rău, filmul respectiv nu poate fi găsit.",
"TheLogLevelDefault": "Nivelul jurnalului este implicit la „Informații” și poate fi modificat în",
"ThisCannotBeCancelled": "Acest lucru nu poate fi anulat odată pornit fără a reporni {appName}.",
"TorrentDelayHelpText": "Întârziați în câteva minute pentru a aștepta înainte de a apuca un torent",
@ -690,7 +690,7 @@
"SupportedIndexersMoreInfo": "Pentru mai multe informații despre indexatorii individuali, faceți clic pe butoanele de informații.",
"GeneralSettings": "Setări generale",
"Global": "Global",
"GoToInterp": "Accesați {0}",
"MovieDetailsGoTo": "Accesați {0}",
"Grab": "Apuca",
"GrabRelease": "Grab Release",
"GrabReleaseMessageText": "{appName} nu a putut stabili pentru ce film a fost lansată această lansare. Este posibil ca {appName} să nu poată importa automat această versiune. Doriți să luați „{0}”?",

View file

@ -575,7 +575,7 @@
"GrabRelease": "Захватить релиз",
"Grabbed": "Захвачено",
"Grab": "Захватить",
"GoToInterp": "Перейти {0}",
"MovieDetailsGoTo": "Перейти {0}",
"Global": "Глобальный",
"Genres": "Жанры",
"GeneralSettingsSummary": "Порт, SSL, имя пользователя/пароль, прокси, аналитика и обновления",
@ -874,7 +874,7 @@
"SourcePath": "Исходный путь",
"Source": "Исходный код",
"Sort": "Сортировка",
"SorryThatMovieCannotBeFound": "Извините, этот фильм не найден.",
"MovieCannotBeFound": "Извините, этот фильм не найден.",
"SomeResultsHiddenFilter": "Некоторые результаты скрыты примененным фильтром",
"Socks5": "Socks5 (Поддержка TOR)",
"Socks4": "Socks4 прокси",

View file

@ -370,7 +370,7 @@
"HiddenClickToShow": "Dold, klicka för att visa",
"Group": "Grupp",
"Folders": "Mappar",
"GoToInterp": "Gå till {0}",
"MovieDetailsGoTo": "Gå till {0}",
"Global": "Global",
"FilterPlaceHolder": "Sök filmer",
"ExtraFileExtensionsHelpTextsExamples": "Exempel: '.sub, .nfo' eller 'sub,nfo'",
@ -883,7 +883,7 @@
"Small": "Små",
"Socks4": "Strumpor4",
"Socks5": "Socks5 (Support TOR)",
"SorryThatMovieCannotBeFound": "Tyvärr kan den filmen inte hittas.",
"MovieCannotBeFound": "Tyvärr kan den filmen inte hittas.",
"SslCertPassword": "SSL-certifierat lösenord",
"SslCertPasswordHelpText": "Lösenord för pfx-fil",
"SslCertPath": "SSL-certifierad sökväg",

View file

@ -643,7 +643,7 @@
"FreeSpace": "ที่ว่าง",
"GeneralSettingsSummary": "พอร์ต SSL ชื่อผู้ใช้ / รหัสผ่านพร็อกซีการวิเคราะห์และอัปเดต",
"Global": "ทั่วโลก",
"GoToInterp": "ไปที่ {0}",
"MovieDetailsGoTo": "ไปที่ {0}",
"Grabbed": "คว้า",
"GrabRelease": "คว้ารีลีส",
"GrabSelected": "Grab Selected",
@ -840,7 +840,7 @@
"Socks4": "ถุงเท้า 4",
"Socks5": "ถุงเท้า 5 (รองรับ TOR)",
"SomeResultsHiddenFilter": "ผลลัพธ์บางอย่างถูกซ่อนไว้โดยตัวกรองที่ใช้",
"SorryThatMovieCannotBeFound": "ขออภัยไม่พบภาพยนตร์เรื่องนั้น",
"MovieCannotBeFound": "ขออภัยไม่พบภาพยนตร์เรื่องนั้น",
"SourcePath": "เส้นทางแหล่งที่มา",
"SourceRelativePath": "เส้นทางสัมพัทธ์ของแหล่งที่มา",
"SourceTitle": "ชื่อแหล่งที่มา",

View file

@ -275,7 +275,7 @@
"SkipFreeSpaceCheck": "Boş Alan Kontrolünü Atla",
"Socks4": "Çorap4",
"SomeResultsHiddenFilter": "Bazı sonuçlar, uygulanan filtre tarafından gizlendi",
"SorryThatMovieCannotBeFound": "Maalesef o film bulunamıyor.",
"MovieCannotBeFound": "Maalesef o film bulunamıyor.",
"Source": "Kaynak",
"SourcePath": "Kaynak Yolu",
"SourceRelativePath": "Kaynak Göreceli Yol",
@ -746,7 +746,7 @@
"GeneralSettings": "Genel Ayarlar",
"Genres": "Türler",
"Global": "Küresel",
"GoToInterp": "{0}'a git",
"MovieDetailsGoTo": "{0}'a git",
"Grabbed": "Alındı",
"GrabRelease": "Sürüm Yakala",
"Group": "Grup",

View file

@ -835,7 +835,7 @@
"SizeLimit": "Обмеження розміру",
"SizeOnDisk": "Розмір на диску",
"Small": "Маленький",
"SorryThatMovieCannotBeFound": "Вибачте, цей фільм неможливо знайти.",
"MovieCannotBeFound": "Вибачте, цей фільм неможливо знайти.",
"Sort": "Сортування",
"Source": "Джерело",
"SourceRelativePath": "Відносний шлях джерела",
@ -977,7 +977,7 @@
"DownloadClientStatusCheckSingleClientMessage": "Завантаження клієнтів недоступне через помилки: {downloadClientNames}",
"Genres": "Жанри",
"Global": "Глобальний",
"GoToInterp": "Перейти до {0}",
"MovieDetailsGoTo": "Перейти до {0}",
"Group": "Група",
"HardlinkCopyFiles": "Жорстке посилання/Копіювати файли",
"HaveNotAddedMovies": "Ви ще не додали жодного фільму. Бажаєте спершу імпортувати деякі або всі свої фільми?",

View file

@ -531,7 +531,7 @@
"ExcludeMovie": "Loại trừ Phim",
"EnableSsl": "Bật SSL",
"Path": "Con đường",
"GoToInterp": "Đi tới {0}",
"MovieDetailsGoTo": "Đi tới {0}",
"Grab": "Vồ lấy",
"Grabbed": "Nắm lấy",
"ImportExtraFiles": "Nhập tệp bổ sung",
@ -858,7 +858,7 @@
"SizeOnDisk": "Kích thước trên đĩa",
"SkipFreeSpaceCheck": "Bỏ qua kiểm tra dung lượng trống",
"SomeResultsHiddenFilter": "Một số kết quả bị ẩn bởi bộ lọc được áp dụng",
"SorryThatMovieCannotBeFound": "Xin lỗi, không thể tìm thấy bộ phim đó.",
"MovieCannotBeFound": "Xin lỗi, không thể tìm thấy bộ phim đó.",
"Sort": "Sắp xếp",
"Source": "Nguồn",
"SourcePath": "Đường dẫn nguồn",

View file

@ -495,7 +495,7 @@
"SslCertPath": "SSL证书路径",
"SourcePath": "来源路径",
"Source": "代码",
"SorryThatMovieCannotBeFound": "对不起,未找到该电影。",
"MovieCannotBeFound": "对不起,未找到该电影。",
"SkipFreeSpaceCheck": "跳过剩余空间检查",
"SizeOnDisk": "磁盘占用大小",
"Size": "大小",
@ -608,7 +608,7 @@
"HardlinkCopyFiles": "硬链接/复制文件",
"GrabSelected": "抓取已选",
"GrabRelease": "抓取版本",
"GoToInterp": "跳转到 {0}",
"MovieDetailsGoTo": "跳转到 {0}",
"Genres": "类型",
"MoveMovieFoldersRenameFolderWarning": "这也将根据设置中的电影文件夹格式重命名电影文件夹。",
"FocusSearchBox": "聚焦搜索框",