1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-03-06 13:31:28 -05:00

Compare commits

...

56 Commits

Author SHA1 Message Date
Bogdan
74d2259f67 Avoid fetching movies twice on initial load 2025-03-29 19:43:30 +02:00
Bogdan
6e68a91922 Fixed: Avoid stale movie statistics on movies index 2025-03-29 17:50:03 +02:00
Bogdan
a962de776b Movie updates already done in MovieControllerWithSignalR 2025-03-29 17:46:54 +02:00
Mark McDowall
e8afde2e90 Add XML declaration and clean up Kodi metadata generation
(cherry picked from commit b103005aa23baffcf95ade6a2fa3b9923cddc167)
2025-03-25 15:01:30 +02:00
Bogdan
4633a834f3 Save Publish Dates as UTC for grabbed movies 2025-03-25 15:01:30 +02:00
Mark McDowall
cd021961f0 Fixed: Deleting movie folder fails when files/folders aren't instantly removed
(cherry picked from commit c84699ed5d5a2f59f236c26a8999d25a1102ec02)
2025-03-25 09:27:23 +02:00
Bogdan
456ea3d57c Fixed: Manual importing queued items with movieId to avoid title parsing
Fixes #10931
2025-03-25 09:26:03 +02:00
Bogdan
d09fa6f880 Cleanup unused sorting fields for bulk manage providers
(cherry picked from commit 6115236d3853f70a18b73aef15ebe4e18ab48e40)
2025-03-25 09:23:58 +02:00
Bogdan
bcd4fe1f08 Fixed: Priority validation for indexers and download clients
(cherry picked from commit f0e320f3aa501f120721503b8256f464a31be783)
2025-03-25 09:23:30 +02:00
Mark McDowall
8efce68922 Fixed: Trakt yearly lists no longer supported
(cherry picked from commit bdd975da0f1587a058c9b44871dd62eaada02467)
2025-03-25 09:22:41 +02:00
Mark McDowall
4b3c29ed93 Fixed: Allow tables to scroll on tablets in portrait mode
(cherry picked from commit 5fb632eb46cf77ea4f61d407f6429d9c32dba766)
2025-03-25 09:19:13 +02:00
Bogdan
7ea9161779 Bump browserslist-db 2025-03-24 20:26:51 +02:00
Stevie Robinson
f5c66c5093 New: Show size in history details
(cherry picked from commit ce6536f8abab584c365dcccbc31a780b51f6b02d)
2025-03-24 20:24:40 +02:00
Mark McDowall
a3515db9f7 Show Remove Failed option in torrent download clients
(cherry picked from commit 24ce10006eda8563b74c25a54dea92e966e6d786)
2025-03-24 20:05:24 +02:00
Bogdan
d4bb318253 Fixed: Prevent exception for seed configuration provider with invalid indexer ID
(cherry picked from commit f7b54f9d6b88330e04f127b6ee2ed9ee19404ec9)
2025-03-24 20:05:24 +02:00
Stevie Robinson
64e865f296 Enhance failed download warning for items not grabbed by Radarr
(cherry picked from commit c9027359271690f7d374d1330fa39776e2b8ec40)
2025-03-24 20:05:24 +02:00
Mark McDowall
982f9062bd Fixed: Downloads failed for file contents will be removed from client
(cherry picked from commit c034282f45c5e83ab9881356edba6f1e08f3cafe)
2025-03-24 20:05:24 +02:00
Mark McDowall
48075e33ac New: Option to treat downloads with non-media extensions as failed
(cherry picked from commit 776143cc813ec1b5fa31fbf8667c3ab174b71f5c)

New: Treat .scr as dangerous file

(cherry picked from commit 103ccd74f30830944e9e9f06d02be096f476ae34)

New: .arj and .lzh extensions are potentially dangerous

(cherry picked from commit a72288a14e67f62b00f921dbeb1a0f57a61e5ba7)

Fixed: Failing dangerous and executable single file downloads

(cherry picked from commit e37684e045310ca543aa6a22b38a325cd8a8e84d)

Fixed: Rejected Imports with no associated release or indexer

(cherry picked from commit 31e02bdeada8c85d67a75b69e57d3e7ea46989c6)

Fixed: Don't return warning in title field for rejected downloads

(cherry picked from commit 1fa532dd3eaaee01ac6a049e43fcdbd44357d617)

Fixed: Improve rejected download handling

(cherry picked from commit 4db43882361232eb8fe9ee5331c3d77ea3aa8dfa)
2025-03-24 20:05:24 +02:00
Bogdan
91f08a83cd Bump version to 5.22.0 2025-03-24 18:29:45 +02:00
Weblate
886db23c58 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Maxime Surrel <maxime.surrel@live.fr>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translation: Servarr/Radarr
2025-03-24 15:48:02 +02:00
Bogdan
b646386e77 Update state with the filtered movie file languages
Due to filtering the languages in /moviefile/bulk to not allow Any or Original, we're updating the state with the actual languages returned by the API.
2025-03-24 12:56:30 +02:00
Servarr
4aa259a666 Automated API Docs update 2025-03-23 18:42:37 +02:00
Bogdan
35f1a61bf8 Fixed: Updating movie files via Manage Files 2025-03-23 18:33:53 +02:00
Bogdan
1d855aed00 Deprecate /api/v3/moviefile/editor 2025-03-23 18:33:53 +02:00
Bogdan
f7da5b0866 Bump version to 5.21.1 2025-03-23 09:46:34 +02:00
Mark McDowall
682cc70acf Fixed: Drop downs flickering in some cases
(cherry picked from commit 3b024443c5447b7638a69a99809bf44b2419261f)

Closes #10869
2025-03-22 19:49:12 +02:00
Weblate
9d624b07ce Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alex <despedo@gmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translation: Servarr/Radarr
2025-03-22 16:34:03 +02:00
Bogdan
2afb41498d Fixed: Improve Movie Details loading 2025-03-22 16:32:47 +02:00
Bogdan
a0679fcf11 Fixed: Don't allow Any or Original for movie files 2025-03-22 15:03:43 +02:00
Weblate
df4a69ac02 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Alex <despedo@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Co-authored-by: theman824 <h.confirmation@gmx.de>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ar/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/id/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/is/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/th/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/vi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-03-21 18:49:59 +02:00
Bogdan
2c8d8ff2d6 Translate indexer settings
Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
2025-03-19 16:32:21 +02:00
Bogdan
0593568065 Fixed: Close modal when deleting movie from index
Fixes #10937
2025-03-19 01:03:39 +02:00
Bogdan
25aa719ad6 Bump NLog, Npgsql, System.Memory and System.ValueTuple 2025-03-18 14:18:00 +02:00
Bogdan
3ab61a2fee Bump version to 5.21.0 2025-03-18 14:13:07 +02:00
Weblate
954a040d6e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Lizandra Candido da Silva <lizandra.c.s@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr
2025-03-18 12:33:26 +02:00
Mark McDowall
905b23618a Improve logging when login fails due to CryptographicException
(cherry picked from commit 1449941471cbb8885e9298317b9a30f2576d7941)
2025-03-16 13:12:41 +02:00
Bogdan
8decd5d8e1 Bump version to 5.20.2 2025-03-16 10:45:58 +02:00
Bogdan
8b5b177d16 New: Display indexer in download failed details 2025-03-16 01:41:37 +02:00
Bogdan
e6c6fceff8 Fixed: Inherit indexer, size and release group for marked as failed history 2025-03-16 01:41:37 +02:00
Weblate
dcc8b28a07 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/bg/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/da/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/el/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/he/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ja/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ko/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/sv/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_TW/
Translation: Servarr/Radarr
2025-03-15 21:06:54 +02:00
Stevie Robinson
02baf4d7a4 Translate Frontend Components and Helpers
(cherry picked from commit e777b7018481b18ef18f1116f75983a037bf0849)

Closes #8995
2025-03-15 18:15:29 +02:00
Servarr
22ec1fe492 Multiple Translations updated by Weblate (#10925)
ignore-downstream






Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translation: Servarr/Radarr

Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: fordas <fordas15@gmail.com>
2025-03-15 14:13:27 +02:00
fezster
a7dbdadd21 New: Add HDR Type to XBMC metadata video stream details (#10906)
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2025-03-12 14:33:46 +02:00
Bogdan
93581e4a2f Fixed: Spinning icon on toggling movie monitoring 2025-03-11 23:07:08 +02:00
Bogdan
4c8da09df6 Fixed: Movie Details crashing on invalid collection 2025-03-11 16:14:29 +02:00
Bogdan
89666175a6 Update recommendation message against using uTorrent
(cherry picked from commit 6d8c3f15b343a24fc31a212463af8ed2b5792508)
2025-03-11 11:32:13 +02:00
Mark McDowall
7a33e156a3 New: Truncate button text
(cherry picked from commit 093ee5b88db0470426f6132e66a5893e5cf89bab)
2025-03-11 11:29:31 +02:00
Mark McDowall
c7c07404b0 Improve wrapping of text in sidebar
(cherry picked from commit f58dfc5605738ebccdd6adc6f1ca2a7843c086b2)
2025-03-11 11:29:20 +02:00
Mark McDowall
abeeee9363 Upgrade 'eslint-plugin-react-hooks' to 5.2.0
(cherry picked from commit c86822b114bd0b7276b40cdf2bb6181ef35db3dc)
2025-03-11 11:27:52 +02:00
Bogdan
23c30734d2 Convert QualityProfileName to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-09 22:02:04 +02:00
Bogdan
939e45e646 Disable left/right arrow navigation when a modal is open on Movie Details 2025-03-09 21:46:08 +02:00
Weblate
16ceba2392 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/
Translation: Servarr/Radarr
2025-03-09 21:20:18 +02:00
Bogdan
94d620d878 Clear search results when switching between movies 2025-03-09 21:16:48 +02:00
hhjuhl
ee0db93a0a Use 'text-wrap: balance' for text wrapping on overview
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
(cherry picked from commit 160151c6e000c6620ba2e04ca6245317c5c9ba16)

Closes #10729
2025-03-09 19:27:24 +02:00
Bogdan
f815b31c33 Convert Movie Details to TypeScript
Co-authored-by: Mark McDowall <mark@mcdowall.ca>
2025-03-09 19:26:44 +02:00
Bogdan
c078191b3d Bump version to 5.20.1 2025-03-09 11:53:09 +02:00
179 changed files with 4472 additions and 3332 deletions

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.20.0'
majorVersion: '5.22.0'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'

View File

@@ -18,6 +18,7 @@ import {
} from 'typings/History';
import formatDateTime from 'Utilities/Date/formatDateTime';
import formatAge from 'Utilities/Number/formatAge';
import formatBytes from 'Utilities/Number/formatBytes';
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
import translate from 'Utilities/String/translate';
import styles from './HistoryDetails.css';
@@ -50,6 +51,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
ageHours,
ageMinutes,
publishedDate,
size,
} = data as GrabbedHistoryData;
const downloadClientNameInfo = downloadClientName ?? downloadClient;
@@ -160,12 +162,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
})}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('Size')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'downloadFailed') {
const { message } = data as DownloadFailedHistory;
const { message, indexer } = data as DownloadFailedHistory;
return (
<DescriptionList>
@@ -179,6 +188,10 @@ function HistoryDetails(props: HistoryDetailsProps) {
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
) : null}
{indexer ? (
<DescriptionListItem title={translate('Indexer')} data={indexer} />
) : null}
{message ? (
<DescriptionListItem title={translate('Message')} data={message} />
) : null}
@@ -187,7 +200,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
}
if (eventType === 'downloadFolderImported') {
const { customFormatScore, droppedPath, importedPath } =
const { customFormatScore, droppedPath, importedPath, size } =
data as DownloadFolderImportedHistory;
return (
@@ -220,12 +233,19 @@ function HistoryDetails(props: HistoryDetailsProps) {
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}
if (eventType === 'movieFileDeleted') {
const { reason, customFormatScore } = data as MovieFileDeletedHistory;
const { reason, customFormatScore, size } = data as MovieFileDeletedHistory;
let reasonMessage = '';
@@ -255,6 +275,13 @@ function HistoryDetails(props: HistoryDetailsProps) {
data={formatCustomFormatScore(parseInt(customFormatScore))}
/>
) : null}
{size ? (
<DescriptionListItem
title={translate('FileSize')}
data={formatBytes(size)}
/>
) : null}
</DescriptionList>
);
}

View File

@@ -82,8 +82,7 @@ class AddNewMovie extends Component {
const {
error,
items,
hasExistingMovies,
colorImpairedMode
hasExistingMovies
} = this.props;
const term = this.state.term;
@@ -150,7 +149,6 @@ class AddNewMovie extends Component {
return (
<AddNewMovieSearchResultConnector
key={item.tmdbId}
colorImpairedMode={colorImpairedMode}
{...item}
/>
);
@@ -223,8 +221,7 @@ AddNewMovie.propTypes = {
items: PropTypes.arrayOf(PropTypes.object).isRequired,
hasExistingMovies: PropTypes.bool.isRequired,
onMovieLookupChange: PropTypes.func.isRequired,
onClearMovieLookup: PropTypes.func.isRequired,
colorImpairedMode: PropTypes.bool.isRequired
onClearMovieLookup: PropTypes.func.isRequired
};
export default AddNewMovie;

View File

@@ -6,7 +6,6 @@ import { clearAddMovie, lookupMovie } from 'Store/Actions/addMovieActions';
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
import parseUrl from 'Utilities/String/parseUrl';
@@ -17,15 +16,13 @@ function createMapStateToProps() {
(state) => state.addMovie,
(state) => state.movies.items.length,
(state) => state.router.location,
createUISettingsSelector(),
(addMovie, existingMoviesCount, location, uiSettings) => {
(addMovie, existingMoviesCount, location) => {
const { params } = parseUrl(location.search);
return {
...addMovie,
term: params.term,
hasExistingMovies: existingMoviesCount > 0,
colorImpairedMode: uiSettings.enableColorImpairedMode
hasExistingMovies: existingMoviesCount > 0
};
}
);

View File

@@ -74,12 +74,9 @@ class AddNewMovieSearchResult extends Component {
isExistingMovie,
isExcluded,
isSmallScreen,
colorImpairedMode,
id,
monitored,
isAvailable,
movieFile,
queueItem,
runtime,
movieRuntimeFormat,
certification
@@ -285,14 +282,12 @@ class AddNewMovieSearchResult extends Component {
{
isExistingMovie && isSmallScreen &&
<MovieStatusLabel
status={status}
hasMovieFiles={hasMovieFile}
movieId={existingMovieId}
monitored={monitored}
isAvailable={isAvailable}
queueItem={queueItem}
id={id}
hasMovieFiles={hasMovieFile}
status={status}
useLabel={true}
colorImpairedMode={colorImpairedMode}
/>
}
</div>
@@ -337,12 +332,9 @@ AddNewMovieSearchResult.propTypes = {
isExistingMovie: PropTypes.bool.isRequired,
isExcluded: PropTypes.bool,
isSmallScreen: PropTypes.bool.isRequired,
id: PropTypes.number,
monitored: PropTypes.bool.isRequired,
isAvailable: PropTypes.bool.isRequired,
movieFile: PropTypes.object,
queueItem: PropTypes.object,
colorImpairedMode: PropTypes.bool,
runtime: PropTypes.number.isRequired,
movieRuntimeFormat: PropTypes.string.isRequired,
certification: PropTypes.string

View File

@@ -8,19 +8,16 @@ function createMapStateToProps() {
return createSelector(
createExistingMovieSelector(),
createDimensionsSelector(),
(state) => state.queue.details.items,
(state) => state.movieFiles.items,
(state, { internalId }) => internalId,
(state) => state.settings.ui.item.movieRuntimeFormat,
(isExistingMovie, dimensions, queueItems, movieFiles, internalId, movieRuntimeFormat) => {
const queueItem = queueItems.find((item) => internalId > 0 && item.movieId === internalId);
(isExistingMovie, dimensions, movieFiles, internalId, movieRuntimeFormat) => {
const movieFile = movieFiles.find((item) => internalId > 0 && item.movieId === internalId);
return {
existingMovieId: internalId,
isExistingMovie,
isSmallScreen: dimensions.isSmallScreen,
queueItem,
movieFile,
movieRuntimeFormat
};

View File

@@ -10,7 +10,7 @@ import CollectionConnector from 'Collection/CollectionConnector';
import NotFound from 'Components/NotFound';
import Switch from 'Components/Router/Switch';
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
import MovieDetailsPage from 'Movie/Details/MovieDetailsPage';
import MovieIndex from 'Movie/Index/MovieIndex';
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
@@ -67,7 +67,7 @@ function AppRoutes() {
<Route path="/add/discover" component={DiscoverMovieConnector} />
<Route path="/movie/:titleSlug" component={MovieDetailsPageConnector} />
<Route path="/movie/:titleSlug" component={MovieDetailsPage} />
{/*
Calendar

View File

@@ -1,6 +1,7 @@
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CommandAppState from './CommandAppState';
import ExtraFilesAppState from './ExtraFilesAppState';
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MovieBlocklistAppState from './MovieBlocklistAppState';
@@ -53,6 +54,7 @@ export interface CustomFilter {
export interface AppSectionState {
isConnected: boolean;
isReconnecting: boolean;
isSidebarVisible: boolean;
version: string;
prevVersion?: string;
dimensions: {
@@ -67,6 +69,7 @@ interface AppState {
blocklist: BlocklistAppState;
calendar: CalendarAppState;
commands: CommandAppState;
extraFiles: ExtraFilesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
movieBlocklist: MovieBlocklistAppState;

View File

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

View File

@@ -11,7 +11,7 @@ import IconButton from 'Components/Link/IconButton';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import { icons, sizes } from 'Helpers/Props';
import MovieGenres from 'Movie/MovieGenres';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import dimensions from 'Styles/Variables/dimensions';
import fonts from 'Styles/Variables/fonts';
import translate from 'Utilities/String/translate';
@@ -212,7 +212,7 @@ class CollectionOverview extends Component {
/>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
<QualityProfileName
qualityProfileId={qualityProfileId}
/>
}

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import StackTrace from 'stacktrace-js';
import translate from 'Utilities/String/translate';
import styles from './ErrorBoundaryError.css';
interface ErrorBoundaryErrorProps {
@@ -18,7 +19,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
className = styles.container,
messageClassName = styles.message,
detailsClassName = styles.details,
message = 'There was an error loading this content',
message = translate('ErrorLoadingContent'),
error,
info,
} = props;

View File

@@ -3,7 +3,10 @@ import translate from 'Utilities/String/translate';
import FilterBuilderRowValue from './FilterBuilderRowValue';
const statusTagList = [
{ id: 'tba', name: 'TBA' },
{ id: 'tba',
get name() {
return translate('Tba');
} },
{
id: 'announced',
get name() {

View File

@@ -53,7 +53,7 @@ function CustomFiltersModalContent(props) {
<div className={styles.addButtonContainer}>
<Button onPress={onAddCustomFilter}>
Add Custom Filter
{translate('AddCustomFilter')}
</Button>
</div>
</ModalBody>

View File

@@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component {
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
return data;
};
@@ -463,6 +456,10 @@ class EnhancedSelectInput extends Component {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
}
}}
>

View File

@@ -1,9 +1,11 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchIndexers } from 'Store/Actions/settingsActions';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import EnhancedSelectInput from './EnhancedSelectInput';
function createMapStateToProps() {
@@ -18,15 +20,17 @@ function createMapStateToProps() {
items
} = indexers;
const values = items.sort(sortByProp('name')).map((indexer) => ({
key: indexer.id,
value: indexer.name
}));
const values = _.map(items.sort(sortByProp('name')), (indexer) => {
return {
key: indexer.id,
value: indexer.name
};
});
if (includeAny) {
values.unshift({
key: 0,
value: '(Any)'
value: `(${translate('Any')})`
});
}

View File

@@ -33,7 +33,7 @@ function createMapStateToProps() {
if (includeMixed) {
values.unshift({
key: 'mixed',
value: '(Mixed)',
value: `(${translate('Mixed')})`,
isDisabled: true
});
}

View File

@@ -55,7 +55,7 @@ function createMapStateToProps() {
values.push({
key: ADD_NEW_KEY,
value: 'Add a new path'
value: translate('AddANewPath')
});
return {

View File

@@ -2,6 +2,7 @@ import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './RootFolderSelectInputOption.css';
@@ -47,14 +48,14 @@ function RootFolderSelectInputOption(props) {
freeSpace == null ?
null :
<div className={styles.freeSpace}>
{formatBytes(freeSpace)} Free
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
</div>
}
{
isMissing ?
<div className={styles.isMissing}>
Missing
{translate('Missing')}
</div> :
null
}
@@ -67,8 +68,8 @@ RootFolderSelectInputOption.propTypes = {
id: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
freeSpace: PropTypes.number,
movieFolder: PropTypes.string,
isMissing: PropTypes.bool,
movieFolder: PropTypes.string,
isMobile: PropTypes.bool.isRequired,
isWindows: PropTypes.bool
};

View File

@@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue';
import styles from './RootFolderSelectInputSelectedValue.css';
@@ -39,7 +40,7 @@ function RootFolderSelectInputSelectedValue(props) {
{
freeSpace != null && includeFreeSpace &&
<div className={styles.freeSpace}>
{formatBytes(freeSpace)} Free
{translate('RootFolderSelectFreeSpace', { freeSpace: formatBytes(freeSpace) })}
</div>
}
</EnhancedSelectInputSelectedValue>

View File

@@ -8,27 +8,37 @@ import styles from './UMaskInput.css';
const umaskOptions = [
{
key: '755',
value: '755 - Owner write, Everyone else read',
get value() {
return translate('Umask755Description', { octal: '755' });
},
hint: 'drwxr-xr-x'
},
{
key: '775',
value: '775 - Owner & Group write, Other read',
get value() {
return translate('Umask775Description', { octal: '775' });
},
hint: 'drwxrwxr-x'
},
{
key: '770',
value: '770 - Owner & Group write',
get value() {
return translate('Umask770Description', { octal: '770' });
},
hint: 'drwxrwx---'
},
{
key: '750',
value: '750 - Owner write, Group read',
get value() {
return translate('Umask750Description', { octal: '750' });
},
hint: 'drwxr-x---'
},
{
key: '777',
value: '777 - Everyone write',
get value() {
return translate('Umask777Description', { octal: '777' });
},
hint: 'drwxrwxrwx'
}
];

View File

@@ -8,17 +8,20 @@ import { kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import styles from './Icon.css';
export type IconName = FontAwesomeIconProps['icon'];
export type IconKind = Extract<Kind, keyof typeof styles>;
export interface IconProps
extends Omit<
FontAwesomeIconProps,
'icon' | 'spin' | 'name' | 'title' | 'size'
> {
containerClassName?: ComponentProps<'span'>['className'];
name: FontAwesomeIconProps['icon'];
kind?: Extract<Kind, keyof typeof styles>;
name: IconName;
kind?: IconKind;
size?: number;
isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string);
title?: string | (() => string) | null;
}
export default function Icon({

View File

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

View File

@@ -1,6 +1,7 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'default': string;
'label': string;
'large': string;
'medium': string;

View File

@@ -1,54 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import styles from './InfoLabel.css';
function InfoLabel(props) {
const {
className,
name,
kind,
size,
outline,
children,
...otherProps
} = props;
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
>
<div className={styles.name}>
{name}
</div>
<div>
{children}
</div>
</span>
);
}
InfoLabel.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
kind: PropTypes.oneOf(kinds.all).isRequired,
size: PropTypes.oneOf(sizes.all).isRequired,
outline: PropTypes.bool.isRequired,
children: PropTypes.node.isRequired
};
InfoLabel.defaultProps = {
className: styles.label,
kind: kinds.DEFAULT,
size: sizes.SMALL,
outline: false
};
export default InfoLabel;

View File

@@ -0,0 +1,41 @@
import classNames from 'classnames';
import React, { ComponentProps, ReactNode } from 'react';
import { Kind } from 'Helpers/Props/kinds';
import { Size } from 'Helpers/Props/sizes';
import styles from './InfoLabel.css';
interface InfoLabelProps extends ComponentProps<'span'> {
className?: string;
name: string;
kind?: Extract<Kind, keyof typeof styles>;
size?: Extract<Size, keyof typeof styles>;
outline?: boolean;
children: ReactNode;
}
function InfoLabel({
className = styles.label,
name,
kind = 'default',
size = 'small',
outline = false,
children,
...otherProps
}: InfoLabelProps) {
return (
<span
className={classNames(
className,
styles[kind],
styles[size],
outline && styles.outline
)}
{...otherProps}
>
<div className={styles.name}>{name}</div>
<div>{children}</div>
</span>
);
}
export default InfoLabel;

View File

@@ -1,3 +1,4 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
@@ -7,26 +8,15 @@ const TIMEOUT = 1 / FPS * 1000;
class Marquee extends Component {
static propTypes = {
text: PropTypes.string,
title: PropTypes.string,
hoverToStop: PropTypes.bool,
loop: PropTypes.bool,
className: PropTypes.string
};
constructor(props, context) {
super(props, context);
static defaultProps = {
text: '',
title: '',
hoverToStop: true,
loop: false
};
state = {
animatedWidth: 0,
overflowWidth: 0,
direction: 0
};
this.state = {
animatedWidth: 0,
overflowWidth: 0,
direction: 0
};
}
componentDidMount() {
this.measureText();
@@ -138,7 +128,7 @@ class Marquee extends Component {
ref={(el) => {
this.container = el;
}}
className={`ui-marquee ${this.props.className}`}
className={classNames('ui-marquee', this.props.className)}
style={{ overflow: 'hidden' }}
>
<span
@@ -159,7 +149,7 @@ class Marquee extends Component {
ref={(el) => {
this.container = el;
}}
className={`ui-marquee ${this.props.className}`.trim()}
className={classNames('ui-marquee', this.props.className)}
style={{ overflow: 'hidden' }}
onMouseEnter={this.onHandleMouseEnter}
onMouseLeave={this.onHandleMouseLeave}
@@ -178,4 +168,20 @@ class Marquee extends Component {
}
}
Marquee.propTypes = {
text: PropTypes.string,
title: PropTypes.string,
hoverToStop: PropTypes.bool,
loop: PropTypes.bool,
className: PropTypes.string
};
Marquee.defaultProps = {
text: '',
title: '',
hoverToStop: true,
loop: false,
className: ''
};
export default Marquee;

View File

@@ -58,9 +58,9 @@ class FilterMenu extends Component {
>
<ButtonComponent
iconName={icons.FILTER}
showIndicator={selectedFilterKey !== 'all'}
text={translate('Filter')}
isDisabled={isDisabled}
showIndicator={selectedFilterKey !== 'all'}
/>
<FilterMenuContent

View File

@@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { align } from 'Helpers/Props';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import styles from './Menu.css';
const sharedPopperOptions = {
@@ -38,8 +38,8 @@ class Menu extends Component {
super(props, context);
this._scheduleUpdate = null;
this._menuButtonId = getUniqueElememtId();
this._menuContentId = getUniqueElememtId();
this._menuButtonId = getUniqueElementId();
this._menuContentId = getUniqueElementId();
this.state = {
isMenuOpen: false,

View File

@@ -82,13 +82,6 @@
}
@media only screen and (max-width: $breakpointMedium) {
.modal.small,
.modal.medium {
width: 90%;
}
}
@media only screen and (max-width: $breakpointSmall) {
.modalContainer {
position: fixed;
}

View File

@@ -18,7 +18,7 @@ function ModalError(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Error
{translate('Error')}
</ModalHeader>
<ModalBody>
@@ -26,7 +26,7 @@ function ModalError(props) {
messageClassName={styles.message}
detailsClassName={styles.details}
{...otherProps}
message={translate('ThereWasAnErrorLoadingThisItem')}
message={translate('ErrorLoadingItem')}
/>
</ModalBody>

View File

@@ -56,7 +56,7 @@ function KeyboardShortcutsModalContent(props) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Keyboard Shortcuts
{translate('KeyboardShortcuts')}
</ModalHeader>
<ModalBody>

View File

@@ -92,7 +92,7 @@ class MovieSearchInput extends Component {
if (item.type === ADD_NEW_TYPE) {
return (
<div className={styles.addNewMovieSuggestion}>
Search for {query}
{translate('SearchForQuery', { query })}
</div>
);
}

View File

@@ -79,7 +79,7 @@ class PageHeader extends Component {
<IconButton
className={styles.donate}
name={icons.HEART}
aria-label="Donate"
aria-label={translate('Donate')}
to="https://radarr.video/donate"
size={14}
title={translate('Donate')}

View File

@@ -10,7 +10,7 @@ function PageContentError(props) {
<PageContentBody>
<ErrorBoundaryError
{...props}
message={translate('ThereWasAnErrorLoadingThisPage')}
message={translate('ErrorLoadingPage')}
/>
</PageContentBody>
</div>

View File

@@ -24,6 +24,7 @@
composes: link;
padding: 10px 24px;
padding-left: 35px;
}
.isActiveLink {
@@ -41,10 +42,6 @@
text-align: center;
}
.noIcon {
margin-left: 25px;
}
.status {
float: right;
}

View File

@@ -8,7 +8,6 @@ interface CssExports {
'isActiveParentLink': string;
'item': string;
'link': string;
'noIcon': string;
'status': string;
}
export const cssExports: CssExports;

View File

@@ -63,9 +63,7 @@ class PageSidebarItem extends Component {
</span>
}
<span className={isChildItem ? styles.noIcon : null}>
{typeof title === 'function' ? title() : title}
</span>
{typeof title === 'function' ? title() : title}
{
!!StatusComponent &&

View File

@@ -23,11 +23,14 @@
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
height: 24px;
}
.label {
padding: 0 3px;
max-width: 100%;
max-height: 100%;
color: var(--toolbarLabelColor);
font-size: $extraSmallFontSize;
line-height: calc($extraSmallFontSize + 1px);

View File

@@ -1,58 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './PageToolbarButton.css';
function PageToolbarButton(props) {
const {
label,
iconName,
spinningName,
isDisabled,
isSpinning,
...otherProps
} = props;
return (
<Link
className={classNames(
styles.toolbarButton,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<Icon
name={isSpinning ? (spinningName || iconName) : iconName}
isSpinning={isSpinning}
size={21}
/>
<div className={styles.labelContainer}>
<div className={styles.label}>
{label}
</div>
</div>
</Link>
);
}
PageToolbarButton.propTypes = {
label: PropTypes.string.isRequired,
iconName: PropTypes.object.isRequired,
spinningName: PropTypes.object,
isSpinning: PropTypes.bool,
isDisabled: PropTypes.bool,
onPress: PropTypes.func
};
PageToolbarButton.defaultProps = {
spinningName: icons.SPINNER,
isDisabled: false,
isSpinning: false
};
export default PageToolbarButton;

View File

@@ -0,0 +1,50 @@
import classNames from 'classnames';
import React from 'react';
import Icon, { IconName } from 'Components/Icon';
import Link, { LinkProps } from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import styles from './PageToolbarButton.css';
export interface PageToolbarButtonProps extends LinkProps {
label: string;
iconName: IconName;
spinningName?: IconName;
isSpinning?: boolean;
isDisabled?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overflowComponent?: React.ComponentType<any>;
}
function PageToolbarButton({
label,
iconName,
spinningName = icons.SPINNER,
isDisabled = false,
isSpinning = false,
overflowComponent,
...otherProps
}: PageToolbarButtonProps) {
return (
<Link
className={classNames(
styles.toolbarButton,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled || isSpinning}
title={label}
{...otherProps}
>
<Icon
name={isSpinning ? spinningName || iconName : iconName}
isSpinning={isSpinning}
size={21}
/>
<div className={styles.labelContainer}>
<div className={styles.label}>{label}</div>
</div>
</Link>
);
}
export default PageToolbarButton;

View File

@@ -4,7 +4,7 @@
line-height: 1.52857143;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.cell {
white-space: nowrap;
}

View File

@@ -7,7 +7,7 @@
white-space: nowrap;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.cell {
white-space: nowrap;
}

View File

@@ -10,7 +10,7 @@
border-collapse: collapse;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.tableContainer {
min-width: 100%;
width: fit-content;

View File

@@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.headerCell {
white-space: nowrap;
}

View File

@@ -170,11 +170,11 @@ class TableOptionsModal extends Component {
{
canModifyColumns ?
<FormGroup>
<FormLabel>{translate('Columns')}</FormLabel>
<FormLabel>{translate('TableColumns')}</FormLabel>
<div>
<FormInputHelpText
text={translate('TableOptionsColumnsMessage')}
text={translate('TableColumnsHelpText')}
/>
<div className={styles.columns}>

View File

@@ -60,7 +60,7 @@
height: 25px;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.pager {
flex-wrap: wrap;
}

View File

@@ -6,6 +6,7 @@ import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './TablePager.css';
class TablePager extends Component {
@@ -156,7 +157,7 @@ class TablePager extends Component {
<div className={styles.recordsContainer}>
<div className={styles.records}>
Total records: {totalRecords}
{translate('TotalRecords', { totalRecords })}
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointSmall) {
@media only screen and (max-width: $breakpointMedium) {
.headerCell {
white-space: nowrap;
}

View File

@@ -7,63 +7,63 @@ export const shortcuts = {
OPEN_KEYBOARD_SHORTCUTS_MODAL: {
key: '?',
get name() {
return translate('OpenThisModal');
return translate('KeyboardShortcutsOpenModal');
}
},
CLOSE_MODAL: {
key: 'Esc',
get name() {
return translate('CloseCurrentModal');
return translate('KeyboardShortcutsCloseModal');
}
},
ACCEPT_CONFIRM_MODAL: {
key: 'Enter',
get name() {
return translate('AcceptConfirmationModal');
return translate('KeyboardShortcutsConfirmModal');
}
},
MOVIE_SEARCH_INPUT: {
key: 's',
get name() {
return translate('FocusSearchBox');
return translate('KeyboardShortcutsFocusSearchBox');
}
},
SAVE_SETTINGS: {
key: 'mod+s',
get name() {
return translate('SaveSettings');
return translate('KeyboardShortcutsSaveSettings');
}
},
SCROLL_TOP: {
key: 'mod+home',
get name() {
return translate('MovieIndexScrollTop');
return translate('KeyboardShortcutsMovieIndexScrollTop');
}
},
SCROLL_BOTTOM: {
key: 'mod+end',
get name() {
return translate('MovieIndexScrollBottom');
return translate('KeyboardShortcutsMovieIndexScrollBottom');
}
},
DETAILS_NEXT: {
key: '→',
get name() {
return translate('MovieDetailsNextMovie');
return translate('KeyboardShortcutsMovieDetailsNextMovie');
}
},
DETAILS_PREVIOUS: {
key: '←',
get name() {
return translate('MovieDetailsPreviousMovie');
return translate('KeyboardShortcutsMovieDetailsPreviousMovie');
}
}
};

View File

@@ -1,3 +1,4 @@
import translate from 'Utilities/String/translate';
import * as filterTypes from './filterTypes';
export const ARRAY = 'array';
@@ -20,49 +21,127 @@ export const all = [
export const possibleFilterTypes = {
[ARRAY]: [
{ key: filterTypes.CONTAINS, value: 'contains' },
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' }
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain')
}
],
[CONTAINS]: [
{ key: filterTypes.CONTAINS, value: 'contains' }
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
}
],
[DATE]: [
{ key: filterTypes.LESS_THAN, value: 'is before' },
{ key: filterTypes.GREATER_THAN, value: 'is after' },
{ key: filterTypes.IN_LAST, value: 'in the last' },
{ key: filterTypes.NOT_IN_LAST, value: 'not in the last' },
{ key: filterTypes.IN_NEXT, value: 'in the next' },
{ key: filterTypes.NOT_IN_NEXT, value: 'not in the next' }
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterIsBefore')
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterIsAfter')
},
{
key: filterTypes.IN_LAST,
value: () => translate('FilterInLast')
},
{
key: filterTypes.NOT_IN_LAST,
value: () => translate('FilterNotInLast')
},
{
key: filterTypes.IN_NEXT,
value: () => translate('FilterInNext')
},
{
key: filterTypes.NOT_IN_NEXT,
value: () => translate('FilterNotInNext')
}
],
[EQUAL]: [
{ key: filterTypes.EQUAL, value: 'is' }
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs')
}
],
[EXACT]: [
{ key: filterTypes.EQUAL, value: 'is' },
{ key: filterTypes.NOT_EQUAL, value: 'is not' }
{
key: filterTypes.EQUAL,
value: () => translate('FilterIs')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterIsNot')
}
],
[NUMBER]: [
{ key: filterTypes.EQUAL, value: 'equal' },
{ key: filterTypes.GREATER_THAN, value: 'greater than' },
{ key: filterTypes.GREATER_THAN_OR_EQUAL, value: 'greater than or equal' },
{ key: filterTypes.LESS_THAN, value: 'less than' },
{ key: filterTypes.LESS_THAN_OR_EQUAL, value: 'less than or equal' },
{ key: filterTypes.NOT_EQUAL, value: 'not equal' }
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual')
},
{
key: filterTypes.GREATER_THAN,
value: () => translate('FilterGreaterThan')
},
{
key: filterTypes.GREATER_THAN_OR_EQUAL,
value: () => translate('FilterGreaterThanOrEqual')
},
{
key: filterTypes.LESS_THAN,
value: () => translate('FilterLessThan')
},
{
key: filterTypes.LESS_THAN_OR_EQUAL,
value: () => translate('FilterLessThanOrEqual')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual')
}
],
[STRING]: [
{ key: filterTypes.CONTAINS, value: 'contains' },
{ key: filterTypes.NOT_CONTAINS, value: 'does not contain' },
{ key: filterTypes.EQUAL, value: 'equal' },
{ key: filterTypes.NOT_EQUAL, value: 'not equal' },
{ key: filterTypes.STARTS_WITH, value: 'starts with' },
{ key: filterTypes.NOT_STARTS_WITH, value: 'does not start with' },
{ key: filterTypes.ENDS_WITH, value: 'ends with' },
{ key: filterTypes.NOT_ENDS_WITH, value: 'does not end with' }
{
key: filterTypes.CONTAINS,
value: () => translate('FilterContains')
},
{
key: filterTypes.NOT_CONTAINS,
value: () => translate('FilterDoesNotContain')
},
{
key: filterTypes.EQUAL,
value: () => translate('FilterEqual')
},
{
key: filterTypes.NOT_EQUAL,
value: () => translate('FilterNotEqual')
},
{
key: filterTypes.STARTS_WITH,
value: () => translate('FilterStartsWith')
},
{
key: filterTypes.NOT_STARTS_WITH,
value: () => translate('FilterDoesNotStartWith')
},
{
key: filterTypes.ENDS_WITH,
value: () => translate('FilterEndsWith')
},
{
key: filterTypes.NOT_ENDS_WITH,
value: () => translate('FilterDoesNotEndWith')
}
]
};

View File

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

View File

@@ -192,10 +192,9 @@ const importModeSelector = createSelector(
}
);
interface InteractiveImportModalContentProps {
export interface InteractiveImportModalContentProps {
downloadId?: string;
movieId?: number;
seasonNumber?: number;
showMovie?: boolean;
allowMovieChange?: boolean;
showDelete?: boolean;
@@ -217,7 +216,6 @@ function InteractiveImportModalContent(
const {
downloadId,
movieId,
seasonNumber,
allowMovieChange = true,
showMovie = true,
showFilterExistingFiles = false,
@@ -343,7 +341,6 @@ function InteractiveImportModalContent(
fetchInteractiveImportItems({
downloadId,
movieId,
seasonNumber,
folder,
filterExistingFiles,
})

View File

@@ -4,9 +4,12 @@ import usePrevious from 'Helpers/Hooks/usePrevious';
import { sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import InteractiveImportSelectFolderModalContent from './Folder/InteractiveImportSelectFolderModalContent';
import InteractiveImportModalContent from './Interactive/InteractiveImportModalContent';
import InteractiveImportModalContent, {
InteractiveImportModalContentProps,
} from './Interactive/InteractiveImportModalContent';
interface InteractiveImportModalProps {
interface InteractiveImportModalProps
extends Omit<InteractiveImportModalContentProps, 'modalTitle'> {
isOpen: boolean;
folder?: string;
downloadId?: string;

View File

@@ -179,7 +179,9 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{modalTitle} - Select Movie</ModalHeader>
<ModalHeader>
{translate('SelectMovieModalTitle', { modalTitle })}
</ModalHeader>
<ModalBody
className={styles.modalBody}
@@ -187,7 +189,7 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
>
<TextInput
className={styles.filterInput}
placeholder="Filter movies"
placeholder={translate('FilterMoviePlaceholder')}
name="filter"
value={filter}
autoFocus={true}
@@ -223,7 +225,7 @@ function SelectMovieModalContent(props: SelectMovieModalContentProps) {
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>Cancel</Button>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
</ModalFooter>
</ModalContent>
);

View File

@@ -60,7 +60,16 @@ function DeleteMovieModalContent({
addImportExclusion,
})
);
}, [movieId, collection, addImportExclusion, deleteFiles, dispatch]);
onModalClose();
}, [
movieId,
collection,
addImportExclusion,
deleteFiles,
dispatch,
onModalClose,
]);
const handleDeleteOptionChange = useCallback(
({ name, value }: CheckInputChanged) => {

View File

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

View File

@@ -1,835 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import TextTruncate from 'react-text-truncate';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import InfoLabel from 'Components/InfoLabel';
import IconButton from 'Components/Link/IconButton';
import Marquee from 'Components/Marquee';
import Measure from 'Components/Measure';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TraktRating from 'Components/TraktRating';
import { icons, kinds, sizes, sortDirections, tooltipPositions } from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import EditMovieModal from 'Movie/Edit/EditMovieModal';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
import MovieCollectionLabel from 'Movie/MovieCollectionLabel';
import MovieGenres from 'Movie/MovieGenres';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import fonts from 'Styles/Variables/fonts';
import * as keyCodes from 'Utilities/Constants/keyCodes';
import formatRuntime from 'Utilities/Date/formatRuntime';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
import MovieDetailsLinks from './MovieDetailsLinks';
import MovieReleaseDates from './MovieReleaseDates';
import MovieStatusLabel from './MovieStatusLabel';
import MovieTagsConnector from './MovieTagsConnector';
import MovieTitlesTable from './Titles/MovieTitlesTable';
import styles from './MovieDetails.css';
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
const image = images.find((img) => img.coverType === 'fanart');
return image?.url ?? image?.remoteUrl;
}
class MovieDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isEditMovieModalOpen: false,
isDeleteMovieModalOpen: false,
isInteractiveImportModalOpen: false,
isInteractiveSearchModalOpen: false,
isMovieHistoryModalOpen: false,
overviewHeight: 0,
titleWidth: 0
};
}
componentDidMount() {
window.addEventListener('touchstart', this.onTouchStart);
window.addEventListener('touchend', this.onTouchEnd);
window.addEventListener('touchcancel', this.onTouchCancel);
window.addEventListener('touchmove', this.onTouchMove);
window.addEventListener('keyup', this.onKeyUp);
}
componentWillUnmount() {
window.removeEventListener('touchstart', this.onTouchStart);
window.removeEventListener('touchend', this.onTouchEnd);
window.removeEventListener('touchcancel', this.onTouchCancel);
window.removeEventListener('touchmove', this.onTouchMove);
window.removeEventListener('keyup', this.onKeyUp);
}
//
// Listeners
onOrganizePress = () => {
this.setState({ isOrganizeModalOpen: true });
};
onOrganizeModalClose = () => {
this.setState({ isOrganizeModalOpen: false });
};
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
};
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
};
onEditMoviePress = () => {
this.setState({ isEditMovieModalOpen: true });
};
onEditMovieModalClose = () => {
this.setState({ isEditMovieModalOpen: false });
};
onInteractiveSearchPress = () => {
this.setState({ isInteractiveSearchModalOpen: true });
};
onInteractiveSearchModalClose = () => {
this.setState({ isInteractiveSearchModalOpen: false });
};
onDeleteMoviePress = () => {
this.setState({
isEditMovieModalOpen: false,
isDeleteMovieModalOpen: true
});
};
onDeleteMovieModalClose = () => {
this.setState({ isDeleteMovieModalOpen: false });
};
onMovieHistoryPress = () => {
this.setState({ isMovieHistoryModalOpen: true });
};
onMovieHistoryModalClose = () => {
this.setState({ isMovieHistoryModalOpen: false });
};
onMeasure = ({ height }) => {
this.setState({ overviewHeight: height });
};
onTitleMeasure = ({ width }) => {
this.setState({ titleWidth: width });
};
onKeyUp = (event) => {
if (event.composedPath && event.composedPath().length === 4) {
if (event.keyCode === keyCodes.LEFT_ARROW) {
this.props.onGoToMovie(this.props.previousMovie.titleSlug);
}
if (event.keyCode === keyCodes.RIGHT_ARROW) {
this.props.onGoToMovie(this.props.nextMovie.titleSlug);
}
}
};
onTouchStart = (event) => {
const touches = event.touches;
const touchStart = touches[0].pageX;
const touchY = touches[0].pageY;
// Only change when swipe is on header, we need horizontal scroll on tables
if (touchY > 470) {
return;
}
if (touches.length !== 1) {
return;
}
if (
touchStart < 50 ||
this.props.isSidebarVisible ||
this.state.isOrganizeModalOpen ||
this.state.isEditMovieModalOpen ||
this.state.isDeleteMovieModalOpen ||
this.state.isInteractiveImportModalOpen ||
this.state.isInteractiveSearchModalOpen ||
this.state.isMovieHistoryModalOpen
) {
return;
}
this._touchStart = touchStart;
};
onTouchEnd = (event) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!this._touchStart) {
return;
}
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
this.props.onGoToMovie(this.props.previousMovie.titleSlug);
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
this.props.onGoToMovie(this.props.nextMovie.titleSlug);
}
this._touchStart = null;
};
onTouchCancel = (event) => {
this._touchStart = null;
};
onTouchMove = (event) => {
if (!this._touchStart) {
return;
}
};
//
// Render
render() {
const {
id,
tmdbId,
imdbId,
title,
originalTitle,
year,
inCinemas,
physicalRelease,
digitalRelease,
runtime,
certification,
ratings,
path,
statistics,
qualityProfileId,
monitored,
studio,
originalLanguage,
genres,
collection,
overview,
status,
youTubeTrailerId,
isAvailable,
images,
tags,
isSaving,
isRefreshing,
isSearching,
isFetching,
isSmallScreen,
movieFilesError,
movieCreditsError,
extraFilesError,
hasMovieFiles,
previousMovie,
nextMovie,
onMonitorTogglePress,
onRefreshPress,
onSearchPress,
queueItem,
movieRuntimeFormat
} = this.props;
const {
sizeOnDisk = 0
} = statistics;
const {
isOrganizeModalOpen,
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isInteractiveImportModalOpen,
isInteractiveSearchModalOpen,
isMovieHistoryModalOpen,
overviewHeight,
titleWidth
} = this.state;
const statusDetails = getMovieStatusDetails(status);
const fanartUrl = getFanartUrl(images);
const marqueeWidth = isSmallScreen ? titleWidth : (titleWidth - 150);
const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`;
return (
<PageContent title={titleWithYear}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAndScan')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title={translate('RefreshInformationAndScanDisk')}
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarButton
label={translate('SearchMovie')}
iconName={icons.SEARCH}
isSpinning={isSearching}
title={undefined}
onPress={onSearchPress}
/>
<PageToolbarButton
label={translate('InteractiveSearch')}
iconName={icons.INTERACTIVE}
isSpinning={isSearching}
title={undefined}
onPress={this.onInteractiveSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('PreviewRename')}
iconName={icons.ORGANIZE}
isDisabled={!hasMovieFiles}
onPress={this.onOrganizePress}
/>
<PageToolbarButton
label={translate('ManageFiles')}
iconName={icons.MOVIE_FILE}
onPress={this.onInteractiveImportPress}
/>
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
onPress={this.onMovieHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={this.onEditMoviePress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={this.onDeleteMoviePress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ?
{ backgroundImage: `url(${fanartUrl})` } :
null
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<MoviePoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<Measure onMeasure={this.onTitleMeasure}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={onMonitorTogglePress}
/>
</div>
<div className={styles.title} style={{ width: marqueeWidth }}>
<Marquee text={title} title={originalTitle} />
</div>
</div>
<div className={styles.movieNavigationButtons}>
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('GoToInterp', [previousMovie.title])}
to={`/movie/${previousMovie.titleSlug}`}
/>
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('GoToInterp', [nextMovie.title])}
to={`/movie/${nextMovie.titleSlug}`}
/>
</div>
</div>
</Measure>
<div className={styles.details}>
<div>
{
certification ?
<span className={styles.certification} title={translate('Certification')}>
{certification}
</span> :
null
}
<span className={styles.year}>
<Popover
anchor={
year > 0 ? (
year
) : (
<Icon
name={icons.WARNING}
kind={kinds.WARNING}
size={20}
/>
)
}
title={translate('ReleaseDates')}
body={
<MovieReleaseDates
tmdbId={tmdbId}
inCinemas={inCinemas}
digitalRelease={digitalRelease}
physicalRelease={physicalRelease}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
{
runtime ?
<span className={styles.runtime} title={translate('Runtime')}>
{formatRuntime(runtime, movieRuntimeFormat)}
</span> :
null
}
{
<span className={styles.links}>
<Tooltip
anchor={
<Icon
name={icons.EXTERNAL_LINK}
size={20}
/>
}
tooltip={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
}
{
!!tags.length &&
<span>
<Tooltip
anchor={
<Icon
name={icons.TAGS}
size={20}
/>
}
tooltip={
<MovieTagsConnector movieId={id} />
}
position={tooltipPositions.BOTTOM}
/>
</span>
}
</div>
</div>
<div className={styles.details}>
{
ratings.tmdb ?
<span className={styles.rating}>
<TmdbRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
{
ratings.imdb ?
<span className={styles.rating}>
<ImdbRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
{
ratings.rottenTomatoes ?
<span className={styles.rating}>
<RottenTomatoRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
{
ratings.trakt ?
<span className={styles.rating}>
<TraktRating
ratings={ratings}
iconSize={20}
/>
</span> :
null
}
</div>
<div className={styles.detailsLabels}>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Path')}
size={sizes.LARGE}
>
<span className={styles.path}>
{path}
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Status')}
title={statusDetails.message}
kind={kinds.DELETE}
size={sizes.LARGE}
>
<span className={styles.statusName}>
<MovieStatusLabel
status={status}
hasMovieFiles={hasMovieFiles}
monitored={monitored}
isAvailable={isAvailable}
queueItem={queueItem}
/>
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('QualityProfile')}
size={sizes.LARGE}
>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
}
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Size')}
size={sizes.LARGE}
>
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</InfoLabel>
{
collection ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Collection')}
size={sizes.LARGE}
>
<div className={styles.collection}>
<MovieCollectionLabel
tmdbId={collection.tmdbId}
/>
</div>
</InfoLabel> :
null
}
{
originalLanguage?.name && !isSmallScreen ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<span className={styles.originalLanguage}>
{originalLanguage.name}
</span>
</InfoLabel> :
null
}
{
studio && !isSmallScreen ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Studio')}
size={sizes.LARGE}
>
<span className={styles.studio}>
{studio}
</span>
</InfoLabel> :
null
}
{
genres.length && !isSmallScreen ?
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Genres')}
size={sizes.LARGE}
>
<MovieGenres className={styles.genres} genres={genres} />
</InfoLabel> :
null
}
</div>
<Measure onMeasure={this.onMeasure}>
<div className={styles.overview}>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={overview}
/>
</div>
</Measure>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{
!isFetching && movieFilesError ?
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieFilesFailed')}
</Alert> :
null
}
{
!isFetching && movieCreditsError ?
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieCreditsFailed')}
</Alert> :
null
}
{
!isFetching && extraFilesError ?
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieExtraFilesFailed')}
</Alert> :
null
}
<FieldSet legend={translate('Files')}>
<MovieFileEditorTable
movieId={id}
/>
<ExtraFileTable
movieId={id}
/>
</FieldSet>
<FieldSet legend={translate('Cast')}>
<MovieCastPosters
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<FieldSet legend={translate('Crew')}>
<MovieCrewPosters
isSmallScreen={isSmallScreen}
/>
</FieldSet>
<FieldSet legend={translate('Titles')}>
<MovieTitlesTable
movieId={id}
/>
</FieldSet>
</div>
<OrganizePreviewModal
isOpen={isOrganizeModalOpen}
movieId={id}
onModalClose={this.onOrganizeModalClose}
/>
<EditMovieModal
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={this.onEditMovieModalClose}
onDeleteMoviePress={this.onDeleteMoviePress}
/>
<MovieHistoryModal
isOpen={isMovieHistoryModalOpen}
movieId={id}
onModalClose={this.onMovieHistoryModalClose}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={this.onDeleteMovieModalClose}
/>
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
movieId={id}
title={title}
folder={path}
initialSortKey="relativePath"
initialSortDirection={sortDirections.ASCENDING}
showMovie={false}
allowMovieChange={false}
showDelete={true}
showImportMode={false}
modalTitle={translate('ManageFiles')}
onModalClose={this.onInteractiveImportModalClose}
/>
<MovieInteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
movieId={id}
movieTitle={title}
onModalClose={this.onInteractiveSearchModalClose}
/>
</PageContentBody>
</PageContent>
);
}
}
MovieDetails.propTypes = {
id: PropTypes.number.isRequired,
tmdbId: PropTypes.number.isRequired,
imdbId: PropTypes.string,
title: PropTypes.string.isRequired,
originalTitle: PropTypes.string,
year: PropTypes.number.isRequired,
runtime: PropTypes.number.isRequired,
certification: PropTypes.string,
ratings: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
status: PropTypes.string.isRequired,
studio: PropTypes.string,
originalLanguage: PropTypes.object,
genres: PropTypes.arrayOf(PropTypes.string).isRequired,
collection: PropTypes.object,
youTubeTrailerId: PropTypes.string,
isAvailable: PropTypes.bool.isRequired,
inCinemas: PropTypes.string,
physicalRelease: PropTypes.string,
digitalRelease: PropTypes.string,
overview: PropTypes.string.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
movieFilesError: PropTypes.object,
movieCreditsError: PropTypes.object,
extraFilesError: PropTypes.object,
hasMovieFiles: PropTypes.bool.isRequired,
previousMovie: PropTypes.object.isRequired,
nextMovie: PropTypes.object.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired,
onGoToMovie: PropTypes.func.isRequired,
queueItem: PropTypes.object,
movieRuntimeFormat: PropTypes.string.isRequired
};
MovieDetails.defaultProps = {
genres: [],
statistics: {},
tags: [],
isSaving: false
};
export default MovieDetails;

View File

@@ -0,0 +1,981 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import TextTruncate from 'react-text-truncate';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import * as commandNames from 'Commands/commandNames';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import ImdbRating from 'Components/ImdbRating';
import InfoLabel from 'Components/InfoLabel';
import IconButton from 'Components/Link/IconButton';
import Marquee from 'Components/Marquee';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import RottenTomatoRating from 'Components/RottenTomatoRating';
import TmdbRating from 'Components/TmdbRating';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import TraktRating from 'Components/TraktRating';
import useMeasure from 'Helpers/Hooks/useMeasure';
import usePrevious from 'Helpers/Hooks/usePrevious';
import {
icons,
kinds,
sizes,
sortDirections,
tooltipPositions,
} from 'Helpers/Props';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import DeleteMovieModal from 'Movie/Delete/DeleteMovieModal';
import EditMovieModal from 'Movie/Edit/EditMovieModal';
import getMovieStatusDetails from 'Movie/getMovieStatusDetails';
import MovieHistoryModal from 'Movie/History/MovieHistoryModal';
import { Image, Statistics } from 'Movie/Movie';
import MovieCollectionLabel from 'Movie/MovieCollectionLabel';
import MovieGenres from 'Movie/MovieGenres';
import MoviePoster from 'Movie/MoviePoster';
import MovieInteractiveSearchModal from 'Movie/Search/MovieInteractiveSearchModal';
import useMovie from 'Movie/useMovie';
import MovieFileEditorTable from 'MovieFile/Editor/MovieFileEditorTable';
import ExtraFileTable from 'MovieFile/Extras/ExtraFileTable';
import OrganizePreviewModal from 'Organize/OrganizePreviewModal';
import QualityProfileName from 'Settings/Profiles/Quality/QualityProfileName';
import { executeCommand } from 'Store/Actions/commandActions';
import {
clearExtraFiles,
fetchExtraFiles,
} from 'Store/Actions/extraFileActions';
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
import {
clearMovieCredits,
fetchMovieCredits,
} from 'Store/Actions/movieCreditsActions';
import {
clearMovieFiles,
fetchMovieFiles,
} from 'Store/Actions/movieFileActions';
import {
clearQueueDetails,
fetchQueueDetails,
} from 'Store/Actions/queueActions';
import {
cancelFetchReleases,
clearReleases,
} from 'Store/Actions/releaseActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import { fetchImportListSchema } from 'Store/Actions/Settings/importLists';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import fonts from 'Styles/Variables/fonts';
import sortByProp from 'Utilities/Array/sortByProp';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import formatRuntime from 'Utilities/Date/formatRuntime';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import formatBytes from 'Utilities/Number/formatBytes';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import MovieCastPosters from './Credits/Cast/MovieCastPosters';
import MovieCrewPosters from './Credits/Crew/MovieCrewPosters';
import MovieDetailsLinks from './MovieDetailsLinks';
import MovieReleaseDates from './MovieReleaseDates';
import MovieStatusLabel from './MovieStatusLabel';
import MovieTags from './MovieTags';
import MovieTitlesTable from './Titles/MovieTitlesTable';
import styles from './MovieDetails.css';
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images: Image[]) {
const image = images.find((image) => image.coverType === 'fanart');
return image?.url ?? image?.remoteUrl;
}
function createMovieFilesSelector() {
return createSelector(
(state: AppState) => state.movieFiles,
({ items, isFetching, isPopulated, error }) => {
const hasMovieFiles = !!items.length;
return {
isMovieFilesFetching: isFetching,
isMovieFilesPopulated: isPopulated,
movieFilesError: error,
hasMovieFiles,
};
}
);
}
function createExtraFilesSelector() {
return createSelector(
(state: AppState) => state.extraFiles,
({ isFetching, isPopulated, error }) => {
return {
isExtraFilesFetching: isFetching,
isExtraFilesPopulated: isPopulated,
extraFilesError: error,
};
}
);
}
function createMovieCreditsSelector() {
return createSelector(
(state: AppState) => state.movieCredits,
({ isFetching, isPopulated, error }) => {
return {
isMovieCreditsFetching: isFetching,
isMovieCreditsPopulated: isPopulated,
movieCreditsError: error,
};
}
);
}
interface MovieDetailsProps {
movieId: number;
}
function MovieDetails({ movieId }: MovieDetailsProps) {
const dispatch = useDispatch();
const history = useHistory();
const movie = useMovie(movieId);
const allMovies = useSelector(createAllMoviesSelector());
const { isMovieFilesFetching, movieFilesError, hasMovieFiles } = useSelector(
createMovieFilesSelector()
);
const { isExtraFilesFetching, extraFilesError } = useSelector(
createExtraFilesSelector()
);
const { isMovieCreditsFetching, movieCreditsError } = useSelector(
createMovieCreditsSelector()
);
const { movieRuntimeFormat } = useSelector(createUISettingsSelector());
const isSidebarVisible = useSelector(
(state: AppState) => state.app.isSidebarVisible
);
const { isSmallScreen } = useSelector(createDimensionsSelector());
const commands = useSelector(createCommandsSelector());
const { isRefreshing, isRenaming, isSearching } = useMemo(() => {
const movieRefreshingCommand = findCommand(commands, {
name: commandNames.REFRESH_MOVIE,
});
const isMovieRefreshingCommandExecuting = isCommandExecuting(
movieRefreshingCommand
);
const allMoviesRefreshing =
isMovieRefreshingCommandExecuting &&
!movieRefreshingCommand?.body.movieIds?.length;
const isMovieRefreshing =
isMovieRefreshingCommandExecuting &&
movieRefreshingCommand?.body.movieIds?.includes(movieId);
const isSearchingExecuting = isCommandExecuting(
findCommand(commands, {
name: commandNames.MOVIE_SEARCH,
movieIds: [movieId],
})
);
const isRenamingFiles = isCommandExecuting(
findCommand(commands, {
name: commandNames.RENAME_FILES,
movieId,
})
);
const isRenamingMovieCommand = findCommand(commands, {
name: commandNames.RENAME_MOVIE,
});
const isRenamingMovie =
isCommandExecuting(isRenamingMovieCommand) &&
isRenamingMovieCommand?.body?.movieIds?.includes(movieId);
return {
isRefreshing: isMovieRefreshing || allMoviesRefreshing,
isRenaming: isRenamingFiles || isRenamingMovie,
isSearching: isSearchingExecuting,
};
}, [movieId, commands]);
const { nextMovie, previousMovie } = useMemo(() => {
const sortedMovies = [...allMovies].sort(sortByProp('sortTitle'));
const movieIndex = sortedMovies.findIndex((movie) => movie.id === movieId);
if (movieIndex === -1) {
return {
nextMovie: undefined,
previousMovie: undefined,
};
}
const nextMovie = sortedMovies[movieIndex + 1] ?? sortedMovies[0];
const previousMovie =
sortedMovies[movieIndex - 1] ?? sortedMovies[sortedMovies.length - 1];
return {
nextMovie: {
title: nextMovie.title,
titleSlug: nextMovie.titleSlug,
},
previousMovie: {
title: previousMovie.title,
titleSlug: previousMovie.titleSlug,
},
};
}, [movieId, allMovies]);
const touchStart = useRef<number | null>(null);
const [isOrganizeModalOpen, setIsOrganizeModalOpen] = useState(false);
const [isManageMoviesModalOpen, setIsManageMoviesModalOpen] = useState(false);
const [isInteractiveSearchModalOpen, setIsInteractiveSearchModalOpen] =
useState(false);
const [isEditMovieModalOpen, setIsEditMovieModalOpen] = useState(false);
const [isDeleteMovieModalOpen, setIsDeleteMovieModalOpen] = useState(false);
const [isMovieHistoryModalOpen, setIsMovieHistoryModalOpen] = useState(false);
const [titleRef, { width: titleWidth }] = useMeasure();
const [overviewRef, { height: overviewHeight }] = useMeasure();
const wasRefreshing = usePrevious(isRefreshing);
const wasRenaming = usePrevious(isRenaming);
const handleOrganizePress = useCallback(() => {
setIsOrganizeModalOpen(true);
}, []);
const handleOrganizeModalClose = useCallback(() => {
setIsOrganizeModalOpen(false);
}, []);
const handleManageMoviesPress = useCallback(() => {
setIsManageMoviesModalOpen(true);
}, []);
const handleManageMoviesModalClose = useCallback(() => {
setIsManageMoviesModalOpen(false);
}, []);
const handleInteractiveSearchPress = useCallback(() => {
setIsInteractiveSearchModalOpen(true);
}, []);
const handleInteractiveSearchModalClose = useCallback(() => {
setIsInteractiveSearchModalOpen(false);
}, []);
const handleEditMoviePress = useCallback(() => {
setIsEditMovieModalOpen(true);
}, []);
const handleEditMovieModalClose = useCallback(() => {
setIsEditMovieModalOpen(false);
}, []);
const handleDeleteMoviePress = useCallback(() => {
setIsEditMovieModalOpen(false);
setIsDeleteMovieModalOpen(true);
}, []);
const handleDeleteMovieModalClose = useCallback(() => {
setIsDeleteMovieModalOpen(false);
}, []);
const handleMovieHistoryPress = useCallback(() => {
setIsMovieHistoryModalOpen(true);
}, []);
const handleMovieHistoryModalClose = useCallback(() => {
setIsMovieHistoryModalOpen(false);
}, []);
const handleMonitorTogglePress = useCallback(
(value: boolean) => {
dispatch(
toggleMovieMonitored({
movieId,
monitored: value,
})
);
},
[movieId, dispatch]
);
const handleRefreshPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.REFRESH_MOVIE,
movieIds: [movieId],
})
);
}, [movieId, dispatch]);
const handleSearchPress = useCallback(() => {
dispatch(
executeCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: [movieId],
})
);
}, [movieId, dispatch]);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touches = event.touches;
const currentTouch = touches[0].pageX;
const touchY = touches[0].pageY;
// Only change when swipe is on header, we need horizontal scroll on tables
if (touchY > 470) {
return;
}
if (touches.length !== 1) {
return;
}
if (
currentTouch < 50 ||
isSidebarVisible ||
isOrganizeModalOpen ||
isEditMovieModalOpen ||
isDeleteMovieModalOpen ||
isManageMoviesModalOpen ||
isInteractiveSearchModalOpen ||
isMovieHistoryModalOpen
) {
return;
}
touchStart.current = currentTouch;
},
[
isSidebarVisible,
isOrganizeModalOpen,
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isManageMoviesModalOpen,
isInteractiveSearchModalOpen,
isMovieHistoryModalOpen,
]
);
const handleTouchEnd = useCallback(
(event: TouchEvent) => {
const touches = event.changedTouches;
const currentTouch = touches[0].pageX;
if (!touchStart.current) {
return;
}
if (
currentTouch > touchStart.current &&
currentTouch - touchStart.current > 100 &&
previousMovie !== undefined
) {
history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`));
} else if (
currentTouch < touchStart.current &&
touchStart.current - currentTouch > 100 &&
nextMovie !== undefined
) {
history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`));
}
touchStart.current = null;
},
[previousMovie, nextMovie, history]
);
const handleTouchCancel = useCallback(() => {
touchStart.current = null;
}, []);
const handleTouchMove = useCallback(() => {
if (!touchStart.current) {
return;
}
}, []);
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (
isOrganizeModalOpen ||
isManageMoviesModalOpen ||
isInteractiveSearchModalOpen ||
isEditMovieModalOpen ||
isDeleteMovieModalOpen ||
isMovieHistoryModalOpen
) {
return;
}
if (event.composedPath && event.composedPath().length === 4) {
if (event.key === 'ArrowLeft' && previousMovie !== undefined) {
history.push(getPathWithUrlBase(`/movie/${previousMovie.titleSlug}`));
}
if (event.key === 'ArrowRight' && nextMovie !== undefined) {
history.push(getPathWithUrlBase(`/movie/${nextMovie.titleSlug}`));
}
}
},
[
isOrganizeModalOpen,
isManageMoviesModalOpen,
isInteractiveSearchModalOpen,
isEditMovieModalOpen,
isDeleteMovieModalOpen,
isMovieHistoryModalOpen,
previousMovie,
nextMovie,
history,
]
);
const populate = useCallback(() => {
dispatch(fetchMovieFiles({ movieId }));
dispatch(fetchExtraFiles({ movieId }));
dispatch(fetchMovieCredits({ movieId }));
dispatch(fetchQueueDetails({ movieId }));
dispatch(fetchImportListSchema());
dispatch(fetchRootFolders());
}, [movieId, dispatch]);
useEffect(() => {
populate();
}, [populate]);
useEffect(() => {
registerPagePopulator(populate, ['movieUpdated']);
return () => {
unregisterPagePopulator(populate);
dispatch(clearMovieFiles());
dispatch(clearExtraFiles());
dispatch(clearMovieCredits());
dispatch(clearQueueDetails());
dispatch(cancelFetchReleases());
dispatch(clearReleases());
};
}, [populate, dispatch]);
useEffect(() => {
if ((!isRefreshing && wasRefreshing) || (!isRenaming && wasRenaming)) {
populate();
}
}, [isRefreshing, wasRefreshing, isRenaming, wasRenaming, populate]);
useEffect(() => {
window.addEventListener('touchstart', handleTouchStart);
window.addEventListener('touchend', handleTouchEnd);
window.addEventListener('touchcancel', handleTouchCancel);
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('touchstart', handleTouchStart);
window.removeEventListener('touchend', handleTouchEnd);
window.removeEventListener('touchcancel', handleTouchCancel);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
handleTouchStart,
handleTouchEnd,
handleTouchCancel,
handleTouchMove,
handleKeyUp,
]);
if (!movie) {
return null;
}
const {
id,
tmdbId,
imdbId,
title,
originalTitle,
year,
inCinemas,
physicalRelease,
digitalRelease,
runtime,
certification,
ratings,
path,
statistics = {} as Statistics,
qualityProfileId,
monitored,
studio,
originalLanguage,
genres = [],
collection,
overview,
status,
youTubeTrailerId,
isAvailable,
images,
tags,
isSaving = false,
} = movie;
const { sizeOnDisk = 0 } = statistics;
const statusDetails = getMovieStatusDetails(status);
const fanartUrl = getFanartUrl(images);
const isFetching =
isMovieFilesFetching || isExtraFilesFetching || isMovieCreditsFetching;
const marqueeWidth = isSmallScreen ? titleWidth : titleWidth - 150;
const titleWithYear = `${title}${year > 0 ? ` (${year})` : ''}`;
return (
<PageContent title={titleWithYear}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('RefreshAndScan')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title={translate('RefreshInformationAndScanDisk')}
isSpinning={isRefreshing}
onPress={handleRefreshPress}
/>
<PageToolbarButton
label={translate('SearchMovie')}
iconName={icons.SEARCH}
isSpinning={isSearching}
title={undefined}
onPress={handleSearchPress}
/>
<PageToolbarButton
label={translate('InteractiveSearch')}
iconName={icons.INTERACTIVE}
isSpinning={isSearching}
title={undefined}
onPress={handleInteractiveSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('PreviewRename')}
iconName={icons.ORGANIZE}
isDisabled={!hasMovieFiles}
onPress={handleOrganizePress}
/>
<PageToolbarButton
label={translate('ManageFiles')}
iconName={icons.MOVIE_FILE}
onPress={handleManageMoviesPress}
/>
<PageToolbarButton
label={translate('History')}
iconName={icons.HISTORY}
onPress={handleMovieHistoryPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label={translate('Edit')}
iconName={icons.EDIT}
onPress={handleEditMoviePress}
/>
<PageToolbarButton
label={translate('Delete')}
iconName={icons.DELETE}
onPress={handleDeleteMoviePress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBody innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={
fanartUrl ? { backgroundImage: `url(${fanartUrl})` } : undefined
}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<MoviePoster
className={styles.poster}
images={images}
size={500}
lazy={false}
/>
<div className={styles.info}>
<div ref={titleRef} className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={handleMonitorTogglePress}
/>
</div>
<div className={styles.title} style={{ width: marqueeWidth }}>
<Marquee text={title} title={originalTitle} />
</div>
</div>
<div className={styles.movieNavigationButtons}>
{previousMovie ? (
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={translate('MovieDetailsGoTo', {
title: previousMovie.title,
})}
to={`/movie/${previousMovie.titleSlug}`}
/>
) : null}
{nextMovie ? (
<IconButton
className={styles.movieNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={translate('MovieDetailsGoTo', {
title: nextMovie.title,
})}
to={`/movie/${nextMovie.titleSlug}`}
/>
) : null}
</div>
</div>
<div className={styles.details}>
<div>
{certification ? (
<span
className={styles.certification}
title={translate('Certification')}
>
{certification}
</span>
) : null}
<span className={styles.year}>
<Popover
anchor={
year > 0 ? (
year
) : (
<Icon
name={icons.WARNING}
kind={kinds.WARNING}
size={20}
/>
)
}
title={translate('ReleaseDates')}
body={
<MovieReleaseDates
tmdbId={tmdbId}
inCinemas={inCinemas}
digitalRelease={digitalRelease}
physicalRelease={physicalRelease}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
{runtime ? (
<span
className={styles.runtime}
title={translate('Runtime')}
>
{formatRuntime(runtime, movieRuntimeFormat)}
</span>
) : null}
<span className={styles.links}>
<Tooltip
anchor={<Icon name={icons.EXTERNAL_LINK} size={20} />}
tooltip={
<MovieDetailsLinks
tmdbId={tmdbId}
imdbId={imdbId}
youTubeTrailerId={youTubeTrailerId}
/>
}
position={tooltipPositions.BOTTOM}
/>
</span>
{!!tags.length && (
<span>
<Tooltip
anchor={<Icon name={icons.TAGS} size={20} />}
tooltip={<MovieTags movieId={id} />}
position={tooltipPositions.BOTTOM}
/>
</span>
)}
</div>
</div>
<div className={styles.details}>
{ratings.tmdb ? (
<span className={styles.rating}>
<TmdbRating ratings={ratings} iconSize={20} />
</span>
) : null}
{ratings.imdb ? (
<span className={styles.rating}>
<ImdbRating ratings={ratings} iconSize={20} />
</span>
) : null}
{ratings.rottenTomatoes ? (
<span className={styles.rating}>
<RottenTomatoRating ratings={ratings} iconSize={20} />
</span>
) : null}
{ratings.trakt ? (
<span className={styles.rating}>
<TraktRating ratings={ratings} iconSize={20} />
</span>
) : null}
</div>
<div>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Path')}
size={sizes.LARGE}
>
<span className={styles.path}>{path}</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Status')}
title={statusDetails.message}
size={sizes.LARGE}
>
<span className={styles.statusName}>
<MovieStatusLabel
movieId={id}
monitored={monitored}
isAvailable={isAvailable}
hasMovieFiles={hasMovieFiles}
status={status}
/>
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('QualityProfile')}
size={sizes.LARGE}
>
<span className={styles.qualityProfileName}>
<QualityProfileName qualityProfileId={qualityProfileId} />
</span>
</InfoLabel>
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Size')}
size={sizes.LARGE}
>
<span className={styles.sizeOnDisk}>
{formatBytes(sizeOnDisk)}
</span>
</InfoLabel>
{collection ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Collection')}
size={sizes.LARGE}
>
<div className={styles.collection}>
<MovieCollectionLabel tmdbId={collection.tmdbId} />
</div>
</InfoLabel>
) : null}
{originalLanguage?.name && !isSmallScreen ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('OriginalLanguage')}
size={sizes.LARGE}
>
<span className={styles.originalLanguage}>
{originalLanguage.name}
</span>
</InfoLabel>
) : null}
{studio && !isSmallScreen ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Studio')}
size={sizes.LARGE}
>
<span className={styles.studio}>{studio}</span>
</InfoLabel>
) : null}
{genres.length && !isSmallScreen ? (
<InfoLabel
className={styles.detailsInfoLabel}
name={translate('Genres')}
size={sizes.LARGE}
>
<MovieGenres className={styles.genres} genres={genres} />
</InfoLabel>
) : null}
</div>
<div ref={overviewRef} className={styles.overview}>
<TextTruncate
line={Math.floor(
overviewHeight / (defaultFontSize * lineHeight)
)}
text={overview}
/>
</div>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{!isFetching && movieFilesError ? (
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieFilesFailed')}
</Alert>
) : null}
{!isFetching && extraFilesError ? (
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieExtraFilesFailed')}
</Alert>
) : null}
{!isFetching && movieCreditsError ? (
<Alert kind={kinds.DANGER}>
{translate('LoadingMovieCreditsFailed')}
</Alert>
) : null}
<FieldSet legend={translate('Files')}>
<MovieFileEditorTable movieId={id} />
<ExtraFileTable movieId={id} />
</FieldSet>
<FieldSet legend={translate('Cast')}>
<MovieCastPosters isSmallScreen={isSmallScreen} />
</FieldSet>
<FieldSet legend={translate('Crew')}>
<MovieCrewPosters isSmallScreen={isSmallScreen} />
</FieldSet>
<FieldSet legend={translate('Titles')}>
<MovieTitlesTable movieId={id} />
</FieldSet>
</div>
<OrganizePreviewModal
isOpen={isOrganizeModalOpen}
movieId={id}
onModalClose={handleOrganizeModalClose}
/>
<EditMovieModal
isOpen={isEditMovieModalOpen}
movieId={id}
onModalClose={handleEditMovieModalClose}
onDeleteMoviePress={handleDeleteMoviePress}
/>
<MovieHistoryModal
isOpen={isMovieHistoryModalOpen}
movieId={id}
onModalClose={handleMovieHistoryModalClose}
/>
<DeleteMovieModal
isOpen={isDeleteMovieModalOpen}
movieId={id}
onModalClose={handleDeleteMovieModalClose}
/>
<InteractiveImportModal
isOpen={isManageMoviesModalOpen}
movieId={id}
title={title}
folder={path}
initialSortKey="relativePath"
initialSortDirection={sortDirections.ASCENDING}
showMovie={false}
allowMovieChange={false}
showDelete={true}
showImportMode={false}
modalTitle={translate('ManageFiles')}
onModalClose={handleManageMoviesModalClose}
/>
<MovieInteractiveSearchModal
isOpen={isInteractiveSearchModalOpen}
movieId={id}
movieTitle={title}
onModalClose={handleInteractiveSearchModalClose}
/>
</PageContentBody>
</PageContent>
);
}
export default MovieDetails;

View File

@@ -1,334 +0,0 @@
import { push } from 'connected-react-router';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import { executeCommand } from 'Store/Actions/commandActions';
import { clearExtraFiles, fetchExtraFiles } from 'Store/Actions/extraFileActions';
import { toggleMovieMonitored } from 'Store/Actions/movieActions';
import { clearMovieCredits, fetchMovieCredits } from 'Store/Actions/movieCreditsActions';
import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchImportListSchema } from 'Store/Actions/settingsActions';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import MovieDetails from './MovieDetails';
const selectMovieFiles = createSelector(
(state) => state.movieFiles,
(movieFiles) => {
const {
items,
isFetching,
isPopulated,
error
} = movieFiles;
const hasMovieFiles = !!items.length;
return {
isMovieFilesFetching: isFetching,
isMovieFilesPopulated: isPopulated,
movieFilesError: error,
hasMovieFiles
};
}
);
const selectMovieCredits = createSelector(
(state) => state.movieCredits,
(movieCredits) => {
const {
isFetching,
isPopulated,
error
} = movieCredits;
return {
isMovieCreditsFetching: isFetching,
isMovieCreditsPopulated: isPopulated,
movieCreditsError: error
};
}
);
const selectExtraFiles = createSelector(
(state) => state.extraFiles,
(extraFiles) => {
const {
isFetching,
isPopulated,
error
} = extraFiles;
return {
isExtraFilesFetching: isFetching,
isExtraFilesPopulated: isPopulated,
extraFilesError: error
};
}
);
function createMapStateToProps() {
return createSelector(
(state, { titleSlug }) => titleSlug,
selectMovieFiles,
selectMovieCredits,
selectExtraFiles,
createAllMoviesSelector(),
createCommandsSelector(),
createDimensionsSelector(),
(state) => state.queue.details.items,
(state) => state.app.isSidebarVisible,
(state) => state.settings.ui.item.movieRuntimeFormat,
(titleSlug, movieFiles, movieCredits, extraFiles, allMovies, commands, dimensions, queueItems, isSidebarVisible, movieRuntimeFormat) => {
const sortedMovies = _.orderBy(allMovies, 'sortTitle');
const movieIndex = _.findIndex(sortedMovies, { titleSlug });
const movie = sortedMovies[movieIndex];
if (!movie) {
return {};
}
const {
isMovieFilesFetching,
isMovieFilesPopulated,
movieFilesError,
hasMovieFiles
} = movieFiles;
const {
isMovieCreditsFetching,
isMovieCreditsPopulated,
movieCreditsError
} = movieCredits;
const {
isExtraFilesFetching,
isExtraFilesPopulated,
extraFilesError
} = extraFiles;
const previousMovie = sortedMovies[movieIndex - 1] || _.last(sortedMovies);
const nextMovie = sortedMovies[movieIndex + 1] || _.first(sortedMovies);
const isMovieRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_MOVIE, movieIds: [movie.id] }));
const movieRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_MOVIE });
const allMoviesRefreshing = (
isCommandExecuting(movieRefreshingCommand) &&
!movieRefreshingCommand.body.movieId
);
const isRefreshing = isMovieRefreshing || allMoviesRefreshing;
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.MOVIE_SEARCH, movieIds: [movie.id] }));
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, movieId: movie.id }));
const isRenamingMovieCommand = findCommand(commands, { name: commandNames.RENAME_MOVIE });
const isRenamingMovie = (
isCommandExecuting(isRenamingMovieCommand) &&
isRenamingMovieCommand.body.movieIds.indexOf(movie.id) > -1
);
const isFetching = isMovieFilesFetching || isMovieCreditsFetching || isExtraFilesFetching;
const isPopulated = isMovieFilesPopulated && isMovieCreditsPopulated && isExtraFilesPopulated;
const alternateTitles = _.reduce(movie.alternateTitles, (acc, alternateTitle) => {
acc.push(alternateTitle.title);
return acc;
}, []);
const queueItem = queueItems.find((item) => item.movieId === movie.id);
return {
...movie,
alternateTitles,
isMovieRefreshing,
allMoviesRefreshing,
isRefreshing,
isSearching,
isRenamingFiles,
isRenamingMovie,
isFetching,
isPopulated,
movieFilesError,
movieCreditsError,
extraFilesError,
hasMovieFiles,
previousMovie,
nextMovie,
isSmallScreen: dimensions.isSmallScreen,
isSidebarVisible,
queueItem,
movieRuntimeFormat
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchMovieFiles({ movieId }) {
dispatch(fetchMovieFiles({ movieId }));
},
dispatchClearMovieFiles() {
dispatch(clearMovieFiles());
},
dispatchFetchMovieCredits({ movieId }) {
dispatch(fetchMovieCredits({ movieId }));
},
dispatchClearMovieCredits() {
dispatch(clearMovieCredits());
},
dispatchFetchExtraFiles({ movieId }) {
dispatch(fetchExtraFiles({ movieId }));
},
dispatchClearExtraFiles() {
dispatch(clearExtraFiles());
},
dispatchFetchQueueDetails({ movieId }) {
dispatch(fetchQueueDetails({ movieId }));
},
dispatchClearQueueDetails() {
dispatch(clearQueueDetails());
},
dispatchFetchImportListSchema() {
dispatch(fetchImportListSchema());
},
dispatchToggleMovieMonitored(payload) {
dispatch(toggleMovieMonitored(payload));
},
dispatchExecuteCommand(payload) {
dispatch(executeCommand(payload));
},
onGoToMovie(titleSlug) {
dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
}
};
}
class MovieDetailsConnector extends Component {
//
// Lifecycle
componentDidMount() {
registerPagePopulator(this.populate, ['movieUpdated']);
this.populate();
}
componentDidUpdate(prevProps) {
const {
id,
isMovieRefreshing,
allMoviesRefreshing,
isRenamingFiles,
isRenamingMovie
} = this.props;
if (
(prevProps.isMovieRefreshing && !isMovieRefreshing) ||
(prevProps.allMoviesRefreshing && !allMoviesRefreshing) ||
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingMovie && !isRenamingMovie)
) {
this.populate();
}
// If the id has changed we need to clear the episodes/episode
// files and fetch from the server.
if (prevProps.id !== id) {
this.unpopulate();
this.populate();
}
}
componentWillUnmount() {
unregisterPagePopulator(this.populate);
this.unpopulate();
}
//
// Control
populate = () => {
const movieId = this.props.id;
this.props.dispatchFetchMovieFiles({ movieId });
this.props.dispatchFetchExtraFiles({ movieId });
this.props.dispatchFetchMovieCredits({ movieId });
this.props.dispatchFetchQueueDetails({ movieId });
this.props.dispatchFetchImportListSchema();
};
unpopulate = () => {
this.props.dispatchClearMovieFiles();
this.props.dispatchClearExtraFiles();
this.props.dispatchClearMovieCredits();
this.props.dispatchClearQueueDetails();
};
//
// Listeners
onMonitorTogglePress = (monitored) => {
this.props.dispatchToggleMovieMonitored({
movieId: this.props.id,
monitored
});
};
onRefreshPress = () => {
this.props.dispatchExecuteCommand({
name: commandNames.REFRESH_MOVIE,
movieIds: [this.props.id]
});
};
onSearchPress = () => {
this.props.dispatchExecuteCommand({
name: commandNames.MOVIE_SEARCH,
movieIds: [this.props.id]
});
};
//
// Render
render() {
return (
<MovieDetails
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
MovieDetailsConnector.propTypes = {
id: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
isMovieRefreshing: PropTypes.bool.isRequired,
allMoviesRefreshing: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingMovie: PropTypes.bool.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
dispatchFetchMovieFiles: PropTypes.func.isRequired,
dispatchClearMovieFiles: PropTypes.func.isRequired,
dispatchFetchExtraFiles: PropTypes.func.isRequired,
dispatchClearExtraFiles: PropTypes.func.isRequired,
dispatchFetchMovieCredits: PropTypes.func.isRequired,
dispatchClearMovieCredits: PropTypes.func.isRequired,
dispatchToggleMovieMonitored: PropTypes.func.isRequired,
dispatchFetchQueueDetails: PropTypes.func.isRequired,
dispatchClearQueueDetails: PropTypes.func.isRequired,
dispatchFetchImportListSchema: PropTypes.func.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired,
onGoToMovie: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieDetailsConnector);

View File

@@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useHistory, useParams } from 'react-router';
import NotFound from 'Components/NotFound';
import usePrevious from 'Helpers/Hooks/usePrevious';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import translate from 'Utilities/String/translate';
import MovieDetails from './MovieDetails';
function MovieDetailsPage() {
const allMovies = useSelector(createAllMoviesSelector());
const { titleSlug } = useParams<{ titleSlug: string }>();
const history = useHistory();
const movieIndex = allMovies.findIndex(
(movie) => movie.titleSlug === titleSlug
);
const previousIndex = usePrevious(movieIndex);
useEffect(() => {
if (
movieIndex === -1 &&
previousIndex !== -1 &&
previousIndex !== undefined
) {
history.push(`${window.Radarr.urlBase}/`);
}
}, [movieIndex, previousIndex, history]);
if (movieIndex === -1) {
return <NotFound message={translate('MovieCannotBeFound')} />;
}
return <MovieDetails movieId={allMovies[movieIndex].id} />;
}
export default MovieDetailsPage;

View File

@@ -1,125 +0,0 @@
import { push } from 'connected-react-router';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import NotFound from 'Components/NotFound';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import MovieDetailsConnector from './MovieDetailsConnector';
import styles from './MovieDetails.css';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.movies,
(match, movies) => {
const titleSlug = match.params.titleSlug;
const {
isFetching,
isPopulated,
error,
items
} = movies;
const movieIndex = _.findIndex(items, { titleSlug });
if (movieIndex > -1) {
return {
isFetching,
isPopulated,
titleSlug
};
}
return {
isFetching,
isPopulated,
error
};
}
);
}
const mapDispatchToProps = {
push,
fetchRootFolders
};
class MovieDetailsPageConnector extends Component {
//
// Lifecycle
componentDidMount() {
this.props.fetchRootFolders();
}
componentDidUpdate(prevProps) {
if (!this.props.titleSlug) {
this.props.push(`${window.Radarr.urlBase}/`);
return;
}
}
//
// Render
render() {
const {
titleSlug,
isFetching,
isPopulated,
error
} = this.props;
if (isFetching && !isPopulated) {
return (
<PageContent title={translate('Loading')}>
<PageContentBody>
<LoadingIndicator />
</PageContentBody>
</PageContent>
);
}
if (!isFetching && !!error) {
return (
<div className={styles.errorMessage}>
{getErrorMessage(error, translate('FailedToLoadMovieFromAPI'))}
</div>
);
}
if (!titleSlug) {
return (
<NotFound
message={translate('SorryThatMovieCannotBeFound')}
/>
);
}
return (
<MovieDetailsConnector
titleSlug={titleSlug}
/>
);
}
}
MovieDetailsPageConnector.propTypes = {
titleSlug: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
push: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(MovieDetailsPageConnector);

View File

@@ -1,13 +1,23 @@
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { MovieStatus } from 'Movie/Movie';
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
import Queue from 'typings/Queue';
import getQueueStatusText from 'Utilities/Movie/getQueueStatusText';
import firstCharToUpper from 'Utilities/String/firstCharToUpper';
import translate from 'Utilities/String/translate';
import styles from './MovieStatusLabel.css';
function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = false) {
function getMovieStatus(
status: MovieStatus,
isMonitored: boolean,
isAvailable: boolean,
hasFiles: boolean,
queueItem: Queue | null = null
) {
if (queueItem) {
const queueStatus = queueItem.status;
const queueState = queueItem.trackedDownloadStatus;
@@ -18,11 +28,11 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f
}
}
if (hasFile && !isMonitored) {
if (hasFiles && !isMonitored) {
return 'availNotMonitored';
}
if (hasFile) {
if (hasFiles) {
return 'ended';
}
@@ -30,34 +40,52 @@ function getMovieStatus(status, hasFile, isMonitored, isAvailable, queueItem = f
return 'deleted';
}
if (isAvailable && !isMonitored && !hasFile) {
if (isAvailable && !isMonitored && !hasFiles) {
return 'missingUnmonitored';
}
if (isAvailable && !hasFile) {
if (isAvailable && !hasFiles) {
return 'missingMonitored';
}
return 'continuing';
}
function MovieStatusLabel(props) {
const {
interface MovieStatusLabelProps {
movieId: number;
monitored: boolean;
isAvailable: boolean;
hasMovieFiles: boolean;
status: MovieStatus;
useLabel?: boolean;
}
function MovieStatusLabel({
movieId,
monitored,
isAvailable,
hasMovieFiles,
status,
useLabel = false,
}: MovieStatusLabelProps) {
const queueItem = useSelector(createQueueItemSelectorForHook(movieId));
let movieStatus = getMovieStatus(
status,
hasMovieFiles,
monitored,
isAvailable,
queueItem,
useLabel,
colorImpairedMode
} = props;
hasMovieFiles,
queueItem
);
let movieStatus = getMovieStatus(status, hasMovieFiles, monitored, isAvailable, queueItem);
let statusClass = movieStatus;
if (movieStatus === 'availNotMonitored' || movieStatus === 'ended') {
movieStatus = 'downloaded';
} else if (movieStatus === 'missingMonitored' || movieStatus === 'missingUnmonitored') {
} else if (
movieStatus === 'missingMonitored' ||
movieStatus === 'missingUnmonitored'
) {
movieStatus = 'missing';
} else if (movieStatus === 'continuing') {
movieStatus = 'notAvailable';
@@ -68,7 +96,7 @@ function MovieStatusLabel(props) {
}
if (useLabel) {
let kind = kinds.SUCCESS;
let kind: Kind = kinds.SUCCESS;
switch (statusClass) {
case 'queue':
@@ -93,11 +121,7 @@ function MovieStatusLabel(props) {
}
return (
<Label
kind={kind}
size={sizes.LARGE}
colorImpairedMode={colorImpairedMode}
>
<Label kind={kind} size={sizes.LARGE}>
{translate(firstCharToUpper(movieStatus))}
</Label>
);
@@ -105,6 +129,8 @@ function MovieStatusLabel(props) {
return (
<span
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
className={styles[statusClass]}
>
{translate(firstCharToUpper(movieStatus))}
@@ -112,19 +138,4 @@ function MovieStatusLabel(props) {
);
}
MovieStatusLabel.propTypes = {
status: PropTypes.string.isRequired,
hasMovieFiles: PropTypes.bool.isRequired,
monitored: PropTypes.bool.isRequired,
isAvailable: PropTypes.bool.isRequired,
queueItem: PropTypes.object,
useLabel: PropTypes.bool,
colorImpairedMode: PropTypes.bool
};
MovieStatusLabel.defaultProps = {
useLabel: false,
colorImpairedMode: false
};
export default MovieStatusLabel;

View File

@@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
function MovieTags({ tags }) {
return (
<div>
{
tags.map((tag) => {
return (
<Label
key={tag}
kind={kinds.INFO}
size={sizes.LARGE}
>
{tag}
</Label>
);
})
}
</div>
);
}
MovieTags.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default MovieTags;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import Label from 'Components/Label';
import { kinds, sizes } from 'Helpers/Props';
import useMovie from 'Movie/useMovie';
import useTags from 'Tags/useTags';
import sortByProp from 'Utilities/Array/sortByProp';
interface MovieTagsProps {
movieId: number;
}
function MovieTags({ movieId }: MovieTagsProps) {
const movie = useMovie(movieId)!;
const tagList = useTags();
const tags = movie.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag)
.sort(sortByProp('label'))
.map((tag) => tag.label);
return (
<div>
{tags.map((tag) => {
return (
<Label key={tag} kind={kinds.INFO} size={sizes.LARGE}>
{tag}
</Label>
);
})}
</div>
);
}
export default MovieTags;

View File

@@ -1,26 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createMovieSelector from 'Store/Selectors/createMovieSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import sortByProp from 'Utilities/Array/sortByProp';
import MovieTags from './MovieTags';
function createMapStateToProps() {
return createSelector(
createMovieSelector(),
createTagsSelector(),
(movie, tagList) => {
const tags = movie.tags
.map((tagId) => tagList.find((tag) => tag.id === tagId))
.filter((tag) => !!tag)
.sort(sortByProp('label'))
.map((tag) => tag.label);
return {
tags
};
}
);
}
export default connect(createMapStateToProps)(MovieTags);

View File

@@ -1,53 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import SearchMenuItem from 'Components/Menu/SearchMenuItem';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class MovieIndexSearchMenu extends Component {
render() {
const {
isDisabled,
onSearchPress
} = this.props;
return (
<Menu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<ToolbarMenuButton
iconName={icons.SEARCH}
text="Search"
isDisabled={isDisabled}
/>
<MenuContent>
<SearchMenuItem
name="missingMoviesSearch"
onPress={onSearchPress}
>
{translate('SearchMissing')}
</SearchMenuItem>
<SearchMenuItem
name="cutoffUnmetMoviesSearch"
onPress={onSearchPress}
>
{translate('SearchCutoffUnmet')}
</SearchMenuItem>
</MenuContent>
</Menu>
);
}
}
MovieIndexSearchMenu.propTypes = {
isDisabled: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired
};
export default MovieIndexSearchMenu;

View File

@@ -6,6 +6,7 @@ import React, {
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { SelectProvider } from 'App/SelectContext';
import ClientSideCollectionAppState from 'App/State/ClientSideCollectionAppState';
import MoviesAppState, { MovieIndexAppState } from 'App/State/MoviesAppState';
@@ -26,6 +27,7 @@ import { DESCENDING } from 'Helpers/Props/sortDirections';
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
import NoMovie from 'Movie/NoMovie';
import { executeCommand } from 'Store/Actions/commandActions';
import { fetchMovies } from 'Store/Actions/movieActions';
import {
setMovieFilter,
setMovieSort,
@@ -75,6 +77,8 @@ interface MovieIndexProps {
}
const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
const history = useHistory();
const {
isFetching,
isPopulated,
@@ -105,6 +109,12 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
);
const [isSelectMode, setIsSelectMode] = useState(false);
useEffect(() => {
if (history.action === 'PUSH') {
dispatch(fetchMovies());
}
}, [history, dispatch]);
useEffect(() => {
dispatch(fetchQueueDetails({ all: true }));
}, [dispatch]);

View File

@@ -14,13 +14,15 @@ function createMovieIndexItemSelector(movieId: number) {
(movie: Movie, qualityProfile, executingCommands: Command[]) => {
const isRefreshingMovie = executingCommands.some((command) => {
return (
command.name === REFRESH_MOVIE && command.body.movieId === movieId
command.name === REFRESH_MOVIE &&
command.body.movieIds?.includes(movieId)
);
});
const isSearchingMovie = executingCommands.some((command) => {
return (
command.name === MOVIE_SEARCH && command.body.movieId === movieId
command.name === MOVIE_SEARCH &&
command.body.movieIds?.includes(movieId)
);
});

View File

@@ -4,6 +4,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import { toggleCollectionMonitored } from 'Store/Actions/movieCollectionActions';
import { createCollectionSelectorForHook } from 'Store/Selectors/createCollectionSelector';
import MovieCollection from 'typings/MovieCollection';
import translate from 'Utilities/String/translate';
import styles from './MovieCollectionLabel.css';
interface MovieCollectionLabelProps {
@@ -16,7 +17,8 @@ function MovieCollectionLabel({ tmdbId }: MovieCollectionLabelProps) {
monitored,
title,
isSaving = false,
} = useSelector(createCollectionSelectorForHook(tmdbId)) as MovieCollection;
} = useSelector(createCollectionSelectorForHook(tmdbId)) ||
({} as MovieCollection);
const dispatch = useDispatch();
@@ -29,6 +31,10 @@ function MovieCollectionLabel({ tmdbId }: MovieCollectionLabelProps) {
[id, dispatch]
);
if (!id) {
return translate('Unknown');
}
return (
<div>
<MonitorToggleButton

View File

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

View File

@@ -86,18 +86,18 @@ class FileEditModalContentConnector extends Component {
real: real ? 1 : 0
};
const movieFileIds = [this.props.movieFileId];
this.props.dispatchUpdateMovieFiles({
movieFileIds,
languages,
indexerFlags,
edition,
releaseGroup,
quality: {
quality,
revision
}
files: [{
id: this.props.movieFileId,
languages,
indexerFlags,
edition,
releaseGroup,
quality: {
quality,
revision
}
}]
});
this.props.onModalClose(true);

View File

@@ -1 +1,11 @@
import ModelBase from 'App/ModelBase';
export type ExtraFileType = 'subtitle' | 'metadata' | 'other';
export interface ExtraFile extends ModelBase {
movieId: number;
movieFileId?: number;
relativePath: string;
extension: string;
type: ExtraFileType;
}

View File

@@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectLanguageModalContentConnector from './SelectLanguageModalContentConnector';
class SelectLanguageModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectLanguageModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectLanguageModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectLanguageModal;

View File

@@ -1,97 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import SelectLanguageModalContent from 'InteractiveImport/Language/SelectLanguageModalContent';
import { updateMovieFiles } from 'Store/Actions/movieFileActions';
import { fetchLanguages } from 'Store/Actions/settingsActions';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.languages,
(languages) => {
const {
isFetching,
isPopulated,
error,
items
} = languages;
const filterItems = ['Any', 'Original'];
const filteredLanguages = items.filter((lang) => !filterItems.includes(lang.name));
return {
isFetching,
isPopulated,
error,
items: filteredLanguages
};
}
);
}
const mapDispatchToProps = {
dispatchFetchLanguages: fetchLanguages,
dispatchupdateMovieFiles: updateMovieFiles
};
class SelectLanguageModalContentConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchLanguages();
}
};
//
// Listeners
onLanguageSelect = ({ languageIds }) => {
const languages = [];
languageIds.forEach((languageId) => {
const language = _.find(this.props.items,
(item) => item.id === parseInt(languageId));
if (language !== undefined) {
languages.push(language);
}
});
this.props.dispatchupdateMovieFiles({
movieFileIds: this.props.ids,
languages
});
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectLanguageModalContent
{...this.props}
onLanguageSelect={this.onLanguageSelect}
/>
);
}
}
SelectLanguageModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchLanguages: PropTypes.func.isRequired,
dispatchupdateMovieFiles: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectLanguageModalContentConnector);

View File

@@ -15,6 +15,7 @@ export interface MovieFile extends ModelBase {
languages: Language[];
quality: QualityModel;
customFormats: CustomFormat[];
customFormatScore: number;
indexerFlags: number;
mediaInfo: MediaInfo;
qualityCutoffNotMet: boolean;

View File

@@ -1,37 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Modal from 'Components/Modal/Modal';
import SelectQualityModalContentConnector from './SelectQualityModalContentConnector';
class SelectQualityModal extends Component {
//
// Render
render() {
const {
isOpen,
onModalClose,
...otherProps
} = this.props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<SelectQualityModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
}
SelectQualityModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectQualityModal;

View File

@@ -1,170 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
class SelectQualityModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
const {
qualityId,
proper,
real
} = props;
this.state = {
qualityId,
proper,
real
};
}
//
// Listeners
onQualityChange = ({ value }) => {
this.setState({ qualityId: parseInt(value) });
};
onProperChange = ({ value }) => {
this.setState({ proper: value });
};
onRealChange = ({ value }) => {
this.setState({ real: value });
};
onQualitySelect = () => {
this.props.onQualitySelect(this.state);
};
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
onModalClose
} = this.props;
const {
qualityId,
proper,
real
} = this.state;
const qualityOptions = items.map(({ id, name }) => {
return {
key: id,
value: name
};
});
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('ManualImportSelectQuality')}
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<Alert kind={kinds.DANGER}>
{translate('QualitiesLoadError')}
</Alert>
}
{
isPopulated && !error &&
<Form>
<FormGroup>
<FormLabel>{translate('Quality')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="quality"
value={qualityId}
values={qualityOptions}
onChange={this.onQualityChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Proper')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="proper"
value={proper}
onChange={this.onProperChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Real')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="real"
value={real}
onChange={this.onRealChange}
/>
</FormGroup>
</Form>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
{translate('Cancel')}
</Button>
<Button
kind={kinds.SUCCESS}
onPress={this.onQualitySelect}
>
{translate('SelectQuality')}
</Button>
</ModalFooter>
</ModalContent>
);
}
}
SelectQualityModalContent.propTypes = {
qualityId: PropTypes.number.isRequired,
proper: PropTypes.bool.isRequired,
real: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
onQualitySelect: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default SelectQualityModalContent;

View File

@@ -1,97 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { updateMovieFiles } from 'Store/Actions/movieFileActions';
import { fetchQualityProfileSchema } from 'Store/Actions/settingsActions';
import getQualities from 'Utilities/Quality/getQualities';
import SelectQualityModalContent from './SelectQualityModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const {
isSchemaFetching: isFetching,
isSchemaPopulated: isPopulated,
schemaError: error,
schema
} = qualityProfiles;
return {
isFetching,
isPopulated,
error,
items: getQualities(schema.items)
};
}
);
}
const mapDispatchToProps = {
dispatchFetchQualityProfileSchema: fetchQualityProfileSchema,
dispatchupdateMovieFiles: updateMovieFiles
};
class SelectQualityModalContentConnector extends Component {
//
// Lifecycle
componentDidMount = () => {
if (!this.props.isPopulated) {
this.props.dispatchFetchQualityProfileSchema();
}
};
//
// Listeners
onQualitySelect = ({ qualityId, proper, real }) => {
const quality = _.find(this.props.items,
(item) => item.id === qualityId);
const revision = {
version: proper ? 2 : 1,
real: real ? 1 : 0
};
const movieFileIds = this.props.ids;
this.props.dispatchupdateMovieFiles({
movieFileIds,
quality: {
quality,
revision
}
});
this.props.onModalClose(true);
};
//
// Render
render() {
return (
<SelectQualityModalContent
{...this.props}
onQualitySelect={this.onQualitySelect}
/>
);
}
}
SelectQualityModalContentConnector.propTypes = {
ids: PropTypes.arrayOf(PropTypes.number).isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
dispatchFetchQualityProfileSchema: PropTypes.func.isRequired,
dispatchupdateMovieFiles: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(SelectQualityModalContentConnector);

View File

@@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,
@@ -34,7 +34,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow
>['onSelectedChange'];
const COLUMNS = [
const COLUMNS: Column[] = [
{
name: 'name',
label: () => translate('Name'),
@@ -56,8 +56,6 @@ const COLUMNS = [
interface ManageCustomFormatsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageCustomFormatsModalContent(

View File

@@ -48,7 +48,6 @@ class EditDownloadClientModalContent extends Component {
implementationName,
name,
enable,
protocol,
priority,
removeCompletedDownloads,
removeFailedDownloads,
@@ -171,20 +170,17 @@ class EditDownloadClientModalContent extends Component {
/>
</FormGroup>
{
protocol.value !== 'torrent' &&
<FormGroup>
<FormLabel>{translate('RemoveFailed')}</FormLabel>
<FormGroup>
<FormLabel>{translate('RemoveFailed')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeFailedDownloads"
helpText={translate('RemoveFailedDownloadsHelpText')}
{...removeFailedDownloads}
onChange={onInputChange}
/>
</FormGroup>
}
<FormInputGroup
type={inputTypes.CHECK}
name="removeFailedDownloads"
helpText={translate('RemoveFailedDownloadsHelpText')}
{...removeFailedDownloads}
onChange={onInputChange}
/>
</FormGroup>
</FieldSet>
</Form>
}

View File

@@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
@@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageDownloadClientsModalRow
>['onSelectedChange'];
const COLUMNS = [
const COLUMNS: Column[] = [
{
name: 'name',
label: () => translate('Name'),
@@ -82,8 +82,6 @@ const COLUMNS = [
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageDownloadClientsModalContent(

View File

@@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import {
bulkDeleteIndexers,
bulkEditIndexers,
@@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageIndexersModalRow
>['onSelectedChange'];
const COLUMNS = [
const COLUMNS: Column[] = [
{
name: 'name',
label: () => translate('Name'),
@@ -82,8 +82,6 @@ const COLUMNS = [
interface ManageIndexersModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector';
import translate from 'Utilities/String/translate';
interface QualityProfileNameProps {
qualityProfileId: number;
}
function QualityProfileName({ qualityProfileId }: QualityProfileNameProps) {
const qualityProfile = useSelector(
createQualityProfileSelectorForHook(qualityProfileId)
);
return <span>{qualityProfile?.name ?? translate('Unknown')}</span>;
}
export default QualityProfileName;

View File

@@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createQualityProfileSelector from 'Store/Selectors/createQualityProfileSelector';
function createMapStateToProps() {
return createSelector(
createQualityProfileSelector(),
(qualityProfile) => {
return {
name: qualityProfile.name
};
}
);
}
function QualityProfileNameConnector({ name, ...otherProps }) {
return (
<span>
{name}
</span>
);
}
QualityProfileNameConnector.propTypes = {
qualityProfileId: PropTypes.number.isRequired,
name: PropTypes.string.isRequired
};
export default connect(createMapStateToProps)(QualityProfileNameConnector);

View File

@@ -29,6 +29,7 @@ export const defaultState = {
isReprocessing: false,
error: null,
items: [],
originalItems: [],
sortKey: 'relativePath',
sortDirection: sortDirections.ASCENDING,
favoriteFolders: [],
@@ -119,7 +120,8 @@ export const actionHandlers = handleThunks({
section,
isFetching: false,
isPopulated: true,
error: null
error: null,
originalItems: data
})
]));
});
@@ -228,13 +230,13 @@ export const reducers = createHandleActions({
},
[UPDATE_INTERACTIVE_IMPORT_ITEMS]: (state, { payload }) => {
const ids = payload.ids;
const { ids, ...otherPayload } = payload;
const newState = Object.assign({}, state);
const items = [...newState.items];
ids.forEach((id) => {
const index = items.findIndex((item) => item.id === id);
const item = Object.assign({}, items[index], payload);
const item = Object.assign({}, items[index], otherPayload);
items.splice(index, 1, item);
});

View File

@@ -248,44 +248,14 @@ export const actionHandlers = handleThunks({
},
[UPDATE_MOVIE_FILES]: function(getState, payload, dispatch) {
const {
movieFileIds,
languages,
indexerFlags,
quality,
edition,
releaseGroup
} = payload;
const { files } = payload;
dispatch(set({ section, isSaving: true }));
const requestData = {
movieFileIds
};
if (languages) {
requestData.languages = languages;
}
if (indexerFlags !== undefined) {
requestData.indexerFlags = indexerFlags;
}
if (quality) {
requestData.quality = quality;
}
if (releaseGroup !== undefined) {
requestData.releaseGroup = releaseGroup;
}
if (edition !== undefined) {
requestData.edition = edition;
}
const requestData = files;
const promise = createAjaxRequest({
url: '/movieFile/editor',
url: '/movieFile/bulk',
method: 'PUT',
dataType: 'json',
data: JSON.stringify(requestData)
@@ -293,36 +263,25 @@ export const actionHandlers = handleThunks({
promise.done((data) => {
dispatch(batchActions([
...movieFileIds.map((id) => {
const movieFile = data.find((file) => file.id === id);
...files.map((file) => {
const id = file.id;
const props = {};
const movieFile = data.find((f) => f.id === id);
const props = {
customFormats: movieFile.customFormats,
customFormatScore: movieFile.customFormatScore,
qualityCutoffNotMet: movieFile.qualityCutoffNotMet
};
props.qualityCutoffNotMet = movieFile.qualityCutoffNotMet;
props.customFormats = movieFile.customFormats;
props.customFormatScore = movieFile.customFormatScore;
props.languages = movieFile.languages;
props.quality = movieFile.quality;
props.edition = movieFile.edition;
props.releaseGroup = movieFile.releaseGroup;
props.indexerFlags = movieFile.indexerFlags;
if (languages) {
props.languages = languages;
}
if (indexerFlags !== undefined) {
props.indexerFlags = indexerFlags;
}
if (quality) {
props.quality = quality;
}
if (edition !== undefined) {
props.edition = edition;
}
if (releaseGroup !== undefined) {
props.releaseGroup = releaseGroup;
}
return updateItem({ section, id, ...props });
return updateItem({
section,
id,
...props
});
}),
set({

View File

@@ -1,25 +0,0 @@
import { createSelector } from 'reselect';
import translate from 'Utilities/String/translate';
function createHealthCheckSelector() {
return createSelector(
(state) => state.system.health,
(state) => state.app,
(health, app) => {
const items = [...health.items];
if (!app.isConnected) {
items.push({
source: 'UI',
type: 'warning',
message: translate('CouldNotConnectSignalR'),
wikiUrl: 'https://wiki.servarr.com/radarr/system#could-not-connect-to-signalr'
});
}
return items;
}
);
}
export default createHealthCheckSelector;

View File

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

View File

@@ -1,11 +0,0 @@
function getErrorMessage(xhr, fallbackErrorMessage) {
if (!xhr || !xhr.responseJSON || !xhr.responseJSON.message) {
return fallbackErrorMessage;
}
const message = xhr.responseJSON.message;
return message || fallbackErrorMessage;
}
export default getErrorMessage;

View File

@@ -0,0 +1,15 @@
import { Error } from 'App/State/AppSectionState';
function getErrorMessage(xhr: Error, fallbackErrorMessage?: string) {
if (!xhr || !xhr.responseJSON) {
return fallbackErrorMessage;
}
if ('message' in xhr.responseJSON && xhr.responseJSON.message) {
return xhr.responseJSON.message;
}
return fallbackErrorMessage;
}
export default getErrorMessage;

View File

@@ -35,6 +35,7 @@ export interface GrabbedHistoryData {
export interface DownloadFailedHistory {
message: string;
indexer?: string;
}
export interface DownloadFolderImportedHistory {
@@ -43,11 +44,13 @@ export interface DownloadFolderImportedHistory {
downloadClientName: string;
droppedPath: string;
importedPath: string;
size: string;
}
export interface MovieFileDeletedHistory {
customFormatScore?: string;
reason: 'Manual' | 'MissingFromDisk' | 'Upgrade';
size: string;
}
export interface MovieFileRenamedHistory {

View File

@@ -117,7 +117,7 @@
"eslint-plugin-import": "2.31.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.37.1",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-simple-import-sort": "12.1.1",
"file-loader": "6.2.0",
"filemanager-webpack-plugin": "8.0.0",

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using NLog;
using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo;
@@ -316,9 +317,26 @@ namespace NzbDrone.Common.Disk
{
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
var files = GetFiles(path, recursive);
var files = GetFiles(path, recursive).ToList();
files.ToList().ForEach(RemoveReadOnly);
files.ForEach(RemoveReadOnly);
var attempts = 0;
while (attempts < 3 && files.Any())
{
EmptyFolder(path);
if (GetFiles(path, recursive).Any())
{
// Wait for IO operations to complete after emptying the folder since they aren't always
// instantly removed and it can lead to false positives that files are still present.
Thread.Sleep(3000);
}
attempts++;
files = GetFiles(path, recursive).ToList();
}
Directory.Delete(path, recursive);
}

View File

@@ -9,15 +9,15 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.3.4" />
<PackageReference Include="NLog" Version="5.4.0" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.3" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.15" />
<PackageReference Include="Npgsql" Version="7.0.9" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.4.0" />
<PackageReference Include="Npgsql" Version="7.0.10" />
<PackageReference Include="Sentry" Version="4.0.2" />
<PackageReference Include="NLog.Targets.Syslog" Version="7.0.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.Text.Json" Version="6.0.10" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.ValueTuple" Version="4.6.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />
<PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />

View File

@@ -0,0 +1,9 @@
using System.IO;
using System.Text;
namespace NzbDrone.Common;
public class Utf8StringWriter : StringWriter
{
public override Encoding Encoding => Encoding.UTF8;
}

View File

@@ -1,8 +1,10 @@
using FluentAssertions;
using System;
using System.Collections.Generic;
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Datastore;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Torznab;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Test.Framework;
@@ -14,13 +16,13 @@ namespace NzbDrone.Core.Test.IndexerTests
[Test]
public void should_not_return_config_for_non_existent_indexer()
{
Mocker.GetMock<IIndexerFactory>()
.Setup(v => v.Get(It.IsAny<int>()))
.Throws(new ModelNotFoundException(typeof(IndexerDefinition), 0));
Mocker.GetMock<ICachedIndexerSettingsProvider>()
.Setup(v => v.GetSettings(It.IsAny<int>()))
.Returns<CachedIndexerSettings>(null);
var result = Subject.GetSeedConfiguration(new RemoteMovie
{
Release = new ReleaseInfo()
Release = new ReleaseInfo
{
DownloadProtocol = DownloadProtocol.Torrent,
IndexerId = 0
@@ -29,5 +31,53 @@ namespace NzbDrone.Core.Test.IndexerTests
result.Should().BeNull();
}
[Test]
public void should_not_return_config_for_invalid_indexer()
{
Mocker.GetMock<ICachedIndexerSettingsProvider>()
.Setup(v => v.GetSettings(It.IsAny<int>()))
.Returns<CachedIndexerSettings>(null);
var result = Subject.GetSeedConfiguration(new RemoteMovie
{
Release = new ReleaseInfo
{
DownloadProtocol = DownloadProtocol.Torrent,
IndexerId = 1
},
ParsedMovieInfo = new ParsedMovieInfo()
});
result.Should().BeNull();
}
[Test]
public void should_return_seed_time_for_movies()
{
var settings = new TorznabSettings();
settings.SeedCriteria.SeedTime = 10;
Mocker.GetMock<ICachedIndexerSettingsProvider>()
.Setup(v => v.GetSettings(It.IsAny<int>()))
.Returns(new CachedIndexerSettings
{
FailDownloads = new HashSet<FailDownloads> { FailDownloads.Executables },
SeedCriteriaSettings = settings.SeedCriteria
});
var result = Subject.GetSeedConfiguration(new RemoteMovie
{
Release = new ReleaseInfo
{
DownloadProtocol = DownloadProtocol.Torrent,
IndexerId = 1
},
ParsedMovieInfo = new ParsedMovieInfo()
});
result.Should().NotBeNull();
result.SeedTime.Should().Be(TimeSpan.FromMinutes(10));
}
}
}

View File

@@ -15,5 +15,6 @@ namespace NzbDrone.Core.Test.IndexerTests
public string BaseUrl { get; set; }
public IEnumerable<int> MultiLanguages { get; set; }
public IEnumerable<int> FailDownloads { get; set; }
}
}

View File

@@ -33,6 +33,7 @@ namespace NzbDrone.Core.Download
private readonly IParsingService _parsingService;
private readonly IMovieService _movieService;
private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported;
private readonly IRejectedImportService _rejectedImportService;
private readonly Logger _logger;
public CompletedDownloadService(IEventAggregator eventAggregator,
@@ -42,6 +43,7 @@ namespace NzbDrone.Core.Download
IParsingService parsingService,
IMovieService movieService,
ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported,
IRejectedImportService rejectedImportService,
Logger logger)
{
_eventAggregator = eventAggregator;
@@ -51,6 +53,7 @@ namespace NzbDrone.Core.Download
_parsingService = parsingService;
_movieService = movieService;
_trackedDownloadAlreadyImported = trackedDownloadAlreadyImported;
_rejectedImportService = rejectedImportService;
_logger = logger;
}
@@ -159,10 +162,8 @@ namespace NzbDrone.Core.Download
{
var firstResult = importResults.First();
if (firstResult.Result == ImportResultType.Rejected && firstResult.ImportDecision.LocalMovie == null)
if (_rejectedImportService.Process(trackedDownload, firstResult))
{
trackedDownload.Warn(new TrackedDownloadStatusMessage(firstResult.Errors.First(), new List<string>()));
return;
}
}

View File

@@ -55,14 +55,18 @@ namespace NzbDrone.Core.Download
{
try
{
// Process completed items followed by failed, this allows failed imports to have
// their state changed and be processed immediately instead of the next execution.
if (enableCompletedDownloadHandling && trackedDownload.State == TrackedDownloadState.ImportPending)
{
_completedDownloadService.Import(trackedDownload);
}
if (trackedDownload.State == TrackedDownloadState.FailedPending)
{
_failedDownloadService.ProcessFailed(trackedDownload);
}
else if (enableCompletedDownloadHandling && trackedDownload.State == TrackedDownloadState.ImportPending)
{
_completedDownloadService.Import(trackedDownload);
}
}
catch (Exception e)
{

View File

@@ -70,7 +70,7 @@ namespace NzbDrone.Core.Download
if (grabbedItems.Empty())
{
trackedDownload.Warn("Download wasn't grabbed by Radarr, skipping");
trackedDownload.Warn(trackedDownload.DownloadItem.IsEncrypted ? "Download is encrypted and wasn't grabbed by Radarr, skipping automatic download handling" : "Download has failed wasn't grabbed by Radarr, skipping automatic download handling");
return;
}

Some files were not shown because too many files have changed in this diff Show More