diff --git a/frontend/src/Movie/Details/MovieDetails.js b/frontend/src/Movie/Details/MovieDetails.js index 2956d9e55..c753000e2 100644 --- a/frontend/src/Movie/Details/MovieDetails.js +++ b/frontend/src/Movie/Details/MovieDetails.js @@ -25,6 +25,7 @@ import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; import Popover from 'Components/Tooltip/Popover'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable'; +import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; import MoviePoster from 'Movie/MoviePoster'; @@ -182,6 +183,7 @@ class MovieDetails extends Component { isSmallScreen, movieFilesError, movieCreditsError, + extraFilesError, hasMovieFiles, previousMovie, nextMovie, @@ -457,15 +459,25 @@ class MovieDetails extends Component {
{ - !isPopulated && !movieFilesError && !movieCreditsError && + !isPopulated && !movieFilesError && !movieCreditsError && !extraFilesError && } { - !isFetching && movieFilesError && !movieCreditsError && + !isFetching && movieFilesError &&
Loading movie files failed
} + { + !isFetching && movieCreditsError && +
Loading movie credits failed
+ } + + { + !isFetching && extraFilesError && +
Loading movie extra files failed
+ } + this.setState({ selectedTabIndex: tabIndex })}> + @@ -623,6 +638,7 @@ MovieDetails.propTypes = { isSmallScreen: PropTypes.bool.isRequired, movieFilesError: PropTypes.object, movieCreditsError: PropTypes.object, + extraFilesError: PropTypes.object, hasMovieFiles: PropTypes.bool.isRequired, previousMovie: PropTypes.object.isRequired, nextMovie: PropTypes.object.isRequired, diff --git a/frontend/src/Movie/Details/MovieDetailsConnector.js b/frontend/src/Movie/Details/MovieDetailsConnector.js index 7584531ca..b656a0a02 100644 --- a/frontend/src/Movie/Details/MovieDetailsConnector.js +++ b/frontend/src/Movie/Details/MovieDetailsConnector.js @@ -9,6 +9,7 @@ import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import { fetchMovieFiles, clearMovieFiles } from 'Store/Actions/movieFileActions'; +import { fetchExtraFiles, clearExtraFiles } from 'Store/Actions/extraFileActions'; import { fetchMovieCredits, clearMovieCredits } from 'Store/Actions/movieCreditsActions'; import { toggleMovieMonitored } from 'Store/Actions/movieActions'; import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions'; @@ -59,15 +60,33 @@ const selectMovieCredits = createSelector( } ); +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(), - (titleSlug, movieFiles, movieCredits, allMovies, commands, dimensions) => { + (titleSlug, movieFiles, movieCredits, extraFiles, allMovies, commands, dimensions) => { const sortedMovies = _.orderBy(allMovies, 'sortTitle'); const movieIndex = _.findIndex(sortedMovies, { titleSlug }); const movie = sortedMovies[movieIndex]; @@ -90,6 +109,12 @@ function createMapStateToProps() { 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, movieId: movie.id })); @@ -107,8 +132,8 @@ function createMapStateToProps() { isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1 ); - const isFetching = isMovieFilesFetching && isMovieCreditsFetching; - const isPopulated = isMovieFilesPopulated && isMovieCreditsPopulated; + const isFetching = isMovieFilesFetching || isMovieCreditsFetching || isExtraFilesFetching; + const isPopulated = isMovieFilesPopulated && isMovieCreditsPopulated && isExtraFilesPopulated; const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => { acc.push(alternateTitle.title); return acc; @@ -127,6 +152,7 @@ function createMapStateToProps() { isPopulated, movieFilesError, movieCreditsError, + extraFilesError, hasMovieFiles, sizeOnDisk, previousMovie, @@ -142,6 +168,8 @@ const mapDispatchToProps = { clearMovieFiles, fetchMovieCredits, clearMovieCredits, + fetchExtraFiles, + clearExtraFiles, clearReleases, cancelFetchReleases, fetchNetImportSchema, @@ -200,6 +228,7 @@ class MovieDetailsConnector extends Component { const movieId = this.props.id; this.props.fetchMovieFiles({ movieId }); + this.props.fetchExtraFiles({ movieId }); this.props.fetchMovieCredits({ movieId }); this.props.fetchQueueDetails({ movieId }); this.props.fetchNetImportSchema(); @@ -208,6 +237,7 @@ class MovieDetailsConnector extends Component { unpopulate = () => { this.props.cancelFetchReleases(); this.props.clearMovieFiles(); + this.props.clearExtraFiles(); this.props.clearMovieCredits(); this.props.clearQueueDetails(); this.props.clearReleases(); @@ -263,6 +293,8 @@ MovieDetailsConnector.propTypes = { isSmallScreen: PropTypes.bool.isRequired, fetchMovieFiles: PropTypes.func.isRequired, clearMovieFiles: PropTypes.func.isRequired, + fetchExtraFiles: PropTypes.func.isRequired, + clearExtraFiles: PropTypes.func.isRequired, fetchMovieCredits: PropTypes.func.isRequired, clearMovieCredits: PropTypes.func.isRequired, clearReleases: PropTypes.func.isRequired, diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorTable.css b/frontend/src/MovieFile/Editor/MovieFileEditorTable.css new file mode 100644 index 000000000..50d1a9e93 --- /dev/null +++ b/frontend/src/MovieFile/Editor/MovieFileEditorTable.css @@ -0,0 +1,10 @@ +.container { + margin-top: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} diff --git a/frontend/src/MovieFile/Editor/MovieFileEditorTable.js b/frontend/src/MovieFile/Editor/MovieFileEditorTable.js index 6d3d37393..5c71c631a 100644 --- a/frontend/src/MovieFile/Editor/MovieFileEditorTable.js +++ b/frontend/src/MovieFile/Editor/MovieFileEditorTable.js @@ -1,6 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import MovieFileEditorTableContentConnector from './MovieFileEditorTableContentConnector'; +import styles from './MovieFileEditorTable.css'; function MovieFileEditorTable(props) { const { @@ -8,9 +9,11 @@ function MovieFileEditorTable(props) { } = props; return ( - +
+ +
); } diff --git a/frontend/src/MovieFile/Extras/ExtraFileRow.css b/frontend/src/MovieFile/Extras/ExtraFileRow.css new file mode 100644 index 000000000..fcf05d5a5 --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileRow.css @@ -0,0 +1,10 @@ +.relativePath { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} + +.extension, +.type { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; +} diff --git a/frontend/src/MovieFile/Extras/ExtraFileRow.js b/frontend/src/MovieFile/Extras/ExtraFileRow.js new file mode 100644 index 000000000..cc626b3e7 --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileRow.js @@ -0,0 +1,63 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import IconButton from 'Components/Link/IconButton'; +import { icons } from 'Helpers/Props'; +import titleCase from 'Utilities/String/titleCase'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import styles from './ExtraFileRow.css'; + +class ExtraFileRow extends Component { + + // + // Render + + render() { + const { + relativePath, + extension, + type + } = this.props; + + return ( + + + {relativePath} + + + + {extension} + + + + {titleCase(type)} + + + + + + + ); + } + +} + +ExtraFileRow.propTypes = { + id: PropTypes.number.isRequired, + extension: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + relativePath: PropTypes.string.isRequired +}; + +export default ExtraFileRow; diff --git a/frontend/src/MovieFile/Extras/ExtraFileTable.css b/frontend/src/MovieFile/Extras/ExtraFileTable.css new file mode 100644 index 000000000..50d1a9e93 --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileTable.css @@ -0,0 +1,10 @@ +.container { + margin-top: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} diff --git a/frontend/src/MovieFile/Extras/ExtraFileTable.js b/frontend/src/MovieFile/Extras/ExtraFileTable.js new file mode 100644 index 000000000..46381c26f --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileTable.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ExtraFileTableContentConnector from './ExtraFileTableContentConnector'; +import styles from './ExtraFileTable.css'; + +function ExtraFileTable(props) { + const { + movieId + } = props; + + return ( +
+ +
+ + ); +} + +ExtraFileTable.propTypes = { + movieId: PropTypes.number.isRequired +}; + +export default ExtraFileTable; diff --git a/frontend/src/MovieFile/Extras/ExtraFileTableContent.css b/frontend/src/MovieFile/Extras/ExtraFileTableContent.css new file mode 100644 index 000000000..2bb243f27 --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileTableContent.css @@ -0,0 +1,10 @@ +.actions { + display: flex; + margin-right: auto; +} + +.blankpad { + padding-top: 10px; + padding-bottom: 10px; + padding-left: 2em; +} diff --git a/frontend/src/MovieFile/Extras/ExtraFileTableContent.js b/frontend/src/MovieFile/Extras/ExtraFileTableContent.js new file mode 100644 index 000000000..ddfc81e6a --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileTableContent.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons } from 'Helpers/Props'; +import IconButton from 'Components/Link/IconButton'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ExtraFileRow from './ExtraFileRow'; +import styles from './ExtraFileTableContent.css'; + +const columns = [ + { + name: 'relativePath', + label: 'Extra File', + isVisible: true + }, + { + name: 'extension', + label: 'Extension', + isVisible: true + }, + { + name: 'type', + label: 'Type', + isVisible: true + }, + { + name: 'action', + label: React.createElement(IconButton, { name: icons.ADVANCED_SETTINGS }), + isVisible: true + } +]; + +class ExtraFileTableContent extends Component { + + // + // Render + + render() { + const { + items + } = this.props; + + return ( +
+ { + !items.length && +
+ No extra files to manage. +
+ } + + { + !!items.length && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } + +
+ ); + } +} + +ExtraFileTableContent.propTypes = { + movieId: PropTypes.number, + items: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default ExtraFileTableContent; diff --git a/frontend/src/MovieFile/Extras/ExtraFileTableContentConnector.js b/frontend/src/MovieFile/Extras/ExtraFileTableContentConnector.js new file mode 100644 index 000000000..38a2720f6 --- /dev/null +++ b/frontend/src/MovieFile/Extras/ExtraFileTableContentConnector.js @@ -0,0 +1,50 @@ +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 ExtraFileTableContent from './ExtraFileTableContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.extraFiles, + createMovieSelector(), + ( + ExtraFiles + ) => { + return { + items: ExtraFiles.items, + error: null + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + }; +} + +class ExtraFileTableContentConnector extends Component { + + // + // Render + + render() { + const { + ...otherProps + } = this.props; + + return ( + + ); + } +} + +ExtraFileTableContentConnector.propTypes = { + movieId: PropTypes.number.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(ExtraFileTableContentConnector); diff --git a/frontend/src/Store/Actions/extraFileActions.js b/frontend/src/Store/Actions/extraFileActions.js new file mode 100644 index 000000000..04d2c728a --- /dev/null +++ b/frontend/src/Store/Actions/extraFileActions.js @@ -0,0 +1,49 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'extraFiles'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_EXTRA_FILES = 'extraFiles/fetchExtraFiles'; +export const CLEAR_EXTRA_FILES = 'extraFiles/clearExtraFiles'; + +// +// Action Creators + +export const fetchExtraFiles = createThunk(FETCH_EXTRA_FILES); +export const clearExtraFiles = createAction(CLEAR_EXTRA_FILES); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EXTRA_FILES]: createFetchHandler(section, '/extraFile') +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_EXTRA_FILES]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 03b67a0bc..03d201680 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -6,6 +6,7 @@ import * as captcha from './captchaActions'; import * as customFilters from './customFilterActions'; import * as commands from './commandActions'; import * as movieFiles from './movieFileActions'; +import * as extraFiles from './extraFileActions'; import * as history from './historyActions'; import * as importMovie from './importMovieActions'; import * as interactiveImportActions from './interactiveImportActions'; @@ -33,6 +34,7 @@ export default [ commands, customFilters, movieFiles, + extraFiles, history, importMovie, interactiveImportActions, diff --git a/src/Radarr.Api.V3/ExtraFiles/ExtraFileModule.cs b/src/Radarr.Api.V3/ExtraFiles/ExtraFileModule.cs new file mode 100644 index 000000000..25407ff3c --- /dev/null +++ b/src/Radarr.Api.V3/ExtraFiles/ExtraFileModule.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.Extras.Metadata.Files; +using NzbDrone.Core.Extras.Others; +using NzbDrone.Core.Extras.Subtitles; +using Radarr.Http; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.ExtraFiles +{ + public class ExtraFileModule : RadarrRestModule + { + private readonly IExtraFileService _subtitleFileService; + private readonly IExtraFileService _metadataFileService; + private readonly IExtraFileService _otherFileService; + + public ExtraFileModule(IExtraFileService subtitleFileService, IExtraFileService metadataFileService, IExtraFileService otherExtraFileService) + : base("/extrafile") + { + _subtitleFileService = subtitleFileService; + _metadataFileService = metadataFileService; + _otherFileService = otherExtraFileService; + GetResourceAll = GetFiles; + } + + private List GetFiles() + { + if (!Request.Query.MovieId.HasValue) + { + throw new BadRequestException("MovieId is missing"); + } + + var extraFiles = new List(); + + List subtitleFiles = _subtitleFileService.GetFilesByMovie(Request.Query.MovieId); + List metadataFiles = _metadataFileService.GetFilesByMovie(Request.Query.MovieId); + List otherExtraFiles = _otherFileService.GetFilesByMovie(Request.Query.MovieId); + + extraFiles.AddRange(subtitleFiles.ToResource()); + extraFiles.AddRange(metadataFiles.ToResource()); + extraFiles.AddRange(otherExtraFiles.ToResource()); + + return extraFiles; + } + } +} diff --git a/src/Radarr.Api.V3/ExtraFiles/ExtraFileResource.cs b/src/Radarr.Api.V3/ExtraFiles/ExtraFileResource.cs new file mode 100644 index 000000000..9d465ee7d --- /dev/null +++ b/src/Radarr.Api.V3/ExtraFiles/ExtraFileResource.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.Extras.Metadata.Files; +using NzbDrone.Core.Extras.Others; +using NzbDrone.Core.Extras.Subtitles; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.ExtraFiles +{ + public class ExtraFileResource : RestResource + { + public int MovieId { get; set; } + public int? MovieFileId { get; set; } + public string RelativePath { get; set; } + public string Extension { get; set; } + public ExtraFileType Type { get; set; } + } + + public static class ExtraFileResourceMapper + { + public static ExtraFileResource ToResource(this MetadataFile model) + { + if (model == null) + { + return null; + } + + return new ExtraFileResource + { + Id = model.Id, + MovieId = model.MovieId, + MovieFileId = model.MovieFileId, + RelativePath = model.RelativePath, + Extension = model.Extension, + Type = ExtraFileType.Metadata + }; + } + + public static ExtraFileResource ToResource(this SubtitleFile model) + { + if (model == null) + { + return null; + } + + return new ExtraFileResource + { + Id = model.Id, + MovieId = model.MovieId, + MovieFileId = model.MovieFileId, + RelativePath = model.RelativePath, + Extension = model.Extension, + Type = ExtraFileType.Subtitle + }; + } + + public static ExtraFileResource ToResource(this OtherExtraFile model) + { + if (model == null) + { + return null; + } + + return new ExtraFileResource + { + Id = model.Id, + MovieId = model.MovieId, + MovieFileId = model.MovieFileId, + RelativePath = model.RelativePath, + Extension = model.Extension, + Type = ExtraFileType.Other + }; + } + + public static List ToResource(this IEnumerable movies) + { + return movies.Select(ToResource).ToList(); + } + + public static List ToResource(this IEnumerable movies) + { + return movies.Select(ToResource).ToList(); + } + + public static List ToResource(this IEnumerable movies) + { + return movies.Select(ToResource).ToList(); + } + } +}