mirror of
https://github.com/Radarr/Radarr.git
synced 2025-04-23 22:17:15 -04:00
New: Rework Movie Details view
This commit is contained in:
parent
757cb9a956
commit
1d4db26f17
37 changed files with 494 additions and 434 deletions
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
import React from 'react';
|
||||
import InteractiveSearchContentConnector from './InteractiveSearchContentConnector';
|
||||
|
||||
function InteractiveSearchTable(props) {
|
||||
|
||||
return (
|
||||
<InteractiveSearchContentConnector
|
||||
searchPayload={props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
InteractiveSearchTable.propTypes = {
|
||||
};
|
||||
|
||||
export default InteractiveSearchTable;
|
|
@ -69,7 +69,8 @@ class MovieCastPoster extends Component {
|
|||
|
||||
const elementStyle = {
|
||||
width: `${posterWidth}px`,
|
||||
height: `${posterHeight}px`
|
||||
height: `${posterHeight}px`,
|
||||
borderRadius: '5px'
|
||||
};
|
||||
|
||||
const contentStyle = {
|
||||
|
|
|
@ -69,7 +69,8 @@ class MovieCrewPoster extends Component {
|
|||
|
||||
const elementStyle = {
|
||||
width: `${posterWidth}px`,
|
||||
height: `${posterHeight}px`
|
||||
height: `${posterHeight}px`,
|
||||
borderRadius: '5px'
|
||||
};
|
||||
|
||||
const contentStyle = {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
$hoverScale: 1.05;
|
||||
|
||||
.content {
|
||||
border-radius: 5px;
|
||||
transition: all 200ms ease-in;
|
||||
|
||||
&:hover {
|
||||
|
|
|
@ -2,6 +2,10 @@
|
|||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.movie {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
.alternateTitle {
|
||||
white-space: nowrap;
|
||||
}
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
9
frontend/src/Movie/Details/Titles/MovieTitlesTable.css
Normal file
9
frontend/src/Movie/Details/Titles/MovieTitlesTable.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.container {
|
||||
border: 1px solid var(--borderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
9
frontend/src/Movie/History/MovieHistoryTable.css
Normal file
9
frontend/src/Movie/History/MovieHistoryTable.css
Normal file
|
@ -0,0 +1,9 @@
|
|||
.container {
|
||||
border: 1px solid var(--borderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
35
frontend/src/Movie/Search/MovieInteractiveSearchModal.js
Normal file
35
frontend/src/Movie/Search/MovieInteractiveSearchModal.js
Normal 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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -1,5 +1,4 @@
|
|||
.container {
|
||||
margin-top: 20px;
|
||||
border: 1px solid var(--borderColor);
|
||||
border-radius: 4px;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
56
src/Radarr.Api.V4/Movies/MovieTranslationResource.cs
Normal file
56
src/Radarr.Api.V4/Movies/MovieTranslationResource.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue