New: Rework Movie Details view

This commit is contained in:
Qstick 2022-03-16 21:14:09 -05:00
parent 757cb9a956
commit 1d4db26f17
37 changed files with 494 additions and 434 deletions

View file

@ -2,12 +2,15 @@ import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import FilterMenu from 'Components/Menu/FilterMenu';
import PageMenuButton from 'Components/Menu/PageMenuButton';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import { icons, sortDirections } from 'Helpers/Props';
import { align, icons, sortDirections } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector';
import InteractiveSearchRowConnector from './InteractiveSearchRowConnector';
import styles from './InteractiveSearchContent.css';
import styles from './InteractiveSearch.css';
const columns = [
{
@ -22,20 +25,6 @@ const columns = [
isSortable: true,
isVisible: true
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, { name: icons.DANGER }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'title',
label: translate('Title'),
@ -99,10 +88,24 @@ const columns = [
label: React.createElement(Icon, { name: icons.FLAG }),
isSortable: true,
isVisible: true
},
{
name: 'rejections',
label: React.createElement(Icon, { name: icons.DANGER }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
},
{
name: 'releaseWeight',
label: React.createElement(Icon, { name: icons.DOWNLOAD }),
isSortable: true,
fixedSortDirection: sortDirections.ASCENDING,
isVisible: true
}
];
function InteractiveSearchContent(props) {
function InteractiveSearch(props) {
const {
searchPayload,
isFetching,
@ -110,44 +113,63 @@ function InteractiveSearchContent(props) {
error,
totalReleasesCount,
items,
selectedFilterKey,
filters,
customFilters,
sortKey,
sortDirection,
longDateFormat,
timeFormat,
onSortPress,
onFilterSelect,
onGrabPress
} = props;
return (
<div>
<div className={styles.filterMenuContainer}>
<FilterMenu
alignMenu={align.RIGHT}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
buttonComponent={PageMenuButton}
filterModalConnectorComponent={InteractiveSearchFilterModalConnector}
filterModalConnectorComponentProps={'movies'}
onFilterSelect={onFilterSelect}
/>
</div>
{
isFetching &&
<LoadingIndicator />
isFetching ? <LoadingIndicator /> : null
}
{
!isFetching && !!error &&
<div className={styles.blankpad}>
!isFetching && error ?
<div>
{translate('UnableToLoadResultsIntSearch')}
</div>
</div> :
null
}
{
!isFetching && isPopulated && !totalReleasesCount &&
<div className={styles.blankpad}>
!isFetching && isPopulated && !totalReleasesCount ?
<div>
{translate('NoResultsFound')}
</div>
</div> :
null
}
{
!!totalReleasesCount && isPopulated && !items.length &&
<div className={styles.blankpad}>
!!totalReleasesCount && isPopulated && !items.length ?
<div>
{translate('AllResultsHiddenFilter')}
</div>
</div> :
null
}
{
isPopulated && !!items.length &&
isPopulated && !!items.length ?
<Table
columns={columns}
sortKey={sortKey}
@ -170,32 +192,38 @@ function InteractiveSearchContent(props) {
})
}
</TableBody>
</Table>
</Table> :
null
}
{
totalReleasesCount !== items.length && !!items.length &&
totalReleasesCount !== items.length && !!items.length ?
<div className={styles.filteredMessage}>
{translate('SomeResultsHiddenFilter')}
</div>
</div> :
null
}
</div>
);
}
InteractiveSearchContent.propTypes = {
InteractiveSearch.propTypes = {
searchPayload: PropTypes.object.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
totalReleasesCount: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.string,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onSortPress: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired
};
export default InteractiveSearchContent;
export default InteractiveSearch;

View file

@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
import * as releaseActions from 'Store/Actions/releaseActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import InteractiveSearchContent from './InteractiveSearchContent';
import InteractiveSearch from './InteractiveSearch';
function createMapStateToProps(appState) {
return createSelector(
@ -48,7 +48,7 @@ function createMapDispatchToProps(dispatch, props) {
};
}
class InteractiveSearchContentConnector extends Component {
class InteractiveSearchConnector extends Component {
//
// Lifecycle
@ -79,18 +79,18 @@ class InteractiveSearchContentConnector extends Component {
return (
<InteractiveSearchContent
<InteractiveSearch
{...otherProps}
/>
);
}
}
InteractiveSearchContentConnector.propTypes = {
InteractiveSearchConnector.propTypes = {
searchPayload: PropTypes.object.isRequired,
isPopulated: PropTypes.bool.isRequired,
dispatchFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchContentConnector);
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);

View file

@ -1,15 +1,20 @@
.cell {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
}
.protocol {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 80px;
}
.title {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
display: flex;
align-items: center;
justify-content: space-between;
word-break: break-all;
}
.indexer {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 85px;
}
@ -17,7 +22,9 @@
.quality,
.customFormat,
.language {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
text-align: center;
}
.language {
@ -25,7 +32,7 @@
}
.customFormatScore {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 55px;
font-weight: bold;
@ -35,34 +42,26 @@
.rejected,
.indexerFlags,
.download {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 50px;
}
.age,
.size {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
white-space: nowrap;
}
.peers {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 75px;
}
.title {
composes: cell;
}
.title div {
overflow-wrap: break-word;
}
.history {
composes: cell;
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 75px;
}

View file

@ -145,46 +145,6 @@ class InteractiveSearchRow extends Component {
{formatAge(age, ageHours, ageMinutes)}
</TableRowCell>
<TableRowCell className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
/>
</TableRowCell>
<TableRowCell className={styles.rejected}>
{
!!rejections.length &&
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title={translate('ReleaseRejected')}
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection}
</li>
);
})
}
</ul>
}
position={tooltipPositions.BOTTOM}
/>
}
</TableRowCell>
<TableRowCell className={styles.title}>
<Link
to={infoUrl}
@ -297,6 +257,46 @@ class InteractiveSearchRow extends Component {
}
</TableRowCell>
<TableRowCell className={styles.rejected}>
{
!!rejections.length &&
<Popover
anchor={
<Icon
name={icons.DANGER}
kind={kinds.DANGER}
/>
}
title={translate('ReleaseRejected')}
body={
<ul>
{
rejections.map((rejection, index) => {
return (
<li key={index}>
{rejection}
</li>
);
})
}
</ul>
}
position={tooltipPositions.LEFT}
/>
}
</TableRowCell>
<TableRowCell className={styles.download}>
<SpinnerIconButton
name={getDownloadIcon(isGrabbing, isGrabbed, grabError)}
kind={grabError ? kinds.DANGER : kinds.DEFAULT}
title={getDownloadTooltip(isGrabbing, isGrabbed, grabError)}
isDisabled={isGrabbed}
isSpinning={isGrabbing}
onPress={downloadAllowed ? this.onGrabPress : this.onConfirmGrabPress}
/>
</TableRowCell>
<ConfirmModal
isOpen={this.state.isConfirmGrabModalOpen}
kind={kinds.WARNING}

View file

@ -1,16 +0,0 @@
import React from 'react';
import InteractiveSearchContentConnector from './InteractiveSearchContentConnector';
function InteractiveSearchTable(props) {
return (
<InteractiveSearchContentConnector
searchPayload={props}
/>
);
}
InteractiveSearchTable.propTypes = {
};
export default InteractiveSearchTable;

View file

@ -69,7 +69,8 @@ class MovieCastPoster extends Component {
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
height: `${posterHeight}px`,
borderRadius: '5px'
};
const contentStyle = {

View file

@ -69,7 +69,8 @@ class MovieCrewPoster extends Component {
const elementStyle = {
width: `${posterWidth}px`,
height: `${posterHeight}px`
height: `${posterHeight}px`,
borderRadius: '5px'
};
const contentStyle = {

View file

@ -1,6 +1,7 @@
$hoverScale: 1.05;
.content {
border-radius: 5px;
transition: all 200ms ease-in;
&:hover {

View file

@ -2,6 +2,10 @@
flex: 1 0 auto;
}
.movie {
padding: 10px;
}
.container {
padding: 10px;
}

View file

@ -1,12 +1,16 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Grid, WindowScroller } from 'react-virtualized';
import Measure from 'Components/Measure';
import { Navigation } from 'swiper';
import { Swiper, SwiperSlide } from 'swiper/react';
import dimensions from 'Styles/Variables/dimensions';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import MovieCreditPosterConnector from './MovieCreditPosterConnector';
import styles from './MovieCreditPosters.css';
// Import Swiper styles
import 'swiper/css';
import 'swiper/css/navigation';
// Poster container dimensions
const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
@ -169,56 +173,50 @@ class MovieCreditPosters extends Component {
render() {
const {
items
items,
itemComponent
} = this.props;
const {
width,
columnWidth,
columnCount,
rowHeight
posterWidth,
posterHeight
} = this.state;
const rowCount = Math.ceil(items.length / columnCount);
return (
<Measure
whitelist={['width']}
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={undefined}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
<div className={styles.sliderContainer}>
<Swiper
slidesPerView='auto'
spaceBetween={10}
slidesPerGroup={3}
loop={false}
loopFillGroupWithBlank={true}
className="mySwiper"
modules={[Navigation]}
onInit={(swiper) => {
swiper.params.navigation.prevEl = this._swiperPrevRef;
swiper.params.navigation.nextEl = this._swiperNextRef;
swiper.navigation.init();
swiper.navigation.update();
}}
>
{items.map((credit) => (
<SwiperSlide key={credit.tmdbId} style={{ width: posterWidth }}>
<MovieCreditPosterConnector
key={credit.order}
component={itemComponent}
posterWidth={posterWidth}
posterHeight={posterHeight}
tmdbId={credit.personTmdbId}
personName={credit.personName}
job={credit.job}
character={credit.character}
images={credit.images}
/>
</SwiperSlide>
))}
</Swiper>
</div>
);
}
}

View file

@ -1,3 +0,0 @@
.alternateTitle {
white-space: nowrap;
}

View file

@ -1,28 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './MovieAlternateTitles.css';
function MovieAlternateTitles({ alternateTitles }) {
return (
<ul>
{
alternateTitles.filter((x, i, a) => a.indexOf(x) === i).map((alternateTitle) => {
return (
<li
key={alternateTitle}
className={styles.alternateTitle}
>
{alternateTitle}
</li>
);
})
}
</ul>
);
}
MovieAlternateTitles.propTypes = {
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default MovieAlternateTitles;

View file

@ -5,7 +5,7 @@
.header {
position: relative;
width: 100%;
height: 375px;
height: 425px;
}
.errorMessage {
@ -39,10 +39,11 @@
}
.poster {
z-index: 2;
flex-shrink: 0;
margin-right: 35px;
width: 217px;
height: 319px;
width: 250px;
height: 368px;
}
.info {

View file

@ -1,8 +1,8 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import TextTruncate from 'react-text-truncate';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import InfoLabel from 'Components/InfoLabel';
@ -22,12 +22,11 @@ import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import InteractiveSearchFilterMenuConnector from 'InteractiveSearch/InteractiveSearchFilterMenuConnector';
import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import EditMovieModalConnector from 'Movie/Edit/EditMovieModalConnector';
import MovieHistoryTable from 'Movie/History/MovieHistoryTable';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModalConnector from 'Movie/Search/MovieInteractiveSearchModalConnector';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
@ -81,10 +80,10 @@ class MovieDetails extends Component {
isEditMovieModalOpen: false,
isDeleteMovieModalOpen: false,
isInteractiveImportModalOpen: false,
isInteractiveSearchModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {},
selectedTabIndex: 0,
overviewHeight: 0,
titleWidth: 0
};
@ -137,6 +136,14 @@ class MovieDetails extends Component {
this.setState({ isEditMovieModalOpen: false });
};
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
};
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
};
onDeleteMoviePress = () => {
this.setState({
isEditMovieModalOpen: false,
@ -298,9 +305,9 @@ class MovieDetails extends Component {
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isInteractiveImportModalOpen,
isInteractiveSearchModalOpen,
overviewHeight,
titleWidth,
selectedTabIndex
titleWidth
} = this.state;
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
@ -326,6 +333,14 @@ class MovieDetails extends Component {
onPress={onSearchPress}
/>
<PageToolbarButton
label={translate('InteractiveSearch')}
iconName={icons.INTERACTIVE}
isSpinning={isSearching}
title={undefined}
onPress={this.onInteractiveSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
@ -651,101 +666,39 @@ class MovieDetails extends Component {
</div>
}
<Tabs selectedIndex={this.state.tabIndex} onSelect={this.onTabSelect}>
<TabList
className={styles.tabList}
>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('History')}
</Tab>
<FieldSet legend={translate('History')}>
<MovieHistoryTable
movieId={id}
/>
</FieldSet>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Search')}
</Tab>
<FieldSet legend={translate('Files')}>
<MovieFileEditorTable
movieId={id}
/>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Files')}
</Tab>
<ExtraFileTable
movieId={id}
/>
</FieldSet>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Titles')}
</Tab>
<FieldSet legend={translate('Cast')}>
<MovieCastPostersConnector
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Cast')}
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
{translate('Crew')}
</Tab>
{
selectedTabIndex === 1 &&
<div className={styles.filterIcon}>
<InteractiveSearchFilterMenuConnector />
</div>
}
</TabList>
<TabPanel>
<MovieHistoryTable
movieId={id}
/>
</TabPanel>
<TabPanel>
<InteractiveSearchTable
movieId={id}
/>
</TabPanel>
<TabPanel>
<MovieFileEditorTable
movieId={id}
/>
<ExtraFileTable
movieId={id}
/>
</TabPanel>
<TabPanel>
<MovieTitlesTable
movieId={id}
/>
</TabPanel>
<TabPanel>
<MovieCastPostersConnector
isSmallScreen={isSmallScreen}
/>
</TabPanel>
<TabPanel>
<MovieCrewPostersConnector
isSmallScreen={isSmallScreen}
/>
</TabPanel>
</Tabs>
<FieldSet legend={translate('Crew')}>
<MovieCrewPostersConnector
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<FieldSet legend={translate('TitlesAndTranslations')}>
<MovieTitlesTable
movieId={id}
/>
</FieldSet>
</div>
<OrganizePreviewModalConnector
@ -777,6 +730,12 @@ class MovieDetails extends Component {
showImportMode={false}
onModalClose={this.onInteractiveImportModalClose}
/>
<MovieInteractiveSearchModalConnector
isOpen={isInteractiveSearchModalOpen}
movieId={id}
onModalClose={this.onInteractiveSearchModalClose}
/>
</PageContentBody>
</PageContent>
);

View file

@ -43,7 +43,7 @@ class MovieTitlesRow extends Component {
}
MovieTitlesRow.propTypes = {
id: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
language: PropTypes.object.isRequired,
sourceType: PropTypes.string.isRequired

View file

@ -0,0 +1,9 @@
.container {
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);
&:last-of-type {
margin-bottom: 0;
}
}

View file

@ -1,5 +1,6 @@
import React from 'react';
import MovieTitlesTableContentConnector from './MovieTitlesTableContentConnector';
import styles from './MovieTitlesTable.css';
function MovieTitlesTable(props) {
const {
@ -7,9 +8,11 @@ function MovieTitlesTable(props) {
} = props;
return (
<MovieTitlesTableContentConnector
{...otherProps}
/>
<div className={styles.container}>
<MovieTitlesTableContentConnector
{...otherProps}
/>
</div>
);
}

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import translate from 'Utilities/String/translate';
@ -10,7 +9,7 @@ import styles from './MovieTitlesTableContent.css';
const columns = [
{
name: 'altTitle',
label: translate('AlternativeTitle'),
label: translate('Title'),
isVisible: true
},
{
@ -32,40 +31,25 @@ class MovieTitlesTableContent extends Component {
render() {
const {
isFetching,
isPopulated,
error,
items
titles
} = this.props;
const hasItems = !!items.length;
const hasItems = !!titles.length;
return (
<div>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div className={styles.blankpad}>
{translate('UnableToLoadAltTitle')}
</div>
}
{
isPopulated && !hasItems && !error &&
!hasItems &&
<div className={styles.blankpad}>
{translate('NoAltTitle')}
</div>
}
{
isPopulated && hasItems && !error &&
hasItems &&
<Table columns={columns}>
<TableBody>
{
items.reverse().map((item) => {
titles.reverse().map((item) => {
return (
<MovieTitlesRow
key={item.id}
@ -83,10 +67,7 @@ class MovieTitlesTableContent extends Component {
}
MovieTitlesTableContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired
titles: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default MovieTitlesTableContent;

View file

@ -2,13 +2,40 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import MovieTitlesTableContent from './MovieTitlesTableContent';
function createMapStateToProps() {
return createSelector(
(state) => state.movies,
(movies) => {
return movies;
createMovieSelector(),
(movie) => {
let titles = [];
if (movie.alternateTitles) {
titles = movie.alternateTitles.map((title) => {
return {
id: `title_${title.id}`,
title: title.title,
language: title.language || 'Unknown',
sourceType: 'Alternative Title'
};
});
}
if (movie.translations) {
titles = titles.concat(movie.translations.map((title) => {
return {
id: `translation_${title.id}`,
title: title.title,
language: title.language || 'Unknown',
sourceType: 'Translation'
};
}));
}
return {
titles
};
}
);
}
@ -23,14 +50,14 @@ class MovieTitlesTableContentConnector extends Component {
// Render
render() {
const movie = this.props.items.filter((obj) => {
return obj.id === this.props.movieId;
});
const {
titles
} = this.props;
return (
<MovieTitlesTableContent
{...this.props}
items={movie[0].alternateTitles}
titles={titles}
/>
);
}
@ -38,7 +65,7 @@ class MovieTitlesTableContentConnector extends Component {
MovieTitlesTableContentConnector.propTypes = {
movieId: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired
titles: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieTitlesTableContentConnector);

View file

@ -0,0 +1,9 @@
.container {
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);
&:last-of-type {
margin-bottom: 0;
}
}

View file

@ -1,5 +1,6 @@
import React from 'react';
import MovieHistoryTableContentConnector from './MovieHistoryTableContentConnector';
import styles from './MovieHistoryTable.css';
function MovieHistoryTable(props) {
const {
@ -7,9 +8,11 @@ function MovieHistoryTable(props) {
} = props;
return (
<MovieHistoryTableContentConnector
{...otherProps}
/>
<div className={styles.container}>
<MovieHistoryTableContentConnector
{...otherProps}
/>
</div>
);
}

View file

@ -0,0 +1,35 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import MovieInteractiveSearchModalContent from './MovieInteractiveSearchModalContent';
function MovieInteractiveSearchModal(props) {
const {
isOpen,
movieId,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
closeOnBackgroundClick={false}
onModalClose={onModalClose}
size={sizes.EXTRA_LARGE}
>
<MovieInteractiveSearchModalContent
movieId={movieId}
onModalClose={onModalClose}
/>
</Modal>
);
}
MovieInteractiveSearchModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
movieId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default MovieInteractiveSearchModal;

View file

@ -0,0 +1,15 @@
import { connect } from 'react-redux';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import MovieInteractiveSearchModal from './MovieInteractiveSearchModal';
function createMapDispatchToProps(dispatch, props) {
return {
onModalClose() {
dispatch(cancelFetchReleases());
dispatch(clearReleases());
props.onModalClose();
}
};
}
export default connect(null, createMapDispatchToProps)(MovieInteractiveSearchModal);

View file

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { scrollDirections } from 'Helpers/Props';
import InteractiveSearchConnector from 'InteractiveSearch/InteractiveSearchConnector';
function MovieInteractiveSearchModalContent(props) {
const {
movieId,
onModalClose
} = props;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Interactive Search
</ModalHeader>
<ModalBody scrollDirection={scrollDirections.BOTH}>
<InteractiveSearchConnector
searchPayload={{
movieId
}}
/>
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
MovieInteractiveSearchModalContent.propTypes = {
movieId: PropTypes.number.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default MovieInteractiveSearchModalContent;

View file

@ -1,5 +1,4 @@
.container {
margin-top: 20px;
border: 1px solid var(--borderColor);
border-radius: 4px;
background-color: var(--inputBackgroundColor);

View file

@ -1,32 +0,0 @@
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Movies.AlternativeTitles;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.MovieTests.AlternativeTitleServiceTests
{
[TestFixture]
public class AlternativeTitleFixture : CoreTest
{
private AlternativeTitle CreateFakeTitle(SourceType source, int votes)
{
return Builder<AlternativeTitle>.CreateNew().With(t => t.SourceType = source).With(t => t.Votes = votes)
.Build();
}
[TestCase(SourceType.TMDB, -1, true)]
[TestCase(SourceType.TMDB, 1000, true)]
[TestCase(SourceType.Mappings, 0, false)]
[TestCase(SourceType.Mappings, 4, true)]
[TestCase(SourceType.Mappings, -1, false)]
[TestCase(SourceType.Indexer, 0, true)]
[TestCase(SourceType.User, 0, true)]
public void should_be_trusted(SourceType source, int votes, bool trusted)
{
var fakeTitle = CreateFakeTitle(source, votes);
fakeTitle.IsTrusted().Should().Be(trusted);
}
}
}

View file

@ -0,0 +1,17 @@
using FluentMigrator;
using NzbDrone.Core.Datastore.Migration.Framework;
namespace NzbDrone.Core.Datastore.Migration
{
[Migration(216)]
public class clean_alt_titles : NzbDroneMigrationBase
{
protected override void MainDbUpgrade()
{
Delete.Column("SourceType").FromTable("AlternativeTitles");
Delete.Column("Votes").FromTable("AlternativeTitles");
Delete.Column("VoteCount").FromTable("AlternativeTitles");
Delete.Column("SourceId").FromTable("AlternativeTitles");
}
}
}

View file

@ -1028,6 +1028,7 @@
"Timeleft": "Time Left",
"Title": "Title",
"Titles": "Titles",
"TitlesAndTranslations": "Titles and Translations",
"TMDb": "TMDb",
"TMDBId": "TMDb Id",
"TmdbIdHelpText": "The TMDb Id of the movie to exclude",

View file

@ -570,7 +570,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook
var newAlternativeTitle = new AlternativeTitle
{
Title = arg.Title,
SourceType = SourceType.TMDB,
CleanTitle = arg.Title.CleanMovieTitle(),
Language = IsoLanguages.Find(arg.Language.ToLower())?.Language ?? Language.English
};

View file

@ -6,39 +6,22 @@ namespace NzbDrone.Core.Movies.AlternativeTitles
{
public class AlternativeTitle : ModelBase
{
public SourceType SourceType { get; set; }
public int MovieMetadataId { get; set; }
public string Title { get; set; }
public string CleanTitle { get; set; }
public int SourceId { get; set; }
public int Votes { get; set; }
public int VoteCount { get; set; }
public Language Language { get; set; }
public AlternativeTitle()
{
}
public AlternativeTitle(string title, SourceType sourceType = SourceType.TMDB, int sourceId = 0, Language language = null)
public AlternativeTitle(string title, int sourceId = 0, Language language = null)
{
Title = title;
CleanTitle = title.CleanMovieTitle();
SourceType = sourceType;
SourceId = sourceId;
Language = language ?? Language.English;
}
public bool IsTrusted(int minVotes = 4)
{
switch (SourceType)
{
case SourceType.Mappings:
return Votes >= minVotes;
default:
return true;
}
}
public override bool Equals(object obj)
{
var item = obj as AlternativeTitle;
@ -61,18 +44,4 @@ namespace NzbDrone.Core.Movies.AlternativeTitles
return Title;
}
}
public enum SourceType
{
TMDB = 0,
Mappings = 1,
User = 2,
Indexer = 3
}
public class AlternativeYear
{
public int Year { get; set; }
public int SourceId { get; set; }
}
}

View file

@ -7,8 +7,6 @@ namespace NzbDrone.Core.Movies.AlternativeTitles
{
public interface IAlternativeTitleRepository : IBasicRepository<AlternativeTitle>
{
AlternativeTitle FindBySourceId(int sourceId);
List<AlternativeTitle> FindBySourceIds(List<int> sourceIds);
List<AlternativeTitle> FindByMovieMetadataId(int movieId);
void DeleteForMovies(List<int> movieIds);
}
@ -20,16 +18,6 @@ namespace NzbDrone.Core.Movies.AlternativeTitles
{
}
public AlternativeTitle FindBySourceId(int sourceId)
{
return Query(x => x.SourceId == sourceId).FirstOrDefault();
}
public List<AlternativeTitle> FindBySourceIds(List<int> sourceIds)
{
return Query(x => sourceIds.Contains(x.SourceId));
}
public List<AlternativeTitle> FindByMovieMetadataId(int movieId)
{
return Query(x => x.MovieMetadataId == movieId);

View file

@ -35,13 +35,16 @@ namespace NzbDrone.Core.Movies
public class MovieRepository : BasicRepository<Movie>, IMovieRepository
{
private readonly IAlternativeTitleRepository _alternativeTitleRepository;
private readonly IMovieTranslationRepository _movieTranslationRepository;
public MovieRepository(IMainDatabase database,
IAlternativeTitleRepository alternativeTitleRepository,
IMovieTranslationRepository movieTranslationRepository,
IEventAggregator eventAggregator)
: base(database, eventAggregator)
{
_alternativeTitleRepository = alternativeTitleRepository;
_movieTranslationRepository = movieTranslationRepository;
}
protected override SqlBuilder Builder() => new SqlBuilder(_database.DatabaseType)
@ -94,6 +97,10 @@ namespace NzbDrone.Core.Movies
.GroupBy(x => x.MovieMetadataId)
.ToDictionary(x => x.Key, y => y.ToList());
var translations = _movieTranslationRepository.All()
.GroupBy(x => x.MovieMetadataId)
.ToDictionary(x => x.Key, y => y.ToList());
return _database.QueryJoined<Movie, MovieMetadata>(
builder,
(movie, metadata) =>
@ -105,6 +112,11 @@ namespace NzbDrone.Core.Movies
movie.MovieMetadata.Value.AlternativeTitles = altTitles;
}
if (translations.TryGetValue(movie.MovieMetadataId, out var trans))
{
movie.MovieMetadata.Value.Translations = trans;
}
return movie;
});
}

View file

@ -15,13 +15,9 @@ namespace Radarr.Api.V3.Movies
// Todo: Sorters should be done completely on the client
// Todo: Is there an easy way to keep IgnoreArticlesWhenSorting in sync between, Series, History, Missing?
// Todo: We should get the entire Profile instead of ID and Name separately
public SourceType SourceType { get; set; }
public int MovieMetadataId { get; set; }
public string Title { get; set; }
public string CleanTitle { get; set; }
public int SourceId { get; set; }
public int Votes { get; set; }
public int VoteCount { get; set; }
public Language Language { get; set; }
// TODO: Add series statistics as a property of the series (instead of individual properties)
@ -39,12 +35,8 @@ namespace Radarr.Api.V3.Movies
return new AlternativeTitleResource
{
Id = model.Id,
SourceType = model.SourceType,
MovieMetadataId = model.MovieMetadataId,
Title = model.Title,
SourceId = model.SourceId,
Votes = model.Votes,
VoteCount = model.VoteCount,
Language = model.Language
};
}
@ -59,12 +51,8 @@ namespace Radarr.Api.V3.Movies
return new AlternativeTitle
{
Id = resource.Id,
SourceType = resource.SourceType,
MovieMetadataId = resource.MovieMetadataId,
Title = resource.Title,
SourceId = resource.SourceId,
Votes = resource.Votes,
VoteCount = resource.VoteCount,
Language = resource.Language
};
}

View file

@ -18,9 +18,6 @@ namespace Radarr.Api.V4.Movies
public int MovieMetadataId { get; set; }
public string Title { get; set; }
public string CleanTitle { get; set; }
public int SourceId { get; set; }
public int Votes { get; set; }
public int VoteCount { get; set; }
public Language Language { get; set; }
// TODO: Add series statistics as a property of the series (instead of individual properties)
@ -38,12 +35,8 @@ namespace Radarr.Api.V4.Movies
return new AlternativeTitleResource
{
Id = model.Id,
SourceType = model.SourceType,
MovieMetadataId = model.MovieMetadataId,
Title = model.Title,
SourceId = model.SourceId,
Votes = model.Votes,
VoteCount = model.VoteCount,
Language = model.Language
};
}
@ -58,12 +51,8 @@ namespace Radarr.Api.V4.Movies
return new AlternativeTitle
{
Id = resource.Id,
SourceType = resource.SourceType,
MovieMetadataId = resource.MovieMetadataId,
Title = resource.Title,
SourceId = resource.SourceId,
Votes = resource.Votes,
VoteCount = resource.VoteCount,
Language = resource.Language
};
}

View file

@ -30,6 +30,7 @@ namespace Radarr.Api.V4.Movies
public string OriginalTitle { get; set; }
public Language OriginalLanguage { get; set; }
public List<AlternativeTitleResource> AlternateTitles { get; set; }
public List<MovieTranslationResource> Translations { get; set; }
public int? SecondaryYear { get; set; }
public int SecondaryYearSourceId { get; set; }
public string SortTitle { get; set; }
@ -135,6 +136,7 @@ namespace Radarr.Api.V4.Movies
Added = model.Added,
AddOptions = model.AddOptions,
AlternateTitles = model.MovieMetadata.Value.AlternativeTitles.ToResource(),
Translations = model.MovieMetadata.Value.Translations.ToResource(),
Ratings = model.MovieMetadata.Value.Ratings,
YouTubeTrailerId = model.MovieMetadata.Value.YouTubeTrailerId,
Studio = model.MovieMetadata.Value.Studio,

View file

@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Movies.Translations;
using Radarr.Http.REST;
namespace Radarr.Api.V4.Movies
{
public class MovieTranslationResource : RestResource
{
public int MovieMetadataId { get; set; }
public string Title { get; set; }
public string CleanTitle { get; set; }
public Language Language { get; set; }
}
public static class MovieTranslationResourceMapper
{
public static MovieTranslationResource ToResource(this MovieTranslation model)
{
if (model == null)
{
return null;
}
return new MovieTranslationResource
{
Id = model.Id,
MovieMetadataId = model.MovieMetadataId,
Title = model.Title,
Language = model.Language
};
}
public static MovieTranslation ToModel(this MovieTranslationResource resource)
{
if (resource == null)
{
return null;
}
return new MovieTranslation
{
Id = resource.Id,
MovieMetadataId = resource.MovieMetadataId,
Title = resource.Title,
Language = resource.Language
};
}
public static List<MovieTranslationResource> ToResource(this IEnumerable<MovieTranslation> movies)
{
return movies.Select(ToResource).ToList();
}
}
}