mirror of
https://github.com/Radarr/Radarr.git
synced 2026-04-16 21:15:33 -04:00
Compare commits
14 Commits
v5.12.0.92
...
v5.12.1.92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c7a3cfc6 | ||
|
|
cfdb7a15de | ||
|
|
63a7d33e7e | ||
|
|
c9836f997c | ||
|
|
d37e71415f | ||
|
|
9a5f4bef63 | ||
|
|
40551ba5a3 | ||
|
|
6e04dc894b | ||
|
|
ac767ed386 | ||
|
|
42fbb79017 | ||
|
|
c43bd77dae | ||
|
|
68dfa55b35 | ||
|
|
fa190c85a3 | ||
|
|
172dcf6f8d |
@@ -9,7 +9,7 @@
|
||||
[](#mega-sponsors)
|
||||
|
||||
Radarr is a movie collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new movies and will interface with clients and indexers to grab, sort, and rename them. It can also be configured to automatically upgrade the quality of existing files in the library when a better quality format becomes available.
|
||||
Note that only one type of a given movie is supported. If you want both an 4k version and 1080p version of a given movie you will need multiple instances.
|
||||
Note that only one type of a given movie is supported. If you want both a 4k version and 1080p version of a given movie you will need multiple instances.
|
||||
|
||||
## Major Features Include
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.12.0'
|
||||
majorVersion: '5.12.1'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
|
||||
@@ -16,6 +16,7 @@ import IndexerFlag from 'typings/IndexerFlag';
|
||||
import Notification from 'typings/Notification';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import General from 'typings/Settings/General';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
|
||||
export interface DownloadClientAppState
|
||||
@@ -49,6 +50,12 @@ export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionSchemaState<QualityProfile> {}
|
||||
|
||||
export interface ReleaseProfilesAppState
|
||||
extends AppSectionState<ReleaseProfile>,
|
||||
AppSectionSaveState {
|
||||
pendingChanges: Partial<ReleaseProfile>;
|
||||
}
|
||||
|
||||
export interface CustomFormatAppState
|
||||
extends AppSectionState<CustomFormat>,
|
||||
AppSectionDeleteState,
|
||||
@@ -83,6 +90,7 @@ interface SettingsAppState {
|
||||
languages: LanguageSettingsAppState;
|
||||
notifications: NotificationAppState;
|
||||
qualityProfiles: QualityProfilesAppState;
|
||||
releaseProfiles: ReleaseProfilesAppState;
|
||||
ui: UiSettingsAppState;
|
||||
}
|
||||
|
||||
|
||||
@@ -272,6 +272,8 @@ FormInputGroup.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.any,
|
||||
values: PropTypes.arrayOf(PropTypes.any),
|
||||
placeholder: PropTypes.string,
|
||||
delimiters: PropTypes.arrayOf(PropTypes.string),
|
||||
isDisabled: PropTypes.bool,
|
||||
type: PropTypes.string.isRequired,
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
@@ -284,8 +286,10 @@ FormInputGroup.propTypes = {
|
||||
helpTextWarning: PropTypes.string,
|
||||
helpLink: PropTypes.string,
|
||||
autoFocus: PropTypes.bool,
|
||||
canEdit: PropTypes.bool,
|
||||
includeNoChange: PropTypes.bool,
|
||||
includeNoChangeDisabled: PropTypes.bool,
|
||||
includeAny: PropTypes.bool,
|
||||
selectedValueOptions: PropTypes.object,
|
||||
indexerFlags: PropTypes.number,
|
||||
pending: PropTypes.bool,
|
||||
|
||||
@@ -422,14 +422,15 @@ class MovieDetails extends Component {
|
||||
<div className={styles.details}>
|
||||
<div>
|
||||
{
|
||||
!!certification &&
|
||||
certification ?
|
||||
<span className={styles.certification}>
|
||||
{certification}
|
||||
</span>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
year > 0 &&
|
||||
year > 0 ?
|
||||
<span className={styles.year}>
|
||||
<Popover
|
||||
anchor={
|
||||
@@ -445,14 +446,16 @@ class MovieDetails extends Component {
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!runtime &&
|
||||
runtime ?
|
||||
<span className={styles.runtime}>
|
||||
{formatRuntime(runtime, movieRuntimeFormat)}
|
||||
</span>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -2,23 +2,25 @@ import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Icon from 'Components/Icon';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Movie from 'Movie/Movie';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatDate from 'Utilities/Date/formatDate';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MovieReleaseDates.css';
|
||||
|
||||
interface MovieReleaseDatesProps {
|
||||
inCinemas?: string;
|
||||
digitalRelease?: string;
|
||||
physicalRelease?: string;
|
||||
}
|
||||
type MovieReleaseDatesProps = Pick<
|
||||
Movie,
|
||||
'inCinemas' | 'digitalRelease' | 'physicalRelease'
|
||||
>;
|
||||
|
||||
function MovieReleaseDates(props: MovieReleaseDatesProps) {
|
||||
const { inCinemas, digitalRelease, physicalRelease } = props;
|
||||
|
||||
const { showRelativeDates, shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
function MovieReleaseDates({
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
}: MovieReleaseDatesProps) {
|
||||
const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } =
|
||||
useSelector(createUISettingsSelector());
|
||||
|
||||
if (!inCinemas && !physicalRelease && !digitalRelease) {
|
||||
return (
|
||||
@@ -34,10 +36,16 @@ function MovieReleaseDates(props: MovieReleaseDatesProps) {
|
||||
return (
|
||||
<>
|
||||
{inCinemas ? (
|
||||
<div title={translate('InCinemas')}>
|
||||
<div
|
||||
title={`${translate('InCinemas')}: ${formatDate(
|
||||
inCinemas,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<div className={styles.dateIcon}>
|
||||
<Icon name={icons.IN_CINEMAS} />
|
||||
</div>
|
||||
|
||||
{getRelativeDate({
|
||||
date: inCinemas,
|
||||
shortDateFormat,
|
||||
@@ -49,10 +57,16 @@ function MovieReleaseDates(props: MovieReleaseDatesProps) {
|
||||
) : null}
|
||||
|
||||
{digitalRelease ? (
|
||||
<div title={translate('DigitalRelease')}>
|
||||
<div
|
||||
title={`${translate('DigitalRelease')}: ${formatDate(
|
||||
digitalRelease,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<div className={styles.dateIcon}>
|
||||
<Icon name={icons.MOVIE_FILE} />
|
||||
</div>
|
||||
|
||||
{getRelativeDate({
|
||||
date: digitalRelease,
|
||||
shortDateFormat,
|
||||
@@ -64,10 +78,16 @@ function MovieReleaseDates(props: MovieReleaseDatesProps) {
|
||||
) : null}
|
||||
|
||||
{physicalRelease ? (
|
||||
<div title={translate('PhysicalRelease')}>
|
||||
<div
|
||||
title={`${translate('PhysicalRelease')}: ${formatDate(
|
||||
physicalRelease,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<div className={styles.dateIcon}>
|
||||
<Icon name={icons.DISC} />
|
||||
</div>
|
||||
|
||||
{getRelativeDate({
|
||||
date: physicalRelease,
|
||||
shortDateFormat,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Statistics } from 'Movie/Movie';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatDate from 'Utilities/Date/formatDate';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createMovieIndexItemSelector from '../createMovieIndexItemSelector';
|
||||
@@ -243,7 +244,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
|
||||
) : null}
|
||||
|
||||
{showCinemaRelease && inCinemas ? (
|
||||
<div className={styles.title} title={translate('InCinemas')}>
|
||||
<div
|
||||
className={styles.title}
|
||||
title={`${translate('InCinemas')}: ${formatDate(
|
||||
inCinemas,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.IN_CINEMAS} />{' '}
|
||||
{getRelativeDate({
|
||||
date: inCinemas,
|
||||
@@ -256,7 +263,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
|
||||
) : null}
|
||||
|
||||
{showDigitalRelease && digitalRelease ? (
|
||||
<div className={styles.title} title={translate('DigitalRelease')}>
|
||||
<div
|
||||
className={styles.title}
|
||||
title={`${translate('DigitalRelease')}: ${formatDate(
|
||||
digitalRelease,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.MOVIE_FILE} />{' '}
|
||||
{getRelativeDate({
|
||||
date: digitalRelease,
|
||||
@@ -269,7 +282,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
|
||||
) : null}
|
||||
|
||||
{showPhysicalRelease && physicalRelease ? (
|
||||
<div className={styles.title} title={translate('PhysicalRelease')}>
|
||||
<div
|
||||
className={styles.title}
|
||||
title={`${translate('PhysicalRelease')}: ${formatDate(
|
||||
physicalRelease,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.DISC} />{' '}
|
||||
{getRelativeDate({
|
||||
date: physicalRelease,
|
||||
@@ -282,7 +301,13 @@ function MovieIndexPoster(props: MovieIndexPosterProps) {
|
||||
) : null}
|
||||
|
||||
{showReleaseDate && releaseDate ? (
|
||||
<div className={styles.title} title={translate('ReleaseDate')}>
|
||||
<div
|
||||
className={styles.title}
|
||||
title={`${translate('ReleaseDate')}: ${formatDate(
|
||||
releaseDate,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.CALENDAR} />{' '}
|
||||
{getRelativeDate({
|
||||
date: releaseDate,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { icons } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { Ratings } from 'Movie/Movie';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import formatDate from 'Utilities/Date/formatDate';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
@@ -139,7 +140,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.info} title={translate('InCinemas')}>
|
||||
<div
|
||||
className={styles.info}
|
||||
title={`${translate('InCinemas')}: ${formatDate(
|
||||
inCinemas,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.IN_CINEMAS} /> {inCinemasDate}
|
||||
</div>
|
||||
);
|
||||
@@ -155,7 +162,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.info} title={translate('DigitalRelease')}>
|
||||
<div
|
||||
className={styles.info}
|
||||
title={`${translate('DigitalRelease')}: ${formatDate(
|
||||
digitalRelease,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.MOVIE_FILE} /> {digitalReleaseDate}
|
||||
</div>
|
||||
);
|
||||
@@ -175,7 +188,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.info} title={translate('PhysicalRelease')}>
|
||||
<div
|
||||
className={styles.info}
|
||||
title={`${translate('PhysicalRelease')}: ${formatDate(
|
||||
physicalRelease,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.DISC} /> {physicalReleaseDate}
|
||||
</div>
|
||||
);
|
||||
@@ -183,7 +202,13 @@ function MovieIndexPosterInfo(props: MovieIndexPosterInfoProps) {
|
||||
|
||||
if (sortKey === 'releaseDate' && releaseDate && !showReleaseDate) {
|
||||
return (
|
||||
<div className={styles.info} title={translate('ReleaseDate')}>
|
||||
<div
|
||||
className={styles.info}
|
||||
title={`${translate('ReleaseDate')}: ${formatDate(
|
||||
releaseDate,
|
||||
longDateFormat
|
||||
)}`}
|
||||
>
|
||||
<Icon name={icons.CALENDAR} />{' '}
|
||||
{getRelativeDate({
|
||||
date: releaseDate,
|
||||
|
||||
@@ -19,7 +19,7 @@ function EditImportListExclusionModal(
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onModalClosePress = useCallback(() => {
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(
|
||||
clearPendingChanges({
|
||||
section: 'settings.importListExclusions',
|
||||
@@ -29,10 +29,10 @@ function EditImportListExclusionModal(
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClosePress}>
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditImportListExclusionModalContent
|
||||
{...otherProps}
|
||||
onModalClose={onModalClosePress}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -32,12 +32,6 @@ const newImportListExclusion = {
|
||||
tmdbId: 0,
|
||||
};
|
||||
|
||||
interface EditImportListExclusionModalContentProps {
|
||||
id?: number;
|
||||
onModalClose: () => void;
|
||||
onDeleteImportListExclusionPress?: () => void;
|
||||
}
|
||||
|
||||
function createImportListExclusionSelector(id?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.importListExclusions,
|
||||
@@ -63,12 +57,24 @@ function createImportListExclusionSelector(id?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
function EditImportListExclusionModalContent(
|
||||
props: EditImportListExclusionModalContentProps
|
||||
) {
|
||||
const { id, onModalClose, onDeleteImportListExclusionPress } = props;
|
||||
interface EditImportListExclusionModalContentProps {
|
||||
id?: number;
|
||||
onModalClose: () => void;
|
||||
onDeleteImportListExclusionPress?: () => void;
|
||||
}
|
||||
|
||||
function EditImportListExclusionModalContent({
|
||||
id,
|
||||
onModalClose,
|
||||
onDeleteImportListExclusionPress,
|
||||
}: EditImportListExclusionModalContentProps) {
|
||||
const { isFetching, isSaving, item, error, saveError, ...otherProps } =
|
||||
useSelector(createImportListExclusionSelector(id));
|
||||
|
||||
const { movieTitle, movieYear, tmdbId } = item;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const previousIsSaving = usePrevious(isSaving);
|
||||
|
||||
const dispatchSetImportListExclusionValue = (payload: {
|
||||
name: string;
|
||||
@@ -78,20 +84,10 @@ function EditImportListExclusionModalContent(
|
||||
dispatch(setImportListExclusionValue(payload));
|
||||
};
|
||||
|
||||
const { isFetching, isSaving, item, error, saveError, ...otherProps } =
|
||||
useSelector(createImportListExclusionSelector(props.id));
|
||||
const previousIsSaving = usePrevious(isSaving);
|
||||
|
||||
const { movieTitle, movieYear, tmdbId } = item;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
Object.keys(newImportListExclusion).forEach((name) => {
|
||||
dispatchSetImportListExclusionValue({
|
||||
name,
|
||||
value:
|
||||
newImportListExclusion[name as keyof typeof newImportListExclusion],
|
||||
});
|
||||
Object.entries(newImportListExclusion).forEach(([name, value]) => {
|
||||
dispatchSetImportListExclusionValue({ name, value });
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -101,7 +97,7 @@ function EditImportListExclusionModalContent(
|
||||
if (previousIsSaving && !isSaving && !saveError) {
|
||||
onModalClose();
|
||||
}
|
||||
});
|
||||
}, [previousIsSaving, isSaving, saveError, onModalClose]);
|
||||
|
||||
const onSavePress = useCallback(() => {
|
||||
dispatch(saveImportListExclusion({ id }));
|
||||
|
||||
@@ -75,7 +75,9 @@ const movieTokens = [
|
||||
{ token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 },
|
||||
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 },
|
||||
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 },
|
||||
{ token: '{Movie CleanTitle:DE}', example: 'Titel des Films', footNote: 1 },
|
||||
{ token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
|
||||
{ token: '{Movie CleanTitleThe}', example: 'Movies Title, The', footNote: 1 },
|
||||
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
|
||||
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
|
||||
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
|
||||
|
||||
@@ -7,7 +7,7 @@ import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import DelayProfilesConnector from './Delay/DelayProfilesConnector';
|
||||
import QualityProfilesConnector from './Quality/QualityProfilesConnector';
|
||||
import ReleaseProfilesConnector from './Release/ReleaseProfilesConnector';
|
||||
import ReleaseProfiles from './Release/ReleaseProfiles';
|
||||
|
||||
// Only a single DragDrop Context can exist so it's done here to allow editing
|
||||
// quality profiles and reordering delay profiles to work.
|
||||
@@ -26,7 +26,7 @@ class Profiles extends Component {
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
<QualityProfilesConnector />
|
||||
<DelayProfilesConnector />
|
||||
<ReleaseProfilesConnector />
|
||||
<ReleaseProfiles />
|
||||
</DndProvider>
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EditReleaseProfileModalContentConnector from './EditReleaseProfileModalContentConnector';
|
||||
|
||||
function EditReleaseProfileModal({ isOpen, onModalClose, ...otherProps }) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EditReleaseProfileModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
EditReleaseProfileModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EditReleaseProfileModal;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
|
||||
|
||||
interface EditReleaseProfileModalProps {
|
||||
id?: number;
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
onDeleteReleaseProfilePress?: () => void;
|
||||
}
|
||||
|
||||
function EditReleaseProfileModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
}: EditReleaseProfileModalProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
dispatch(
|
||||
clearPendingChanges({
|
||||
section: 'settings.releaseProfiles',
|
||||
})
|
||||
);
|
||||
onModalClose();
|
||||
}, [dispatch, onModalClose]);
|
||||
|
||||
return (
|
||||
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<EditReleaseProfileModalContent
|
||||
{...otherProps}
|
||||
onModalClose={handleModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditReleaseProfileModal;
|
||||
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
clearPendingChanges
|
||||
};
|
||||
|
||||
class EditReleaseProfileModalConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.clearPendingChanges({ section: 'settings.releaseProfiles' });
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditReleaseProfileModal
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditReleaseProfileModalConnector.propTypes = {
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
clearPendingChanges: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(null, mapDispatchToProps)(EditReleaseProfileModalConnector);
|
||||
@@ -1,5 +1,7 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
@@ -10,33 +12,97 @@ 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 usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
saveReleaseProfile,
|
||||
setReleaseProfileValue,
|
||||
} from 'Store/Actions/Settings/releaseProfiles';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import { PendingSection } from 'typings/pending';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditReleaseProfileModalContent.css';
|
||||
|
||||
const tagInputDelimiters = ['Tab', 'Enter'];
|
||||
|
||||
function EditReleaseProfileModalContent(props) {
|
||||
const {
|
||||
isSaving,
|
||||
saveError,
|
||||
item,
|
||||
onInputChange,
|
||||
onModalClose,
|
||||
onSavePress,
|
||||
onDeleteReleaseProfilePress,
|
||||
...otherProps
|
||||
} = props;
|
||||
const newReleaseProfile = {
|
||||
enabled: true,
|
||||
required: [],
|
||||
ignored: [],
|
||||
tags: [],
|
||||
indexerId: 0,
|
||||
};
|
||||
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
enabled,
|
||||
required,
|
||||
ignored,
|
||||
tags,
|
||||
indexerId
|
||||
} = item;
|
||||
function createReleaseProfileSelector(id?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.releaseProfiles,
|
||||
(releaseProfiles) => {
|
||||
const { items, isFetching, error, isSaving, saveError, pendingChanges } =
|
||||
releaseProfiles;
|
||||
|
||||
const mapping = id ? items.find((i) => i.id === id) : newReleaseProfile;
|
||||
const settings = selectSettings(mapping, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings as PendingSection<ReleaseProfile>,
|
||||
...settings,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface EditReleaseProfileModalContentProps {
|
||||
id?: number;
|
||||
onModalClose: () => void;
|
||||
onDeleteReleaseProfilePress?: () => void;
|
||||
}
|
||||
|
||||
function EditReleaseProfileModalContent({
|
||||
id,
|
||||
onModalClose,
|
||||
onDeleteReleaseProfilePress,
|
||||
}: EditReleaseProfileModalContentProps) {
|
||||
const { item, isFetching, isSaving, error, saveError, ...otherProps } =
|
||||
useSelector(createReleaseProfileSelector(id));
|
||||
|
||||
const { name, enabled, required, ignored, tags, indexerId } = item;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const previousIsSaving = usePrevious(isSaving);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
Object.entries(newReleaseProfile).forEach(([name, value]) => {
|
||||
// @ts-expect-error 'setReleaseProfileValue' isn't typed yet
|
||||
dispatch(setReleaseProfileValue({ name, value }));
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousIsSaving && !isSaving && !saveError) {
|
||||
onModalClose();
|
||||
}
|
||||
}, [previousIsSaving, isSaving, saveError, onModalClose]);
|
||||
|
||||
const handleSavePress = useCallback(() => {
|
||||
dispatch(saveReleaseProfile({ id }));
|
||||
}, [dispatch, id]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(payload: { name: string; value: string | number }) => {
|
||||
// @ts-expect-error 'setReleaseProfileValue' isn't typed yet
|
||||
dispatch(setReleaseProfileValue(payload));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -46,7 +112,6 @@ function EditReleaseProfileModalContent(props) {
|
||||
|
||||
<ModalBody>
|
||||
<Form {...otherProps}>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Name')}</FormLabel>
|
||||
|
||||
@@ -56,7 +121,7 @@ function EditReleaseProfileModalContent(props) {
|
||||
{...name}
|
||||
placeholder={translate('OptionalName')}
|
||||
canEdit={true}
|
||||
onChange={onInputChange}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -68,7 +133,7 @@ function EditReleaseProfileModalContent(props) {
|
||||
name="enabled"
|
||||
helpText={translate('EnableProfileHelpText')}
|
||||
{...enabled}
|
||||
onChange={onInputChange}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -85,7 +150,7 @@ function EditReleaseProfileModalContent(props) {
|
||||
placeholder={translate('AddNewRestriction')}
|
||||
delimiters={tagInputDelimiters}
|
||||
canEdit={true}
|
||||
onChange={onInputChange}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -102,7 +167,7 @@ function EditReleaseProfileModalContent(props) {
|
||||
placeholder={translate('AddNewRestriction')}
|
||||
delimiters={tagInputDelimiters}
|
||||
canEdit={true}
|
||||
onChange={onInputChange}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -113,10 +178,12 @@ function EditReleaseProfileModalContent(props) {
|
||||
type={inputTypes.INDEXER_SELECT}
|
||||
name="indexerId"
|
||||
helpText={translate('ReleaseProfileIndexerHelpText')}
|
||||
helpTextWarning={translate('ReleaseProfileIndexerHelpTextWarning')}
|
||||
helpTextWarning={translate(
|
||||
'ReleaseProfileIndexerHelpTextWarning'
|
||||
)}
|
||||
{...indexerId}
|
||||
includeAny={true}
|
||||
onChange={onInputChange}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -128,33 +195,28 @@ function EditReleaseProfileModalContent(props) {
|
||||
name="tags"
|
||||
helpText={translate('ReleaseProfileTagMovieHelpText')}
|
||||
{...tags}
|
||||
onChange={onInputChange}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{
|
||||
id &&
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteReleaseProfilePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
}
|
||||
{id ? (
|
||||
<Button
|
||||
className={styles.deleteButton}
|
||||
kind={kinds.DANGER}
|
||||
onPress={onDeleteReleaseProfilePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<SpinnerErrorButton
|
||||
isSpinning={isSaving}
|
||||
error={saveError}
|
||||
onPress={onSavePress}
|
||||
onPress={handleSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerErrorButton>
|
||||
@@ -163,14 +225,4 @@ function EditReleaseProfileModalContent(props) {
|
||||
);
|
||||
}
|
||||
|
||||
EditReleaseProfileModalContent.propTypes = {
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onDeleteReleaseProfilePress: PropTypes.func
|
||||
};
|
||||
|
||||
export default EditReleaseProfileModalContent;
|
||||
@@ -1,112 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { saveReleaseProfile, setReleaseProfileValue } from 'Store/Actions/settingsActions';
|
||||
import selectSettings from 'Store/Selectors/selectSettings';
|
||||
import EditReleaseProfileModalContent from './EditReleaseProfileModalContent';
|
||||
|
||||
const newReleaseProfile = {
|
||||
enabled: true,
|
||||
required: [],
|
||||
ignored: [],
|
||||
tags: [],
|
||||
indexerId: 0
|
||||
};
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { id }) => id,
|
||||
(state) => state.settings.releaseProfiles,
|
||||
(id, releaseProfiles) => {
|
||||
const {
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
pendingChanges,
|
||||
items
|
||||
} = releaseProfiles;
|
||||
|
||||
const profile = id ? items.find((i) => i.id === id) : newReleaseProfile;
|
||||
const settings = selectSettings(profile, pendingChanges, saveError);
|
||||
|
||||
return {
|
||||
id,
|
||||
isFetching,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
item: settings.settings,
|
||||
...settings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setReleaseProfileValue,
|
||||
saveReleaseProfile
|
||||
};
|
||||
|
||||
class EditReleaseProfileModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.id) {
|
||||
Object.keys(newReleaseProfile).forEach((name) => {
|
||||
this.props.setReleaseProfileValue({
|
||||
name,
|
||||
value: newReleaseProfile[name]
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||
this.props.onModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.setReleaseProfileValue({ name, value });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.saveReleaseProfile({ id: this.props.id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EditReleaseProfileModalContent
|
||||
{...this.props}
|
||||
onSavePress={this.onSavePress}
|
||||
onTestPress={this.onTestPress}
|
||||
onInputChange={this.onInputChange}
|
||||
onFieldChange={this.onFieldChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EditReleaseProfileModalContentConnector.propTypes = {
|
||||
id: PropTypes.number,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
item: PropTypes.object.isRequired,
|
||||
setReleaseProfileValue: PropTypes.func.isRequired,
|
||||
saveReleaseProfile: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EditReleaseProfileModalContentConnector);
|
||||
@@ -1,197 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TagList from 'Components/TagList';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
|
||||
import styles from './ReleaseProfile.css';
|
||||
|
||||
class ReleaseProfile extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isEditReleaseProfileModalOpen: false,
|
||||
isDeleteReleaseProfileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEditReleaseProfilePress = () => {
|
||||
this.setState({ isEditReleaseProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onEditReleaseProfileModalClose = () => {
|
||||
this.setState({ isEditReleaseProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onDeleteReleaseProfilePress = () => {
|
||||
this.setState({
|
||||
isEditReleaseProfileModalOpen: false,
|
||||
isDeleteReleaseProfileModalOpen: true
|
||||
});
|
||||
};
|
||||
|
||||
onDeleteReleaseProfileModalClose = () => {
|
||||
this.setState({ isDeleteReleaseProfileModalOpen: false });
|
||||
};
|
||||
|
||||
onConfirmDeleteReleaseProfile = () => {
|
||||
this.props.onConfirmDeleteReleaseProfile(this.props.id);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
enabled,
|
||||
required,
|
||||
ignored,
|
||||
tags,
|
||||
indexerId,
|
||||
tagList,
|
||||
indexerList
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isEditReleaseProfileModalOpen,
|
||||
isDeleteReleaseProfileModalOpen
|
||||
} = this.state;
|
||||
|
||||
const indexer = indexerId !== 0 && indexerList.find((i) => i.id === indexerId);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.releaseProfile}
|
||||
overlayContent={true}
|
||||
onPress={this.onEditReleaseProfilePress}
|
||||
>
|
||||
{
|
||||
name ?
|
||||
<div className={styles.name}>
|
||||
{name}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div>
|
||||
{
|
||||
required.map((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
className={styles.label}
|
||||
key={item}
|
||||
kind={kinds.SUCCESS}
|
||||
>
|
||||
{item}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{
|
||||
ignored.map((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
className={styles.label}
|
||||
key={item}
|
||||
kind={kinds.DANGER}
|
||||
>
|
||||
{item}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<TagList
|
||||
tags={tags}
|
||||
tagList={tagList}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{
|
||||
!enabled &&
|
||||
<Label
|
||||
kind={kinds.DISABLED}
|
||||
outline={true}
|
||||
>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
}
|
||||
|
||||
{
|
||||
indexer &&
|
||||
<Label
|
||||
kind={kinds.INFO}
|
||||
outline={true}
|
||||
>
|
||||
{indexer.name}
|
||||
</Label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditReleaseProfileModalConnector
|
||||
id={id}
|
||||
isOpen={isEditReleaseProfileModalOpen}
|
||||
onModalClose={this.onEditReleaseProfileModalClose}
|
||||
onDeleteReleaseProfilePress={this.onDeleteReleaseProfilePress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteReleaseProfileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteReleaseProfile')}
|
||||
message={translate('DeleteReleaseProfileMessageText', { name })}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={this.onConfirmDeleteReleaseProfile}
|
||||
onCancel={this.onDeleteReleaseProfileModalClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseProfile.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
required: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
ignored: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
indexerId: PropTypes.number.isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
ReleaseProfile.defaultProps = {
|
||||
enabled: true,
|
||||
required: [],
|
||||
ignored: [],
|
||||
indexerId: 0
|
||||
};
|
||||
|
||||
export default ReleaseProfile;
|
||||
130
frontend/src/Settings/Profiles/Release/ReleaseProfileRow.tsx
Normal file
130
frontend/src/Settings/Profiles/Release/ReleaseProfileRow.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Tag } from 'App/State/TagsAppState';
|
||||
import Card from 'Components/Card';
|
||||
import Label from 'Components/Label';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TagList from 'Components/TagList';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { deleteReleaseProfile } from 'Store/Actions/Settings/releaseProfiles';
|
||||
import Indexer from 'typings/Indexer';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||
import styles from './ReleaseProfileRow.css';
|
||||
|
||||
interface ReleaseProfileProps extends ReleaseProfile {
|
||||
tagList: Tag[];
|
||||
indexerList: Indexer[];
|
||||
}
|
||||
|
||||
function ReleaseProfileRow(props: ReleaseProfileProps) {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
enabled = true,
|
||||
required = [],
|
||||
ignored = [],
|
||||
tags,
|
||||
indexerId = 0,
|
||||
tagList,
|
||||
indexerList,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [
|
||||
isEditReleaseProfileModalOpen,
|
||||
setEditReleaseProfileModalOpen,
|
||||
setEditReleaseProfileModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
const [
|
||||
isDeleteReleaseProfileModalOpen,
|
||||
setDeleteReleaseProfileModalOpen,
|
||||
setDeleteReleaseProfileModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
const handleDeletePress = useCallback(() => {
|
||||
dispatch(deleteReleaseProfile({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
const indexer =
|
||||
indexerId !== 0 && indexerList.find((i) => i.id === indexerId);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles.releaseProfile}
|
||||
overlayContent={true}
|
||||
onPress={setEditReleaseProfileModalOpen}
|
||||
>
|
||||
{name ? <div className={styles.name}>{name}</div> : null}
|
||||
|
||||
<div>
|
||||
{required.map((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label key={item} className={styles.label} kind={kinds.SUCCESS}>
|
||||
{item}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{ignored.map((item) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label key={item} className={styles.label} kind={kinds.DANGER}>
|
||||
{item}
|
||||
</Label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<TagList tags={tags} tagList={tagList} />
|
||||
|
||||
<div>
|
||||
{enabled ? null : (
|
||||
<Label kind={kinds.DISABLED} outline={true}>
|
||||
{translate('Disabled')}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
{indexer ? (
|
||||
<Label kind={kinds.INFO} outline={true}>
|
||||
{indexer.name}
|
||||
</Label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<EditReleaseProfileModal
|
||||
id={id}
|
||||
isOpen={isEditReleaseProfileModalOpen}
|
||||
onModalClose={setEditReleaseProfileModalClosed}
|
||||
onDeleteReleaseProfilePress={setDeleteReleaseProfileModalOpen}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteReleaseProfileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteReleaseProfile')}
|
||||
message={translate('DeleteReleaseProfileMessageText', {
|
||||
name: name ?? id,
|
||||
})}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={handleDeletePress}
|
||||
onCancel={setDeleteReleaseProfileModalClosed}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReleaseProfileRow;
|
||||
@@ -4,7 +4,7 @@
|
||||
}
|
||||
|
||||
.addReleaseProfile {
|
||||
composes: releaseProfile from '~./ReleaseProfile.css';
|
||||
composes: releaseProfile from '~./ReleaseProfileRow.css';
|
||||
|
||||
background-color: var(--cardAlternateBackgroundColor);
|
||||
color: var(--gray);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Card from 'Components/Card';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModalConnector from './EditReleaseProfileModalConnector';
|
||||
import ReleaseProfile from './ReleaseProfile';
|
||||
import styles from './ReleaseProfiles.css';
|
||||
|
||||
class ReleaseProfiles extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isAddReleaseProfileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onAddReleaseProfilePress = () => {
|
||||
this.setState({ isAddReleaseProfileModalOpen: true });
|
||||
};
|
||||
|
||||
onAddReleaseProfileModalClose = () => {
|
||||
this.setState({ isAddReleaseProfileModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
items,
|
||||
tagList,
|
||||
indexerList,
|
||||
onConfirmDeleteReleaseProfile,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('ReleaseProfiles')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('ReleaseProfilesLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={styles.releaseProfiles}>
|
||||
<Card
|
||||
className={styles.addReleaseProfile}
|
||||
onPress={this.onAddReleaseProfilePress}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.ADD}
|
||||
size={45}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<ReleaseProfile
|
||||
key={item.id}
|
||||
tagList={tagList}
|
||||
indexerList={indexerList}
|
||||
{...item}
|
||||
onConfirmDeleteReleaseProfile={onConfirmDeleteReleaseProfile}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<EditReleaseProfileModalConnector
|
||||
isOpen={this.state.isAddReleaseProfileModalOpen}
|
||||
onModalClose={this.onAddReleaseProfileModalClose}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseProfiles.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
indexerList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onConfirmDeleteReleaseProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ReleaseProfiles;
|
||||
81
frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx
Normal file
81
frontend/src/Settings/Profiles/Release/ReleaseProfiles.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { ReleaseProfilesAppState } from 'App/State/SettingsAppState';
|
||||
import Card from 'Components/Card';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import ReleaseProfileRow from 'Settings/Profiles/Release/ReleaseProfileRow';
|
||||
import { fetchIndexers } from 'Store/Actions/Settings/indexers';
|
||||
import { fetchReleaseProfiles } from 'Store/Actions/Settings/releaseProfiles';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EditReleaseProfileModal from './EditReleaseProfileModal';
|
||||
import styles from './ReleaseProfiles.css';
|
||||
|
||||
function ReleaseProfiles() {
|
||||
const { items, isFetching, isPopulated, error }: ReleaseProfilesAppState =
|
||||
useSelector(createClientSideCollectionSelector('settings.releaseProfiles'));
|
||||
|
||||
const tagList = useSelector(createTagsSelector());
|
||||
const indexerList = useSelector(
|
||||
(state: AppState) => state.settings.indexers.items
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [
|
||||
isAddReleaseProfileModalOpen,
|
||||
setAddReleaseProfileModalOpen,
|
||||
setAddReleaseProfileModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchReleaseProfiles());
|
||||
dispatch(fetchIndexers());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('ReleaseProfiles')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('ReleaseProfilesLoadError')}
|
||||
isFetching={isFetching}
|
||||
isPopulated={isPopulated}
|
||||
error={error}
|
||||
>
|
||||
<div className={styles.releaseProfiles}>
|
||||
<Card
|
||||
className={styles.addReleaseProfile}
|
||||
onPress={setAddReleaseProfileModalOpen}
|
||||
>
|
||||
<div className={styles.center}>
|
||||
<Icon name={icons.ADD} size={45} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ReleaseProfileRow
|
||||
key={item.id}
|
||||
tagList={tagList}
|
||||
indexerList={indexerList}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<EditReleaseProfileModal
|
||||
isOpen={isAddReleaseProfileModalOpen}
|
||||
onModalClose={setAddReleaseProfileModalClosed}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default ReleaseProfiles;
|
||||
@@ -1,74 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteReleaseProfile, fetchIndexers, fetchReleaseProfiles } from 'Store/Actions/settingsActions';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import ReleaseProfiles from './ReleaseProfiles';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.releaseProfiles,
|
||||
(state) => state.settings.indexers,
|
||||
createTagsSelector(),
|
||||
(releaseProfiles, indexers, tagList) => {
|
||||
return {
|
||||
...releaseProfiles,
|
||||
tagList,
|
||||
isIndexersPopulated: indexers.isPopulated,
|
||||
indexerList: indexers.items
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchIndexers,
|
||||
fetchReleaseProfiles,
|
||||
deleteReleaseProfile
|
||||
};
|
||||
|
||||
class ReleaseProfilesConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.isPopulated) {
|
||||
this.props.fetchReleaseProfiles();
|
||||
}
|
||||
|
||||
if (!this.props.isIndexersPopulated) {
|
||||
this.props.fetchIndexers();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onConfirmDeleteReleaseProfile = (id) => {
|
||||
this.props.deleteReleaseProfile({ id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ReleaseProfiles
|
||||
{...this.props}
|
||||
onConfirmDeleteReleaseProfile={this.onConfirmDeleteReleaseProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseProfilesConnector.propTypes = {
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
isIndexersPopulated: PropTypes.bool.isRequired,
|
||||
fetchReleaseProfiles: PropTypes.func.isRequired,
|
||||
deleteReleaseProfile: PropTypes.func.isRequired,
|
||||
fetchIndexers: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(ReleaseProfilesConnector);
|
||||
@@ -156,6 +156,10 @@ export const filterPredicates = {
|
||||
return dateFilterPredicate(item.digitalRelease, filterValue, type);
|
||||
},
|
||||
|
||||
releaseDate: function(item, filterValue, type) {
|
||||
return dateFilterPredicate(item.releaseDate, filterValue, type);
|
||||
},
|
||||
|
||||
tmdbRating: function({ ratings = {} }, filterValue, type) {
|
||||
const predicate = filterTypePredicates[type];
|
||||
|
||||
|
||||
12
frontend/src/typings/Settings/ReleaseProfile.ts
Normal file
12
frontend/src/typings/Settings/ReleaseProfile.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
interface ReleaseProfile extends ModelBase {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
required: string[];
|
||||
ignored: string[];
|
||||
indexerId: number;
|
||||
tags: number[];
|
||||
}
|
||||
|
||||
export default ReleaseProfile;
|
||||
@@ -392,5 +392,26 @@ namespace NzbDrone.Common.Test
|
||||
PosixOnly();
|
||||
path.AsOsAgnostic().IsPathValid(PathValidationType.CurrentOs).Should().BeFalse();
|
||||
}
|
||||
|
||||
[TestCase(@"C:\", @"C:\")]
|
||||
[TestCase(@"C:\\", @"C:\")]
|
||||
[TestCase(@"C:\Test", @"C:\Test")]
|
||||
[TestCase(@"C:\Test\", @"C:\Test")]
|
||||
[TestCase(@"\\server\share", @"\\server\share")]
|
||||
[TestCase(@"\\server\share\", @"\\server\share")]
|
||||
public void windows_path_should_return_clean_path(string path, string cleanPath)
|
||||
{
|
||||
path.GetCleanPath().Should().Be(cleanPath);
|
||||
}
|
||||
|
||||
[TestCase("/", "/")]
|
||||
[TestCase("//", "/")]
|
||||
[TestCase("/test", "/test")]
|
||||
[TestCase("/test/", "/test")]
|
||||
[TestCase("/test//", "/test")]
|
||||
public void unix_path_should_return_clean_path(string path, string cleanPath)
|
||||
{
|
||||
path.GetCleanPath().Should().Be(cleanPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,19 @@ namespace NzbDrone.Common.Disk
|
||||
switch (kind)
|
||||
{
|
||||
case OsPathKind.Windows when !path.EndsWith(":\\"):
|
||||
return path.TrimEnd('\\');
|
||||
while (!path.EndsWith(":\\") && path.EndsWith('\\'))
|
||||
{
|
||||
path = path[..^1];
|
||||
}
|
||||
|
||||
return path;
|
||||
case OsPathKind.Unix when path != "/":
|
||||
return path.TrimEnd('/');
|
||||
while (path != "/" && path.EndsWith('/'))
|
||||
{
|
||||
path = path[..^1];
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
return path;
|
||||
|
||||
@@ -26,8 +26,6 @@ namespace NzbDrone.Common.Extensions
|
||||
private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Radarr.Update" + Path.DirectorySeparatorChar;
|
||||
private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar;
|
||||
|
||||
private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(?<!:)\\$", RegexOptions.Compiled);
|
||||
|
||||
public static string CleanFilePath(this string path)
|
||||
{
|
||||
if (path.IsNotNullOrWhiteSpace())
|
||||
@@ -114,11 +112,9 @@ namespace NzbDrone.Common.Extensions
|
||||
|
||||
public static string GetCleanPath(this string path)
|
||||
{
|
||||
var cleanPath = OsInfo.IsWindows
|
||||
? PARENT_PATH_END_SLASH_REGEX.Replace(path, "")
|
||||
: path.TrimEnd(Path.DirectorySeparatorChar);
|
||||
var osPath = new OsPath(path);
|
||||
|
||||
return cleanPath;
|
||||
return osPath == OsPath.Null ? null : osPath.PathWithoutTrailingSlash;
|
||||
}
|
||||
|
||||
public static bool IsParentPath(this string parentPath, string childPath)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
@@ -90,5 +91,16 @@ namespace NzbDrone.Core.Test.DecisionEngineTests
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_true_if_root_folder_is_not_available()
|
||||
{
|
||||
WithMinimumFreeSpace(150);
|
||||
WithSize(100);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>().Setup(s => s.GetAvailableSpace(It.IsAny<string>())).Throws<DirectoryNotFoundException>();
|
||||
|
||||
Subject.IsSatisfiedBy(_remoteMovie, null).Accepted.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ namespace NzbDrone.Core.Test.MediaFiles.MovieFileMovingServiceTests
|
||||
.Returns(@"C:\Test\Movies\Movie\File Name.avi".AsOsAgnostic());
|
||||
|
||||
var rootFolder = @"C:\Test\Movies\".AsOsAgnostic();
|
||||
|
||||
Mocker.GetMock<IRootFolderService>()
|
||||
.Setup(s => s.GetBestRootFolderPath(It.IsAny<string>(), null))
|
||||
.Returns(rootFolder);
|
||||
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.FolderExists(rootFolder))
|
||||
.Returns(true);
|
||||
@@ -55,10 +60,6 @@ namespace NzbDrone.Core.Test.MediaFiles.MovieFileMovingServiceTests
|
||||
Mocker.GetMock<IDiskProvider>()
|
||||
.Setup(s => s.FileExists(It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
|
||||
Mocker.GetMock<IRootFolderService>()
|
||||
.Setup(s => s.GetBestRootFolderPath(It.IsAny<string>(), null))
|
||||
.Returns(rootFolder);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.CustomFormats;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Movies;
|
||||
using NzbDrone.Core.Organizer;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class CleanTitleTheFixture : CoreTest<FileNameBuilder>
|
||||
{
|
||||
private Movie _movie;
|
||||
private MovieFile _movieFile;
|
||||
private NamingConfig _namingConfig;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_movie = Builder<Movie>
|
||||
.CreateNew()
|
||||
.Build();
|
||||
|
||||
_movieFile = new MovieFile { Quality = new QualityModel(Quality.HDTV720p), ReleaseGroup = "RadarrTest" };
|
||||
|
||||
_namingConfig = NamingConfig.Default;
|
||||
_namingConfig.RenameMovies = true;
|
||||
|
||||
Mocker.GetMock<INamingConfigService>()
|
||||
.Setup(c => c.GetConfig()).Returns(_namingConfig);
|
||||
|
||||
Mocker.GetMock<IQualityDefinitionService>()
|
||||
.Setup(v => v.Get(Moq.It.IsAny<Quality>()))
|
||||
.Returns<Quality>(v => Quality.DefaultQualityDefinitions.First(c => c.Quality == v));
|
||||
|
||||
Mocker.GetMock<ICustomFormatService>()
|
||||
.Setup(v => v.All())
|
||||
.Returns(new List<CustomFormat>());
|
||||
}
|
||||
|
||||
[TestCase("The Mist", "Mist, The")]
|
||||
[TestCase("A Place to Call Home", "Place to Call Home, A")]
|
||||
[TestCase("An Adventure in Space and Time", "Adventure in Space and Time, An")]
|
||||
[TestCase("The Flash (2010)", "Flash, The 2010")]
|
||||
[TestCase("A League Of Their Own (AU)", "League Of Their Own, A AU")]
|
||||
[TestCase("The Fixer (ZH) (2015)", "Fixer, The ZH 2015")]
|
||||
[TestCase("The Sixth Sense 2 (Thai)", "Sixth Sense 2, The Thai")]
|
||||
[TestCase("The Amazing Race (Latin America)", "Amazing Race, The Latin America")]
|
||||
[TestCase("The Rat Pack (A&E)", "Rat Pack, The AandE")]
|
||||
[TestCase("The Climax: I (Almost) Got Away With It (2016)", "Climax I Almost Got Away With It, The 2016")]
|
||||
public void should_get_expected_title_back(string title, string expected)
|
||||
{
|
||||
_movie.Title = title;
|
||||
_namingConfig.StandardMovieFormat = "{Movie CleanTitleThe}";
|
||||
|
||||
Subject.BuildFileName(_movie, _movieFile)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestCase("A")]
|
||||
[TestCase("Anne")]
|
||||
[TestCase("Theodore")]
|
||||
[TestCase("3%")]
|
||||
public void should_not_change_title(string title)
|
||||
{
|
||||
_movie.Title = title;
|
||||
_namingConfig.StandardMovieFormat = "{Movie CleanTitleThe}";
|
||||
|
||||
Subject.BuildFileName(_movie, _movieFile)
|
||||
.Should().Be(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Movie Title(2023) 1080p SkySHO WEB-DL ESP DD+ 5.1 H.264-EML HDTeam", "EML HDTeam")]
|
||||
[TestCase("Movie Title (2022) BDFull 1080p DTS-HD MA 5.1 AVC LMain", "LMain")]
|
||||
[TestCase("Movie Title (2024) (1080p BluRay x265 SDR DDP 5.1 English - DarQ)", "DarQ")]
|
||||
[TestCase("Movie Title (2024) (1080p BluRay x265 SDR DDP 5.1 English -BEN THE MAN", "BEN THE MAN")]
|
||||
public void should_parse_exception_release_group(string title, string expected)
|
||||
{
|
||||
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
@@ -32,11 +33,21 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
|
||||
}
|
||||
|
||||
var size = subject.Release.Size;
|
||||
var freeSpace = _diskProvider.GetAvailableSpace(subject.Movie.Path);
|
||||
var path = subject.Movie.Path;
|
||||
long? freeSpace = null;
|
||||
|
||||
try
|
||||
{
|
||||
freeSpace = _diskProvider.GetAvailableSpace(path);
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// Ignore so it'll be skipped in the following checks
|
||||
}
|
||||
|
||||
if (!freeSpace.HasValue)
|
||||
{
|
||||
_logger.Debug("Unable to get available space for {0}. Skipping", subject.Movie.Path);
|
||||
_logger.Debug("Unable to get available space for {0}. Skipping", path);
|
||||
|
||||
return Decision.Accept();
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ namespace NzbDrone.Core.Indexers.FileList
|
||||
Movie_BluRay4K = 26,
|
||||
[FieldOption("Movies 3D")]
|
||||
Movie_3D = 25,
|
||||
[FieldOption("RO Dubbed")]
|
||||
RoDubbed = 28,
|
||||
[FieldOption("XXX")]
|
||||
Xxx = 7
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@
|
||||
"DeleteQualityProfile": "Delete Quality Profile",
|
||||
"DeleteQualityProfileMessageText": "Are you sure you want to delete the quality profile '{name}'?",
|
||||
"DeleteReleaseProfile": "Delete Release Profile",
|
||||
"DeleteReleaseProfileMessageText": "Are you sure you want to delete this release profile '{name}'?",
|
||||
"DeleteReleaseProfileMessageText": "Are you sure you want to delete the release profile '{name}'?",
|
||||
"DeleteRemotePathMapping": "Delete Remote Path Mapping",
|
||||
"DeleteRemotePathMappingMessageText": "Are you sure you want to delete this remote path mapping?",
|
||||
"DeleteRestriction": "Delete Restriction",
|
||||
|
||||
@@ -30,9 +30,9 @@ namespace NzbDrone.Core.MediaFiles
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly IMediaFileAttributeService _mediaFileAttributeService;
|
||||
private readonly IImportScript _scriptImportDecider;
|
||||
private readonly IRootFolderService _rootFolderService;
|
||||
private readonly IEventAggregator _eventAggregator;
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IRootFolderService _rootFolderService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public MovieFileMovingService(IUpdateMovieFileService updateMovieFileService,
|
||||
@@ -41,9 +41,9 @@ namespace NzbDrone.Core.MediaFiles
|
||||
IDiskProvider diskProvider,
|
||||
IMediaFileAttributeService mediaFileAttributeService,
|
||||
IImportScript scriptImportDecider,
|
||||
IRootFolderService rootFolderService,
|
||||
IEventAggregator eventAggregator,
|
||||
IConfigService configService,
|
||||
IRootFolderService rootFolderService,
|
||||
Logger logger)
|
||||
{
|
||||
_updateMovieFileService = updateMovieFileService;
|
||||
@@ -52,9 +52,9 @@ namespace NzbDrone.Core.MediaFiles
|
||||
_diskProvider = diskProvider;
|
||||
_mediaFileAttributeService = mediaFileAttributeService;
|
||||
_scriptImportDecider = scriptImportDecider;
|
||||
_rootFolderService = rootFolderService;
|
||||
_eventAggregator = eventAggregator;
|
||||
_configService = configService;
|
||||
_rootFolderService = rootFolderService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -167,13 +167,17 @@ namespace NzbDrone.Core.MediaFiles
|
||||
private void EnsureMovieFolder(MovieFile movieFile, Movie movie, string filePath)
|
||||
{
|
||||
var movieFileFolder = Path.GetDirectoryName(filePath);
|
||||
|
||||
var movieFolder = movie.Path;
|
||||
var rootFolder = _rootFolderService.GetBestRootFolderPath(movieFolder);
|
||||
|
||||
if (rootFolder.IsNullOrWhiteSpace())
|
||||
{
|
||||
throw new RootFolderNotFoundException($"Root folder was not found, '{movieFolder}' is not a subdirectory of a defined root folder.");
|
||||
}
|
||||
|
||||
if (!_diskProvider.FolderExists(rootFolder))
|
||||
{
|
||||
throw new RootFolderNotFoundException(string.Format("Root folder '{0}' was not found.", rootFolder));
|
||||
throw new RootFolderNotFoundException($"Root folder '{rootFolder}' was not found.");
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Instrumentation.Extensions;
|
||||
using NzbDrone.Core.AutoTagging;
|
||||
using NzbDrone.Core.Configuration;
|
||||
@@ -145,7 +146,7 @@ namespace NzbDrone.Core.Movies
|
||||
SearchOnAdd = movie.AddOptions?.SearchForMovie ?? false,
|
||||
QualityProfileId = movie.QualityProfileId,
|
||||
MinimumAvailability = movie.MinimumAvailability,
|
||||
RootFolderPath = _folderService.GetBestRootFolderPath(movie.Path).TrimEnd('/', '\\', ' '),
|
||||
RootFolderPath = _folderService.GetBestRootFolderPath(movie.Path).GetCleanPath(),
|
||||
Tags = movie.Tags
|
||||
});
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace NzbDrone.Core.Organizer
|
||||
private static readonly Regex TitleRegex = new Regex(@"(?<tag>\{(?:imdb-|edition-))?\{(?<prefix>[- ._\[(]*)(?<token>(?:[a-z0-9]+)(?:(?<separator>[- ._]+)(?:[a-z0-9]+))?)(?::(?<customFormat>[ ,a-z0-9|+-]+(?<![- ])))?(?<suffix>[-} ._)\]]*)\}",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||||
|
||||
public static readonly Regex MovieTitleRegex = new Regex(@"(?<token>\{((?:(Movie|Original))(?<separator>[- ._])(Clean)?(Original)?(Title|Filename)(The)?)(?::(?<customFormat>[a-z0-9|-]+))?\})",
|
||||
public static readonly Regex MovieTitleRegex = new Regex(@"(?<token>\{(?:(?:Movie)(?<separator>[- ._])(?:Clean)?(?:OriginalTitle|Title(?:The)?)(?::(?<customFormat>[a-z0-9|-]+))?|Original[- ._](?:Title|Filename))\})",
|
||||
RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex FileNameCleanupRegex = new Regex(@"([- ._])(\1)+", RegexOptions.Compiled);
|
||||
@@ -226,6 +226,17 @@ namespace NzbDrone.Core.Organizer
|
||||
return TitlePrefixRegex.Replace(title, "$2, $1$3");
|
||||
}
|
||||
|
||||
public static string CleanTitleThe(string title)
|
||||
{
|
||||
if (TitlePrefixRegex.IsMatch(title))
|
||||
{
|
||||
var splitResult = TitlePrefixRegex.Split(title);
|
||||
return $"{CleanTitle(splitResult[2]).Trim()}, {splitResult[1]}{CleanTitle(splitResult[3])}";
|
||||
}
|
||||
|
||||
return CleanTitle(title);
|
||||
}
|
||||
|
||||
public static string TitleFirstCharacter(string title)
|
||||
{
|
||||
if (char.IsLetterOrDigit(title[0]))
|
||||
@@ -260,6 +271,7 @@ namespace NzbDrone.Core.Organizer
|
||||
tokenHandlers["{Movie Title}"] = m => Truncate(GetLanguageTitle(movie, m.CustomFormat), m.CustomFormat);
|
||||
tokenHandlers["{Movie CleanTitle}"] = m => Truncate(CleanTitle(GetLanguageTitle(movie, m.CustomFormat)), m.CustomFormat);
|
||||
tokenHandlers["{Movie TitleThe}"] = m => Truncate(TitleThe(movie.Title), m.CustomFormat);
|
||||
tokenHandlers["{Movie CleanTitleThe}"] = m => Truncate(CleanTitleThe(movie.Title), m.CustomFormat);
|
||||
tokenHandlers["{Movie TitleFirstCharacter}"] = m => TitleFirstCharacter(TitleThe(GetLanguageTitle(movie, m.CustomFormat)));
|
||||
tokenHandlers["{Movie OriginalTitle}"] = m => Truncate(movie.MovieMetadata.Value.OriginalTitle, m.CustomFormat) ?? string.Empty;
|
||||
tokenHandlers["{Movie CleanOriginalTitle}"] = m => Truncate(CleanTitle(movie.MovieMetadata.Value.OriginalTitle ?? string.Empty), m.CustomFormat);
|
||||
|
||||
@@ -159,7 +159,7 @@ namespace NzbDrone.Core.Parser
|
||||
|
||||
// Handle Exception Release Groups that don't follow -RlsGrp; Manual List
|
||||
// name only...BE VERY CAREFUL WITH THIS, HIGH CHANCE OF FALSE POSITIVES
|
||||
private static readonly Regex ExceptionReleaseGroupRegexExact = new Regex(@"\b(?<releasegroup>KRaLiMaRKo|E\.N\.D|D\-Z0N3|Koten_Gars|BluDragon|ZØNEHD|Tigole|HQMUX|VARYG|YIFY|YTS(.(MX|LT|AG))?|TMd|Eml HDTeam|LMain|DarQ)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex ExceptionReleaseGroupRegexExact = new Regex(@"\b(?<releasegroup>KRaLiMaRKo|E\.N\.D|D\-Z0N3|Koten_Gars|BluDragon|ZØNEHD|Tigole|HQMUX|VARYG|YIFY|YTS(.(MX|LT|AG))?|TMd|Eml HDTeam|LMain|DarQ|BEN THE MAN)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex WordDelimiterRegex = new Regex(@"(\s|\.|,|_|-|=|'|\|)+", RegexOptions.Compiled);
|
||||
private static readonly Regex SpecialCharRegex = new Regex(@"(\&|\:|\\|\/)+", RegexOptions.Compiled);
|
||||
|
||||
@@ -86,27 +86,35 @@ namespace Radarr.Api.V3.Movies
|
||||
_rootFolderService = rootFolderService;
|
||||
_logger = logger;
|
||||
|
||||
SharedValidator.RuleFor(s => s.QualityProfileId).ValidId();
|
||||
SharedValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderValidator)
|
||||
.SetValidator(mappedNetworkDriveValidator)
|
||||
.SetValidator(moviesPathValidator)
|
||||
.SetValidator(moviesAncestorValidator)
|
||||
.SetValidator(recycleBinValidator)
|
||||
.SetValidator(systemFolderValidator)
|
||||
.When(s => s.Path.IsNotNullOrWhiteSpace());
|
||||
|
||||
SharedValidator.RuleFor(s => s.Path)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderValidator)
|
||||
.SetValidator(mappedNetworkDriveValidator)
|
||||
.SetValidator(moviesPathValidator)
|
||||
.SetValidator(moviesAncestorValidator)
|
||||
.SetValidator(recycleBinValidator)
|
||||
.SetValidator(systemFolderValidator)
|
||||
.When(s => !s.Path.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.When(s => s.RootFolderPath.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.RootFolderPath).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderExistsValidator)
|
||||
.SetValidator(movieFolderAsRootFolderValidator)
|
||||
.When(s => s.Path.IsNullOrWhiteSpace());
|
||||
|
||||
SharedValidator.RuleFor(s => s.QualityProfileId).SetValidator(qualityProfileExistsValidator);
|
||||
PutValidator.RuleFor(s => s.Path).Cascade(CascadeMode.Stop)
|
||||
.NotEmpty()
|
||||
.IsValidPath();
|
||||
|
||||
SharedValidator.RuleFor(s => s.QualityProfileId).Cascade(CascadeMode.Stop)
|
||||
.ValidId()
|
||||
.SetValidator(qualityProfileExistsValidator);
|
||||
|
||||
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.RootFolderPath)
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderExistsValidator)
|
||||
.SetValidator(movieFolderAsRootFolderValidator)
|
||||
.When(s => s.Path.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.Title).NotEmpty().When(s => s.TmdbId <= 0);
|
||||
PostValidator.RuleFor(s => s.TmdbId).NotNull().NotEmpty().SetValidator(moviesExistsValidator);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user