1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-16 21:15:33 -04:00

Compare commits

..

14 Commits

Author SHA1 Message Date
Mark McDowall
75c7a3cfc6 Fixed: Ignore free space check before grabbing if directory is missing 2024-10-06 00:15:40 +03:00
Bogdan
cfdb7a15de Simplify defaults set when adding release profiles and list exclusions 2024-10-05 13:02:36 +03:00
Bogdan
63a7d33e7e Fixed: Cleaning the path for movie collections with top level folders 2024-10-05 12:01:13 +03:00
Bogdan
c9836f997c Fixed: Clean paths for top level root folders 2024-10-05 12:01:13 +03:00
Bogdan
d37e71415f Convert Release Profiles to TypeScript 2024-10-04 17:28:04 +03:00
Bogdan
9a5f4bef63 Check if root folder is not empty on files import 2024-10-04 12:04:09 +03:00
Bogdan
40551ba5a3 Fixed: Custom filters with release date filter
Fixes #10508
2024-10-02 22:32:33 +03:00
Bogdan
6e04dc894b Fixed: Validate path on movie update 2024-10-02 19:27:07 +03:00
Bogdan
ac767ed386 New: Add 'Movie CleanTitleThe' token
First attempt to fix movie folder validation by ignoring invalid tokens mixed from 'Original' and 'The'

Co-authored-by: Stevie Robinson <stevie.robinson@gmail.com>
2024-10-02 19:23:57 +03:00
Mark McDowall
42fbb79017 New: Parse 'BEN THE MAN' release group
(cherry picked from commit da610a1f409c9c03cbed1c27ccaedc32f42e636c)
2024-10-02 15:39:41 +03:00
Bogdan
c43bd77dae Display long date tooltips for release dates 2024-10-02 10:12:07 +03:00
Lorenzo Lewis
68dfa55b35 Fix typo README.md (#10502) 2024-10-01 12:04:50 -05:00
Bogdan
fa190c85a3 Add new category for FL 2024-09-30 17:14:18 +03:00
Bogdan
172dcf6f8d Bump version to 5.12.1 2024-09-29 08:16:01 +03:00
42 changed files with 716 additions and 707 deletions

View File

@@ -9,7 +9,7 @@
[![Mega Sponsors on Open Collective](https://opencollective.com/Radarr/megasponsors/badge.svg)](#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

View File

@@ -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)'

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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
}
{

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>
);

View File

@@ -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 }));

View File

@@ -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' },

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View 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;

View File

@@ -4,7 +4,7 @@
}
.addReleaseProfile {
composes: releaseProfile from '~./ReleaseProfile.css';
composes: releaseProfile from '~./ReleaseProfileRow.css';
background-color: var(--cardAlternateBackgroundColor);
color: var(--gray);

View File

@@ -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;

View 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;

View File

@@ -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);

View File

@@ -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];

View 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;

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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();
}
}
}

View File

@@ -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]

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}