Merge remote-tracking branch 'origin/feature/arm-mac-selections' into feature/retro_add_tags

This commit is contained in:
scphantm 2024-10-08 18:07:51 -04:00
commit 5f9c52d790
94 changed files with 1961 additions and 2005 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.1'
majorVersion: '5.12.2'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
@ -20,7 +20,7 @@ variables:
innoVersion: '6.2.2'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-12'
macImage: 'macOS-13'
trigger:
branches:

View file

@ -16,6 +16,9 @@ import IndexerFlag from 'typings/IndexerFlag';
import Notification from 'typings/Notification';
import QualityProfile from 'typings/QualityProfile';
import General from 'typings/Settings/General';
import NamingConfig from 'typings/Settings/NamingConfig';
import NamingExample from 'typings/Settings/NamingExample';
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
import UiSettings from 'typings/Settings/UiSettings';
export interface DownloadClientAppState
@ -29,6 +32,13 @@ export interface GeneralAppState
extends AppSectionItemState<General>,
AppSectionSaveState {}
export interface NamingAppState
extends AppSectionItemState<NamingConfig>,
AppSectionSaveState {}
export interface NamingExamplesAppState
extends AppSectionItemState<NamingExample> {}
export interface ImportListAppState
extends AppSectionState<ImportList>,
AppSectionDeleteState,
@ -49,6 +59,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,
@ -81,8 +97,11 @@ interface SettingsAppState {
indexerFlags: IndexerFlagSettingsAppState;
indexers: IndexerAppState;
languages: LanguageSettingsAppState;
naming: NamingAppState;
namingExamples: NamingExamplesAppState;
notifications: NotificationAppState;
qualityProfiles: QualityProfilesAppState;
releaseProfiles: ReleaseProfilesAppState;
ui: UiSettingsAppState;
}

View file

@ -1,54 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
function FormInputButton(props) {
const {
className,
canSpin,
isLastButton,
...otherProps
} = props;
if (canSpin) {
return (
<SpinnerButton
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(
className,
!isLastButton && styles.middleButton
)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
FormInputButton.propTypes = {
className: PropTypes.string.isRequired,
isLastButton: PropTypes.bool.isRequired,
canSpin: PropTypes.bool.isRequired
};
FormInputButton.defaultProps = {
className: styles.button,
isLastButton: true,
canSpin: false
};
export default FormInputButton;

View file

@ -0,0 +1,38 @@
import classNames from 'classnames';
import React from 'react';
import Button, { ButtonProps } from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { kinds } from 'Helpers/Props';
import styles from './FormInputButton.css';
export interface FormInputButtonProps extends ButtonProps {
canSpin?: boolean;
isLastButton?: boolean;
}
function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
...otherProps
}: FormInputButtonProps) {
if (canSpin) {
return (
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
return (
<Button
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kinds.PRIMARY}
{...otherProps}
/>
);
}
export default FormInputButton;

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

@ -1,139 +0,0 @@
import Clipboard from 'clipboard';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import styles from './ClipboardButton.css';
class ClipboardButton extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._id = getUniqueElememtId();
this._successTimeout = null;
this._testResultTimeout = null;
this.state = {
showSuccess: false,
showError: false
};
}
componentDidMount() {
this._clipboard = new Clipboard(`#${this._id}`, {
text: () => this.props.value,
container: document.getElementById(this._id)
});
this._clipboard.on('success', this.onSuccess);
}
componentDidUpdate() {
const {
showSuccess,
showError
} = this.state;
if (showSuccess || showError) {
this._testResultTimeout = setTimeout(this.resetState, 3000);
}
}
componentWillUnmount() {
if (this._clipboard) {
this._clipboard.destroy();
}
if (this._testResultTimeout) {
clearTimeout(this._testResultTimeout);
}
}
//
// Control
resetState = () => {
this.setState({
showSuccess: false,
showError: false
});
};
//
// Listeners
onSuccess = () => {
this.setState({
showSuccess: true
});
};
onError = () => {
this.setState({
showError: true
});
};
//
// Render
render() {
const {
value,
className,
...otherProps
} = this.props;
const {
showSuccess,
showError
} = this.state;
const showStateIcon = showSuccess || showError;
const iconName = showError ? icons.DANGER : icons.CHECK;
const iconKind = showError ? kinds.DANGER : kinds.SUCCESS;
return (
<FormInputButton
id={this._id}
className={className}
{...otherProps}
>
<span className={showStateIcon ? styles.showStateIcon : undefined}>
{
showSuccess &&
<span className={styles.stateIconContainer}>
<Icon
name={iconName}
kind={iconKind}
/>
</span>
}
{
<span className={styles.clipboardIconContainer}>
<Icon name={icons.CLIPBOARD} />
</span>
}
</span>
</FormInputButton>
);
}
}
ClipboardButton.propTypes = {
className: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
};
ClipboardButton.defaultProps = {
className: styles.button
};
export default ClipboardButton;

View file

@ -0,0 +1,76 @@
import copy from 'copy-to-clipboard';
import React, { useCallback, useEffect, useState } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import Icon from 'Components/Icon';
import { icons, kinds } from 'Helpers/Props';
import { ButtonProps } from './Button';
import styles from './ClipboardButton.css';
export interface ClipboardButtonProps extends Omit<ButtonProps, 'children'> {
value: string;
}
export type ClipboardState = 'success' | 'error' | null;
export default function ClipboardButton({
id,
value,
className = styles.button,
...otherProps
}: ClipboardButtonProps) {
const [state, setState] = useState<ClipboardState>(null);
useEffect(() => {
if (!state) {
return;
}
const timeoutId = setTimeout(() => {
setState(null);
}, 3000);
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [state]);
const handleClick = useCallback(async () => {
try {
if ('clipboard' in navigator) {
await navigator.clipboard.writeText(value);
} else {
copy(value);
}
setState('success');
} catch (e) {
setState('error');
console.error(`Failed to copy to clipboard`, e);
}
}, [value]);
return (
<FormInputButton
className={className}
onClick={handleClick}
{...otherProps}
>
<span className={state ? styles.showStateIcon : undefined}>
{state ? (
<span className={styles.stateIconContainer}>
<Icon
name={state === 'error' ? icons.DANGER : icons.CHECK}
kind={state === 'error' ? kinds.DANGER : kinds.SUCCESS}
/>
</span>
) : null}
<span className={styles.clipboardIconContainer}>
<Icon name={icons.CLIPBOARD} />
</span>
</span>
</FormInputButton>
);
}

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

@ -13,7 +13,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props';
import RootFolders from 'RootFolder/RootFolders';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import NamingConnector from './Naming/NamingConnector';
import Naming from './Naming/Naming';
import AddRootFolder from './RootFolder/AddRootFolder';
const rescanAfterRefreshOptions = [
@ -106,7 +106,7 @@ class MediaManagement extends Component {
/>
<PageContentBody>
<NamingConnector />
<Naming />
{
isFetching ?

View file

@ -1,229 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import NamingModal from './NamingModal';
import styles from './Naming.css';
class Naming extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isNamingModalOpen: false,
namingModalOptions: null
};
}
//
// Listeners
onStandardNamingModalOpenClick = () => {
this.setState({
isNamingModalOpen: true,
namingModalOptions: {
name: 'standardMovieFormat',
additional: true
}
});
};
onMovieFolderNamingModalOpenClick = () => {
this.setState({
isNamingModalOpen: true,
namingModalOptions: {
name: 'movieFolderFormat'
}
});
};
onNamingModalClose = () => {
this.setState({ isNamingModalOpen: false });
};
//
// Render
render() {
const {
advancedSettings,
isFetching,
error,
settings,
hasSettings,
examples,
examplesPopulated,
onInputChange
} = this.props;
const {
isNamingModalOpen,
namingModalOptions
} = this.state;
const renameMovies = hasSettings && settings.renameMovies.value;
const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value;
const colonReplacementOptions = [
{ key: 'delete', value: translate('Delete') },
{ key: 'dash', value: translate('ReplaceWithDash') },
{ key: 'spaceDash', value: translate('ReplaceWithSpaceDash') },
{ key: 'spaceDashSpace', value: translate('ReplaceWithSpaceDashSpace') },
{ key: 'smart', value: translate('SmartReplace'), hint: translate('SmartReplaceHint') }
];
const standardMovieFormatHelpTexts = [];
const standardMovieFormatErrors = [];
const movieFolderFormatHelpTexts = [];
const movieFolderFormatErrors = [];
if (examplesPopulated) {
if (examples.movieExample) {
standardMovieFormatHelpTexts.push(`${translate('Movie')}: ${examples.movieExample}`);
} else {
standardMovieFormatErrors.push({ message: translate('MovieInvalidFormat') });
}
if (examples.movieFolderExample) {
movieFolderFormatHelpTexts.push(`${translate('Example')}: ${examples.movieFolderExample}`);
} else {
movieFolderFormatErrors.push({ message: translate('InvalidFormat') });
}
}
return (
<FieldSet legend={translate('MovieNaming')}>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && error &&
<Alert kind={kinds.DANGER}>
{translate('NamingSettingsLoadError')}
</Alert>
}
{
hasSettings && !isFetching && !error &&
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('RenameMovies')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="renameMovies"
helpText={translate('RenameMoviesHelpText')}
onChange={onInputChange}
{...settings.renameMovies}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ReplaceIllegalCharacters')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="replaceIllegalCharacters"
helpText={translate('ReplaceIllegalCharactersHelpText')}
onChange={onInputChange}
{...settings.replaceIllegalCharacters}
/>
</FormGroup>
{
replaceIllegalCharacters &&
<FormGroup>
<FormLabel>{translate('ColonReplacement')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="colonReplacementFormat"
values={colonReplacementOptions}
helpText={translate('ColonReplacementFormatHelpText')}
onChange={onInputChange}
{...settings.colonReplacementFormat}
/>
</FormGroup>
}
{
renameMovies &&
<FormGroup size={sizes.LARGE}>
<FormLabel>{translate('StandardMovieFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="standardMovieFormat"
buttons={<FormInputButton onPress={this.onStandardNamingModalOpenClick}>?</FormInputButton>}
onChange={onInputChange}
{...settings.standardMovieFormat}
helpTexts={standardMovieFormatHelpTexts}
errors={[...standardMovieFormatErrors, ...settings.standardMovieFormat.errors]}
/>
</FormGroup>
}
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('MovieFolderFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="movieFolderFormat"
buttons={<FormInputButton onPress={this.onMovieFolderNamingModalOpenClick}>?</FormInputButton>}
onChange={onInputChange}
{...settings.movieFolderFormat}
helpTexts={[translate('MovieFolderFormatHelpText'), ...movieFolderFormatHelpTexts]}
errors={[...movieFolderFormatErrors, ...settings.movieFolderFormat.errors]}
/>
</FormGroup>
{
namingModalOptions &&
<NamingModal
isOpen={isNamingModalOpen}
advancedSettings={advancedSettings}
{...namingModalOptions}
value={settings[namingModalOptions.name].value}
onInputChange={onInputChange}
onModalClose={this.onNamingModalClose}
/>
}
</Form>
}
</FieldSet>
);
}
}
Naming.propTypes = {
advancedSettings: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
error: PropTypes.object,
settings: PropTypes.object.isRequired,
hasSettings: PropTypes.bool.isRequired,
examples: PropTypes.object.isRequired,
examplesPopulated: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired
};
export default Naming;

View file

@ -0,0 +1,273 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { inputTypes, kinds, sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import {
fetchNamingExamples,
fetchNamingSettings,
setNamingSettingsValue,
} from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingModal from './NamingModal';
import styles from './Naming.css';
const SECTION = 'naming';
function createNamingSelector() {
return createSelector(
(state: AppState) => state.settings.advancedSettings,
(state: AppState) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(advancedSettings, namingExamples, sectionSettings) => {
return {
advancedSettings,
examples: namingExamples.item,
examplesPopulated: namingExamples.isPopulated,
...sectionSettings,
};
}
);
}
interface NamingModalOptions {
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
movie?: boolean;
additional?: boolean;
}
function Naming() {
const {
advancedSettings,
isFetching,
error,
settings,
hasSettings,
examples,
examplesPopulated,
} = useSelector(createNamingSelector());
const dispatch = useDispatch();
const [isNamingModalOpen, setNamingModalOpen, setNamingModalClosed] =
useModalOpenState(false);
const [namingModalOptions, setNamingModalOptions] =
useState<NamingModalOptions | null>(null);
const namingExampleTimeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
dispatch(fetchNamingSettings());
dispatch(fetchNamingExamples());
return () => {
dispatch(clearPendingChanges({ section: SECTION }));
};
}, [dispatch]);
const handleInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
// @ts-expect-error 'setNamingSettingsValue' isn't typed yet
dispatch(setNamingSettingsValue({ name, value }));
if (namingExampleTimeout.current) {
clearTimeout(namingExampleTimeout.current);
}
namingExampleTimeout.current = setTimeout(() => {
dispatch(fetchNamingExamples());
}, 1000);
},
[dispatch]
);
const onStandardNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
name: 'standardMovieFormat',
movie: true,
additional: true,
});
}, [setNamingModalOpen, setNamingModalOptions]);
const onMovieFolderNamingModalOpenClick = useCallback(() => {
setNamingModalOpen();
setNamingModalOptions({
name: 'movieFolderFormat',
});
}, [setNamingModalOpen, setNamingModalOptions]);
const renameMovies = hasSettings && settings.renameMovies.value;
const replaceIllegalCharacters =
hasSettings && settings.replaceIllegalCharacters.value;
const colonReplacementOptions = [
{ key: 'delete', value: translate('Delete') },
{ key: 'dash', value: translate('ReplaceWithDash') },
{ key: 'spaceDash', value: translate('ReplaceWithSpaceDash') },
{ key: 'spaceDashSpace', value: translate('ReplaceWithSpaceDashSpace') },
{
key: 'smart',
value: translate('SmartReplace'),
hint: translate('SmartReplaceHint'),
},
];
const standardMovieFormatHelpTexts = [];
const standardMovieFormatErrors = [];
const movieFolderFormatHelpTexts = [];
const movieFolderFormatErrors = [];
if (examplesPopulated) {
if (examples.movieExample) {
standardMovieFormatHelpTexts.push(
`${translate('Movie')}: ${examples.movieExample}`
);
} else {
standardMovieFormatErrors.push({
message: translate('MovieInvalidFormat'),
});
}
if (examples.movieFolderExample) {
movieFolderFormatHelpTexts.push(
`${translate('Example')}: ${examples.movieFolderExample}`
);
} else {
movieFolderFormatErrors.push({ message: translate('InvalidFormat') });
}
}
return (
<FieldSet legend={translate('MovieNaming')}>
{isFetching ? <LoadingIndicator /> : null}
{!isFetching && error ? (
<Alert kind={kinds.DANGER}>
{translate('NamingSettingsLoadError')}
</Alert>
) : null}
{hasSettings && !isFetching && !error ? (
<Form>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('RenameMovies')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="renameMovies"
helpText={translate('RenameMoviesHelpText')}
onChange={handleInputChange}
{...settings.renameMovies}
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ReplaceIllegalCharacters')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="replaceIllegalCharacters"
helpText={translate('ReplaceIllegalCharactersHelpText')}
onChange={handleInputChange}
{...settings.replaceIllegalCharacters}
/>
</FormGroup>
{replaceIllegalCharacters ? (
<FormGroup size={sizes.MEDIUM}>
<FormLabel>{translate('ColonReplacement')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="colonReplacementFormat"
values={colonReplacementOptions}
helpText={translate('ColonReplacementFormatHelpText')}
onChange={handleInputChange}
{...settings.colonReplacementFormat}
/>
</FormGroup>
) : null}
{renameMovies ? (
<FormGroup size={sizes.LARGE}>
<FormLabel>{translate('StandardMovieFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="standardMovieFormat"
buttons={
<FormInputButton onPress={onStandardNamingModalOpenClick}>
?
</FormInputButton>
}
onChange={handleInputChange}
{...settings.standardMovieFormat}
helpTexts={standardMovieFormatHelpTexts}
errors={[
...standardMovieFormatErrors,
...settings.standardMovieFormat.errors,
]}
/>
</FormGroup>
) : null}
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>{translate('MovieFolderFormat')}</FormLabel>
<FormInputGroup
inputClassName={styles.namingInput}
type={inputTypes.TEXT}
name="movieFolderFormat"
buttons={
<FormInputButton onPress={onMovieFolderNamingModalOpenClick}>
?
</FormInputButton>
}
onChange={handleInputChange}
{...settings.movieFolderFormat}
helpTexts={[
translate('MovieFolderFormatHelpText'),
...movieFolderFormatHelpTexts,
]}
errors={[
...movieFolderFormatErrors,
...settings.movieFolderFormat.errors,
]}
/>
</FormGroup>
{namingModalOptions ? (
<NamingModal
isOpen={isNamingModalOpen}
{...namingModalOptions}
value={settings[namingModalOptions.name].value}
onInputChange={handleInputChange}
onModalClose={setNamingModalClosed}
/>
) : null}
</Form>
) : null}
</FieldSet>
);
}
export default Naming;

View file

@ -1,96 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import { fetchNamingExamples, fetchNamingSettings, setNamingSettingsValue } from 'Store/Actions/settingsActions';
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
import Naming from './Naming';
const SECTION = 'naming';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.advancedSettings,
(state) => state.settings.namingExamples,
createSettingsSectionSelector(SECTION),
(advancedSettings, namingExamples, sectionSettings) => {
return {
advancedSettings,
examples: namingExamples.item,
examplesPopulated: namingExamples.isPopulated,
...sectionSettings
};
}
);
}
const mapDispatchToProps = {
fetchNamingSettings,
setNamingSettingsValue,
fetchNamingExamples,
clearPendingChanges
};
class NamingConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._namingExampleTimeout = null;
}
componentDidMount() {
this.props.fetchNamingSettings();
this.props.fetchNamingExamples();
}
componentWillUnmount() {
this.props.clearPendingChanges({ section: `settings.${SECTION}` });
}
//
// Control
_fetchNamingExamples = () => {
this.props.fetchNamingExamples();
};
//
// Listeners
onInputChange = ({ name, value }) => {
this.props.setNamingSettingsValue({ name, value });
if (this._namingExampleTimeout) {
clearTimeout(this._namingExampleTimeout);
}
this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000);
};
//
// Render
render() {
return (
<Naming
onInputChange={this.onInputChange}
onSavePress={this.onSavePress}
{...this.props}
/>
);
}
}
NamingConnector.propTypes = {
fetchNamingSettings: PropTypes.func.isRequired,
setNamingSettingsValue: PropTypes.func.isRequired,
fetchNamingExamples: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector);

View file

@ -1,506 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import Modal from 'Components/Modal/Modal';
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 { icons, sizes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import NamingOption from './NamingOption';
import styles from './NamingModal.css';
const separatorOptions = [
{
key: ' ',
get value() {
return `${translate('Space')} ( )`;
}
},
{
key: '.',
get value() {
return `${translate('Period')} (.)`;
}
},
{
key: '_',
get value() {
return `${translate('Underscore')} (_)`;
}
},
{
key: '-',
get value() {
return `${translate('Dash')} (-)`;
}
}
];
const caseOptions = [
{
key: 'title',
get value() {
return translate('DefaultCase');
}
},
{
key: 'lower',
get value() {
return translate('Lowercase');
}
},
{
key: 'upper',
get value() {
return translate('Uppercase');
}
}
];
const fileNameTokens = [
{
token: '{Movie Title} - {Quality Full}',
example: 'Movie Title (2010) - HDTV-720p Proper'
}
];
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 TitleThe}', example: 'Movie\'s Title, The', footNote: 1 },
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 },
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
{ token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 },
{ token: '{Movie Certification}', example: 'R' },
{ token: '{Release Year}', example: '2009' }
];
const movieIdTokens = [
{ token: '{ImdbId}', example: 'tt12345' },
{ token: '{TmdbId}', example: '123456' }
];
const qualityTokens = [
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
{ token: '{Quality Title}', example: 'HDTV-720p' }
];
const mediaInfoTokens = [
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: 1 },
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: 1 },
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: 1 },
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
{ token: '{MediaInfo 3D}', example: '3D' }
];
const releaseGroupTokens = [
{ token: '{Release Group}', example: 'Rls Grp', footNote: 1 }
];
const editionTokens = [
{ token: '{Edition Tags}', example: 'IMAX', footNote: 1 }
];
const customFormatTokens = [
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' }
];
const originalTokens = [
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' }
];
class NamingModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._selectionStart = null;
this._selectionEnd = null;
this.state = {
separator: ' ',
case: 'title'
};
}
//
// Listeners
onTokenSeparatorChange = (event) => {
this.setState({ separator: event.value });
};
onTokenCaseChange = (event) => {
this.setState({ case: event.value });
};
onInputSelectionChange = (selectionStart, selectionEnd) => {
this._selectionStart = selectionStart;
this._selectionEnd = selectionEnd;
};
onOptionPress = ({ isFullFilename, tokenValue }) => {
const {
name,
value,
onInputChange
} = this.props;
const selectionStart = this._selectionStart;
const selectionEnd = this._selectionEnd;
if (isFullFilename) {
onInputChange({ name, value: tokenValue });
} else if (selectionStart == null) {
onInputChange({
name,
value: `${value}${tokenValue}`
});
} else {
const start = value.substring(0, selectionStart);
const end = value.substring(selectionEnd);
const newValue = `${start}${tokenValue}${end}`;
onInputChange({ name, value: newValue });
this._selectionStart = newValue.length - 1;
this._selectionEnd = newValue.length - 1;
}
};
//
// Render
render() {
const {
name,
value,
isOpen,
advancedSettings,
additional,
onInputChange,
onModalClose
} = this.props;
const {
separator: tokenSeparator,
case: tokenCase
} = this.state;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('FileNameTokens')}
</ModalHeader>
<ModalBody>
<div className={styles.namingSelectContainer}>
<SelectInput
className={styles.namingSelect}
name="separator"
value={tokenSeparator}
values={separatorOptions}
onChange={this.onTokenSeparatorChange}
/>
<SelectInput
className={styles.namingSelect}
name="case"
value={tokenCase}
values={caseOptions}
onChange={this.onTokenCaseChange}
/>
</div>
{
!advancedSettings &&
<FieldSet legend={translate('FileNames')}>
<div className={styles.groups}>
{
fileNameTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
isFullFilename={true}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
}
<FieldSet legend={translate('Movie')}>
<div className={styles.groups}>
{
movieTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MovieFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('MovieID')}>
<div className={styles.groups}>
{
movieIdTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
{
additional &&
<div>
<FieldSet legend={translate('Quality')}>
<div className={styles.groups}>
{
qualityTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend={translate('MediaInfo')}>
<div className={styles.groups}>
{
mediaInfoTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MediaInfoFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('ReleaseGroup')}>
<div className={styles.groups}>
{
releaseGroupTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('Edition')}>
<div className={styles.groups}>
{
editionTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EditionFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('CustomFormats')}>
<div className={styles.groups}>
{
customFormatTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
<FieldSet legend={translate('Original')}>
<div className={styles.groups}>
{
originalTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
</div>
}
</ModalBody>
<ModalFooter>
<TextInput
name={name}
value={value}
onChange={onInputChange}
onSelectionChange={this.onInputSelectionChange}
/>
<Button onPress={onModalClose}>
{translate('Close')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
NamingModal.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
advancedSettings: PropTypes.bool.isRequired,
additional: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
NamingModal.defaultProps = {
additional: false
};
export default NamingModal;

View file

@ -0,0 +1,469 @@
import React, { useCallback, useState } from 'react';
import FieldSet from 'Components/FieldSet';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import Modal from 'Components/Modal/Modal';
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 { icons, sizes } from 'Helpers/Props';
import NamingConfig from 'typings/Settings/NamingConfig';
import translate from 'Utilities/String/translate';
import NamingOption from './NamingOption';
import TokenCase from './TokenCase';
import TokenSeparator from './TokenSeparator';
import styles from './NamingModal.css';
const separatorOptions: { key: TokenSeparator; value: string }[] = [
{
key: ' ',
get value() {
return `${translate('Space')} ( )`;
},
},
{
key: '.',
get value() {
return `${translate('Period')} (.)`;
},
},
{
key: '_',
get value() {
return `${translate('Underscore')} (_)`;
},
},
{
key: '-',
get value() {
return `${translate('Dash')} (-)`;
},
},
];
const caseOptions: { key: TokenCase; value: string }[] = [
{
key: 'title',
get value() {
return translate('DefaultCase');
},
},
{
key: 'lower',
get value() {
return translate('Lowercase');
},
},
{
key: 'upper',
get value() {
return translate('Uppercase');
},
},
];
const fileNameTokens = [
{
token:
'{Movie Title} ({Release Year}) - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
example:
'The Movie - Title (2010) - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
},
{
token:
'{Movie CleanTitle} {Release Year} - {Edition Tags }{[Custom Formats]}{[Quality Full]}{-Release Group}',
example:
'The Movie Title 2010 - Ultimate Extended Edition [Surround Sound x264][Bluray-1080p Proper]-EVOLVE',
},
{
token:
'{Movie.CleanTitle}{.Release.Year}{.Edition.Tags}{.Custom.Formats}{.Quality.Full}{-Release Group}',
example:
'The.Movie.Title.2010.Ultimate.Extended.Edition.Surround.Sound.x264.Bluray-1080p.Proper-EVOLVE',
},
];
const movieTokens = [
{ token: '{Movie Title}', example: "Movie's Title", footNote: true },
{ token: '{Movie Title:DE}', example: 'Titel des Films', footNote: true },
{ token: '{Movie CleanTitle}', example: 'Movies Title', footNote: true },
{
token: '{Movie CleanTitle:DE}',
example: 'Titel des Films',
footNote: true,
},
{ token: '{Movie TitleThe}', example: "Movie's Title, The", footNote: true },
{
token: '{Movie CleanTitleThe}',
example: 'Movies Title, The',
footNote: true,
},
{ token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: true },
{
token: '{Movie CleanOriginalTitle}',
example: 'Τίτλος ταινίας',
footNote: true,
},
{ token: '{Movie TitleFirstCharacter}', example: 'M' },
{ token: '{Movie TitleFirstCharacter:DE}', example: 'T' },
{
token: '{Movie Collection}',
example: 'The Movie Collection',
footNote: true,
},
{ token: '{Movie Certification}', example: 'R' },
{ token: '{Release Year}', example: '2009' },
];
const movieIdTokens = [
{ token: '{ImdbId}', example: 'tt12345' },
{ token: '{TmdbId}', example: '123456' },
];
const qualityTokens = [
{ token: '{Quality Full}', example: 'HDTV-720p Proper' },
{ token: '{Quality Title}', example: 'HDTV-720p' },
];
const mediaInfoTokens = [
{ token: '{MediaInfo Simple}', example: 'x264 DTS' },
{ token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: true },
{ token: '{MediaInfo AudioCodec}', example: 'DTS' },
{ token: '{MediaInfo AudioChannels}', example: '5.1' },
{ token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: true },
{ token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: true },
{ token: '{MediaInfo VideoCodec}', example: 'x264' },
{ token: '{MediaInfo VideoBitDepth}', example: '10' },
{ token: '{MediaInfo VideoDynamicRange}', example: 'HDR' },
{ token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' },
{ token: '{MediaInfo 3D}', example: '3D' },
];
const releaseGroupTokens = [
{ token: '{Release Group}', example: 'Rls Grp', footNote: true },
];
const editionTokens = [
{ token: '{Edition Tags}', example: 'IMAX', footNote: true },
];
const customFormatTokens = [
{ token: '{Custom Formats}', example: 'Surround Sound x264' },
{ token: '{Custom Format:FormatName}', example: 'AMZN' },
];
const originalTokens = [
{ token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' },
{ token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' },
];
interface NamingModalProps {
isOpen: boolean;
name: keyof Pick<NamingConfig, 'standardMovieFormat' | 'movieFolderFormat'>;
value: string;
movie?: boolean;
additional?: boolean;
onInputChange: ({ name, value }: { name: string; value: string }) => void;
onModalClose: () => void;
}
function NamingModal(props: NamingModalProps) {
const {
isOpen,
name,
value,
movie = false,
additional = false,
onInputChange,
onModalClose,
} = props;
const [tokenSeparator, setTokenSeparator] = useState<TokenSeparator>(' ');
const [tokenCase, setTokenCase] = useState<TokenCase>('title');
const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const handleTokenSeparatorChange = useCallback(
({ value }: { value: TokenSeparator }) => {
setTokenSeparator(value);
},
[setTokenSeparator]
);
const handleTokenCaseChange = useCallback(
({ value }: { value: TokenCase }) => {
setTokenCase(value);
},
[setTokenCase]
);
const handleInputSelectionChange = useCallback(
(selectionStart: number, selectionEnd: number) => {
setSelectionStart(selectionStart);
setSelectionEnd(selectionEnd);
},
[setSelectionStart, setSelectionEnd]
);
const handleOptionPress = useCallback(
({
isFullFilename,
tokenValue,
}: {
isFullFilename: boolean;
tokenValue: string;
}) => {
if (isFullFilename) {
onInputChange({ name, value: tokenValue });
} else if (selectionStart == null || selectionEnd == null) {
onInputChange({
name,
value: `${value}${tokenValue}`,
});
} else {
const start = value.substring(0, selectionStart);
const end = value.substring(selectionEnd);
const newValue = `${start}${tokenValue}${end}`;
onInputChange({ name, value: newValue });
setSelectionStart(newValue.length - 1);
setSelectionEnd(newValue.length - 1);
}
},
[name, value, selectionEnd, selectionStart, onInputChange]
);
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{movie ? translate('FileNameTokens') : translate('FolderNameTokens')}
</ModalHeader>
<ModalBody>
<div className={styles.namingSelectContainer}>
<SelectInput
className={styles.namingSelect}
name="separator"
value={tokenSeparator}
values={separatorOptions}
onChange={handleTokenSeparatorChange}
/>
<SelectInput
className={styles.namingSelect}
name="case"
value={tokenCase}
values={caseOptions}
onChange={handleTokenCaseChange}
/>
</div>
{movie ? (
<FieldSet legend={translate('FileNames')}>
<div className={styles.groups}>
{fileNameTokens.map(({ token, example }) => (
<NamingOption
key={token}
token={token}
example={example}
isFullFilename={true}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={handleOptionPress}
/>
))}
</div>
</FieldSet>
) : null}
<FieldSet legend={translate('Movie')}>
<div className={styles.groups}>
{movieTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MovieFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('MovieID')}>
<div className={styles.groups}>
{movieIdTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
{additional ? (
<div>
<FieldSet legend={translate('Quality')}>
<div className={styles.groups}>
{qualityTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
<FieldSet legend={translate('MediaInfo')}>
<div className={styles.groups}>
{mediaInfoTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('MediaInfoFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('ReleaseGroup')}>
<div className={styles.groups}>
{releaseGroupTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('ReleaseGroupFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('Edition')}>
<div className={styles.groups}>
{editionTokens.map(({ token, example, footNote }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
footNote={footNote}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
<div className={styles.footNote}>
<Icon className={styles.icon} name={icons.FOOTNOTE} />
<InlineMarkdown data={translate('EditionFootNote')} />
</div>
</FieldSet>
<FieldSet legend={translate('CustomFormats')}>
<div className={styles.groups}>
{customFormatTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
<FieldSet legend={translate('Original')}>
<div className={styles.groups}>
{originalTokens.map(({ token, example }) => {
return (
<NamingOption
key={token}
token={token}
example={example}
tokenSeparator={tokenSeparator}
tokenCase={tokenCase}
size={sizes.LARGE}
onPress={handleOptionPress}
/>
);
})}
</div>
</FieldSet>
</div>
) : null}
</ModalBody>
<ModalFooter>
<TextInput
name={name}
value={value}
onChange={onInputChange}
onSelectionChange={handleInputSelectionChange}
/>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default NamingModal;

View file

@ -46,6 +46,10 @@
}
}
.title {
text-transform: none;
}
.lower {
text-transform: lowercase;
}

View file

@ -8,6 +8,7 @@ interface CssExports {
'lower': string;
'option': string;
'small': string;
'title': string;
'token': string;
'upper': string;
}

View file

@ -1,93 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sizes } from 'Helpers/Props';
import styles from './NamingOption.css';
class NamingOption extends Component {
//
// Listeners
onPress = () => {
const {
token,
tokenSeparator,
tokenCase,
isFullFilename,
onPress
} = this.props;
let tokenValue = token;
tokenValue = tokenValue.replace(/ /g, tokenSeparator);
if (tokenCase === 'lower') {
tokenValue = token.toLowerCase();
} else if (tokenCase === 'upper') {
tokenValue = token.toUpperCase();
}
onPress({ isFullFilename, tokenValue });
};
//
// Render
render() {
const {
token,
tokenSeparator,
example,
footNote,
tokenCase,
isFullFilename,
size
} = this.props;
return (
<Link
className={classNames(
styles.option,
styles[size],
styles[tokenCase],
isFullFilename && styles.isFullFilename
)}
onPress={this.onPress}
>
<div className={styles.token}>
{token.replace(/ /g, tokenSeparator)}
</div>
<div className={styles.example}>
{example.replace(/ /g, tokenSeparator)}
{
footNote !== 0 &&
<Icon className={styles.footNote} name={icons.FOOTNOTE} />
}
</div>
</Link>
);
}
}
NamingOption.propTypes = {
token: PropTypes.string.isRequired,
example: PropTypes.string.isRequired,
footNote: PropTypes.number.isRequired,
tokenSeparator: PropTypes.string.isRequired,
tokenCase: PropTypes.string.isRequired,
isFullFilename: PropTypes.bool.isRequired,
size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
onPress: PropTypes.func.isRequired
};
NamingOption.defaultProps = {
footNote: 0,
size: sizes.SMALL,
isFullFilename: false
};
export default NamingOption;

View file

@ -0,0 +1,77 @@
import classNames from 'classnames';
import React, { useCallback } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import { Size } from 'Helpers/Props/sizes';
import TokenCase from './TokenCase';
import TokenSeparator from './TokenSeparator';
import styles from './NamingOption.css';
interface NamingOptionProps {
token: string;
tokenSeparator: TokenSeparator;
example: string;
tokenCase: TokenCase;
isFullFilename?: boolean;
footNote?: boolean;
size?: Extract<Size, keyof typeof styles>;
onPress: ({
isFullFilename,
tokenValue,
}: {
isFullFilename: boolean;
tokenValue: string;
}) => void;
}
function NamingOption(props: NamingOptionProps) {
const {
token,
tokenSeparator,
example,
tokenCase,
isFullFilename = false,
footNote = false,
size = 'small',
onPress,
} = props;
const handlePress = useCallback(() => {
let tokenValue = token;
tokenValue = tokenValue.replace(/ /g, tokenSeparator);
if (tokenCase === 'lower') {
tokenValue = token.toLowerCase();
} else if (tokenCase === 'upper') {
tokenValue = token.toUpperCase();
}
onPress({ isFullFilename, tokenValue });
}, [token, tokenCase, tokenSeparator, isFullFilename, onPress]);
return (
<Link
className={classNames(
styles.option,
styles[size],
styles[tokenCase],
isFullFilename && styles.isFullFilename
)}
onPress={handlePress}
>
<div className={styles.token}>{token.replace(/ /g, tokenSeparator)}</div>
<div className={styles.example}>
{example.replace(/ /g, tokenSeparator)}
{footNote ? (
<Icon className={styles.footNote} name={icons.FOOTNOTE} />
) : null}
</div>
</Link>
);
}
export default NamingOption;

View file

@ -0,0 +1,3 @@
type TokenCase = 'title' | 'lower' | 'upper';
export default TokenCase;

View file

@ -0,0 +1,3 @@
type TokenSeparator = ' ' | '.' | '_' | '-';
export default TokenSeparator;

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

@ -1,7 +1,9 @@
let i = 0;
// returns a HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
/**
* @deprecated Use React's useId() instead
* @returns An HTML 4.0 compliant element IDs (http://stackoverflow.com/a/79022)
*/
export default function getUniqueElementId() {
return `id-${i++}`;
}

View file

@ -0,0 +1,14 @@
type ColonReplacementFormat =
| 'delete'
| 'dash'
| 'spaceDash'
| 'spaceDashSpace'
| 'smart';
export default interface NamingConfig {
renameMovies: boolean;
replaceIllegalCharacters: boolean;
colonReplacementFormat: ColonReplacementFormat;
standardMovieFormat: string;
movieFolderFormat: string;
}

View file

@ -0,0 +1,4 @@
export default interface NamingExample {
movieExample: string;
movieFolderExample: string;
}

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

@ -36,8 +36,8 @@
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"classnames": "2.3.2",
"clipboard": "2.0.11",
"connected-react-router": "6.9.3",
"copy-to-clipboard": "3.3.3",
"element-class": "0.2.2",
"filesize": "10.0.7",
"fuse.js": "6.6.2",

View file

@ -221,7 +221,7 @@
<PropertyGroup Condition="'$(IsOSX)' == 'true' and
'$(RuntimeIdentifier)' == ''">
<_UsingDefaultRuntimeIdentifier>true</_UsingDefaultRuntimeIdentifier>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<RuntimeIdentifier>osx-$(Architecture)</RuntimeIdentifier>
</PropertyGroup>
</Project>

View file

@ -392,5 +392,26 @@ public void IsPathValid_should_be_false_on_unix(string path)
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

@ -474,12 +474,7 @@ private void TryCopyFileVerified(string sourcePath, string targetPath, long orig
try
{
_diskProvider.CopyFile(sourcePath, targetPath);
var targetSize = _diskProvider.GetFileSize(targetPath);
if (targetSize != originalSize)
{
throw new IOException(string.Format("File copy incomplete. [{0}] was {1} bytes long instead of {2} bytes.", targetPath, targetSize, originalSize));
}
VerifyFile(sourcePath, targetPath, originalSize, "copy");
}
catch
{
@ -493,12 +488,7 @@ private void TryMoveFileVerified(string sourcePath, string targetPath, long orig
try
{
_diskProvider.MoveFile(sourcePath, targetPath);
var targetSize = _diskProvider.GetFileSize(targetPath);
if (targetSize != originalSize)
{
throw new IOException(string.Format("File move incomplete, data loss may have occurred. [{0}] was {1} bytes long instead of the expected {2}.", targetPath, targetSize, originalSize));
}
VerifyFile(sourcePath, targetPath, originalSize, "move");
}
catch (Exception ex)
{
@ -511,6 +501,27 @@ private void TryMoveFileVerified(string sourcePath, string targetPath, long orig
}
}
private void VerifyFile(string sourcePath, string targetPath, long originalSize, string action)
{
var targetSize = _diskProvider.GetFileSize(targetPath);
if (targetSize == originalSize)
{
return;
}
_logger.Debug("File {0} incomplete, waiting in case filesystem is not synchronized. [{1}] was {2} bytes long instead of the expected {3}.", action, targetPath, targetSize, originalSize);
WaitForIO();
targetSize = _diskProvider.GetFileSize(targetPath);
if (targetSize == originalSize)
{
return;
}
throw new IOException(string.Format("File {0} incomplete, data loss may have occurred. [{1}] was {2} bytes long instead of the expected {3}.", action, targetPath, targetSize, originalSize));
}
private bool ShouldIgnore(DirectoryInfo folder)
{
if (folder.Name.StartsWith(".nfs"))

View file

@ -104,9 +104,19 @@ private static string TrimTrailingSlash(string path, OsPathKind kind)
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 @@ public static class PathExtensions
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 @@ public static string GetDirectoryName(this string childPath)
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 @@ public void should_return_true_if_skip_free_space_check_is_true()
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 @@ public void Setup()
.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 @@ public void Setup()
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 @@ public void should_parse_release_group(string title, string expected)
[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 @@ public Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase searchCrit
}
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

@ -37,6 +37,7 @@ public virtual Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase se
}
file.Movie = subject.Movie;
var customFormats = _formatService.ParseCustomFormat(file);
_logger.Debug("Comparing file quality with report. Existing file is {0} [{1}].", file.Quality, customFormats.ConcatToString());
@ -48,8 +49,8 @@ public virtual Decision IsSatisfiedBy(RemoteMovie subject, SearchCriteriaBase se
{
_logger.Debug("Cutoff already met, rejecting.");
var qualityCutoffIndex = qualityProfile.GetIndex(qualityProfile.Cutoff);
var qualityCutoff = qualityProfile.Items[qualityCutoffIndex.Index];
var cutoff = qualityProfile.UpgradeAllowed ? qualityProfile.Cutoff : qualityProfile.FirststAllowedQuality().Id;
var qualityCutoff = qualityProfile.Items[qualityProfile.GetIndex(cutoff).Index];
return Decision.Reject("Existing file meets cutoff: {0} [{1}]", qualityCutoff, customFormats.ConcatToString());
}

View file

@ -1073,5 +1073,6 @@
"ShowDigitalRelease": "عرض تاريخ الإصدار السينمائي",
"ShowDigitalReleaseHelpText": "عرض تاريخ الإصدار تحت الملصق",
"ShowPhysicalRelease": "تاريخ الإصدار المادي",
"ShowPhysicalReleaseHelpText": "عرض تاريخ الإصدار تحت الملصق"
"ShowPhysicalReleaseHelpText": "عرض تاريخ الإصدار تحت الملصق",
"FolderNameTokens": "رموز اسم الملف"
}

View file

@ -1070,5 +1070,6 @@
"ShowPhysicalReleaseHelpText": "Показване на датата на пускане под постер",
"ShowDigitalRelease": "Показване на датата на излизане на киното",
"ShowDigitalReleaseHelpText": "Показване на датата на пускане под постер",
"ShowPhysicalRelease": "Дата на физическото издаване"
"ShowPhysicalRelease": "Дата на физическото издаване",
"FolderNameTokens": "Токени за име на файл"
}

View file

@ -1385,5 +1385,6 @@
"ShowPhysicalReleaseHelpText": "Mostra la data de llançament sota el cartell",
"ShowDigitalRelease": "Mostra la data d'estrena",
"ShowDigitalReleaseHelpText": "Mostra la data de llançament sota el cartell",
"Logout": "Tanca la sessió"
"Logout": "Tanca la sessió",
"FolderNameTokens": "Testimonis de nom de fitxer"
}

View file

@ -1200,5 +1200,6 @@
"ShowPhysicalRelease": "Datum fyzického vydání",
"ShowPhysicalReleaseHelpText": "Zobrazit datum vydání pod plakátem",
"ShowDigitalRelease": "Zobrazit datum vydání kina",
"MovieCollectionFolderMultipleMissingRootsHealthCheckMessage": "Několik kořenových adresářů chybí pro seznamy importu: {rootFoldersInfo}"
"MovieCollectionFolderMultipleMissingRootsHealthCheckMessage": "Několik kořenových adresářů chybí pro seznamy importu: {rootFoldersInfo}",
"FolderNameTokens": "Tokeny názvů souborů"
}

View file

@ -1092,5 +1092,6 @@
"ShowPhysicalReleaseHelpText": "Vis udgivelsesdato under plakat",
"ShowDigitalRelease": "Vis biografens udgivelsesdato",
"ShowDigitalReleaseHelpText": "Vis udgivelsesdato under plakat",
"ShowPhysicalRelease": "Fysisk udgivelsesdato"
"ShowPhysicalRelease": "Fysisk udgivelsesdato",
"FolderNameTokens": "Filnavn tokens"
}

View file

@ -1466,5 +1466,6 @@
"ShowDigitalRelease": "Erscheinungsdatum des Kinos anzeigen",
"ShowDigitalReleaseHelpText": "Kino-Erscheinungsdatum unter Poster anzeigen",
"ShowPhysicalRelease": "Disc Veröffentlichungsdatum",
"ShowPhysicalReleaseHelpText": "Kino-Erscheinungsdatum unter Poster anzeigen"
"ShowPhysicalReleaseHelpText": "Kino-Erscheinungsdatum unter Poster anzeigen",
"FolderNameTokens": "Dateinamen Teile"
}

View file

@ -1232,5 +1232,6 @@
"ShowPhysicalReleaseHelpText": "Εμφάνιση ημερομηνίας κυκλοφορίας στην αφίσα",
"ShowDigitalRelease": "Εμφάνιση ημερομηνίας κυκλοφορίας κινηματογράφου",
"SmartReplace": "Έξυπνη Αντικατάσταση",
"SmartReplaceHint": "Παύλα ή Κενό-Παύλα ανάλογα με το όνομα"
"SmartReplaceHint": "Παύλα ή Κενό-Παύλα ανάλογα με το όνομα",
"FolderNameTokens": "Διακριτικά ονόματος αρχείου"
}

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",
@ -641,6 +641,7 @@
"FocusSearchBox": "Focus Search Box",
"Folder": "Folder",
"FolderMoveRenameWarning": "This will also rename the movie folder per the movie folder format in settings.",
"FolderNameTokens": "Folder Name Tokens",
"Folders": "Folders",
"FollowPerson": "Follow Person",
"Forecast": "Forecast",

View file

@ -1218,7 +1218,7 @@
"InteractiveImportNoMovie": "Debe elegirse una película para cada archivo seleccionado",
"ManualGrab": "Captura manual",
"MatchedToMovie": "Coincidencia con la película",
"HealthMessagesInfoBox": "Puede encontrar más información sobre la causa de estos mensajes de comprobación de salud haciendo clic en el enlace wiki (icono de libro) al final de la fila, o comprobando sus [logs]({link}). Si tienes dificultades para interpretar estos mensajes, puedes ponerte en contacto con nuestro servicio de asistencia en los enlaces que aparecen a continuación.",
"HealthMessagesInfoBox": "Puedes encontrar más información sobre la causa de estos mensajes de comprobación de salud haciendo clic en el enlace wiki (icono de libro) al final de la fila, o comprobando tus [registros]({link}). Si tienes dificultades para interpretar estos mensajes, puedes ponerte en contacto con nuestro soporte en los enlaces que aparecen a continuación.",
"ManageFiles": "Gestionar archivos",
"MovieFileDeleted": "Archivo de película eliminado",
"MovieFileRenamedTooltip": "Archivo de película renombrado",
@ -1650,7 +1650,7 @@
"DelayMinutes": "{delay} minutos",
"DelayProfileProtocol": "Protocolo: {preferredProtocol}",
"DeleteReleaseProfile": "Eliminar perfil de lanzamiento",
"DeleteReleaseProfileMessageText": "¿Estás seguro que quieres eliminar este perfil de lanzamiento '{name}'?",
"DeleteReleaseProfileMessageText": "¿Estás seguro que quieres eliminar el perfil de lanzamiento '{name}'?",
"DeleteSpecification": "Eliminar especificación",
"DownloadClientDelugeValidationLabelPluginFailure": "La configuración de etiqueta falló",
"DownloadClientDelugeValidationLabelPluginFailureDetail": "{appName} no pudo añadir la etiqueta a {clientName}.",
@ -1835,5 +1835,6 @@
"InCinemasMovieAvailabilityDescription": "Las películas se consideran disponibles tan pronto como llegan a los cines.",
"ReleasedMovieAvailabilityDescription": "Las películas se consideran disponibles tan pronto como se lanzan la versión en Blu-Ray o en streaming.",
"SmartReplaceHint": "Raya o barra espaciadora según el nombre",
"SmartReplace": "Reemplazo inteligente"
"SmartReplace": "Reemplazo inteligente",
"FolderNameTokens": "Tokens de nombre de carpeta"
}

View file

@ -1759,5 +1759,6 @@
"ShowPhysicalReleaseHelpText": "Näytä teatterijulkaisun päiväys julisteen alla.",
"Logout": "Kirjaudu ulos",
"ShowTraktRatingPosterHelpText": "Näytä Tomato-arvio julisteen alla.",
"SmartReplaceHint": "Yhdysmerkki tai välilyönti nimen perusteella"
"SmartReplaceHint": "Yhdysmerkki tai välilyönti nimen perusteella",
"FolderNameTokens": "Tiedostonimen muuttujat"
}

View file

@ -1,6 +1,6 @@
{
"IndexerStatusCheckAllClientMessage": "Tous les indexeurs sont indisponibles en raison d'échecs",
"IndexerSearchCheckNoInteractiveMessage": "Aucun indexeur n'est disponible avec la recherche interactive activée, {appName} ne fournira aucun résultat de recherche interactive",
"IndexerSearchCheckNoInteractiveMessage": "Aucun indexeur n'est disponible avec la recherche interactive activée, {appName} ne fournira aucun résultats de recherche interactive",
"IndexerSearchCheckNoAvailableIndexersMessage": "Tous les indexeurs compatibles avec la recherche sont temporairement indisponibles en raison d'erreurs d'indexation récentes",
"IndexerSearchCheckNoAutomaticMessage": "Aucun indexeur disponible avec la recherche automatique activée, {appName} ne fournira aucun résultat de recherche automatique",
"Indexers": "Indexeurs",
@ -1820,5 +1820,9 @@
"NotificationsGotifySettingsMetadataLinks": "Liens de métadonnées",
"ShowTraktRatingPosterHelpText": "Affiche la note Tomate sous l'affiche",
"SmartReplace": "Remplacement intelligent",
"SmartReplaceHint": "Tiret ou espace puis tiret selon le nom"
"SmartReplaceHint": "Tiret ou espace puis tiret selon le nom",
"AnnouncedMovieAvailabilityDescription": "Les films sont considérés disponibles dès qu'ils sont ajouté à {appName}.",
"CustomFormatsSpecificationExceptLanguage": "Excepté Langue",
"CustomFormatsSpecificationExceptLanguageHelpText": "Corresponf si l'autre langue que celle sélectionné est présente",
"FolderNameTokens": "Jetons de nom de fichier"
}

View file

@ -1115,5 +1115,6 @@
"ShowDigitalReleaseHelpText": "הצג תאריך יציאה תחת הכרזה",
"ShowPhysicalRelease": "תאריך שחרור פיזי",
"ShowPhysicalReleaseHelpText": "הצג תאריך יציאה תחת הכרזה",
"MovieCollectionFolderMultipleMissingRootsHealthCheckMessage": "מספר תיקיות אב חסרות לייבוא רשימות: {rootFoldersInfo}"
"MovieCollectionFolderMultipleMissingRootsHealthCheckMessage": "מספר תיקיות אב חסרות לייבוא רשימות: {rootFoldersInfo}",
"FolderNameTokens": "אסימונים לשם קובץ"
}

View file

@ -1068,5 +1068,6 @@
"ShowDigitalReleaseHelpText": "पोस्टर के तहत रिलीज की तारीख दिखाएं",
"ShowPhysicalRelease": "शारीरिक रिलीज की तारीख",
"ShowPhysicalReleaseHelpText": "पोस्टर के तहत रिलीज की तारीख दिखाएं",
"ShowDigitalRelease": "सिनेमा रिलीज की तारीख दिखाएँ"
"ShowDigitalRelease": "सिनेमा रिलीज की तारीख दिखाएँ",
"FolderNameTokens": "फ़ाइल नाम टोकन"
}

View file

@ -21,7 +21,7 @@
"AddNewTmdbIdMessage": "Možeš pretraživati koristeći TMDb id filma. npr 'tmdb:71663'",
"AddQualityProfile": "Dodaj Profil Kvalitete",
"AddRestriction": "Dodaj Ograničenje",
"AddRootFolder": "asdf",
"AddRootFolder": "Dodaj Korijensku Mapu",
"ApplicationUrlHelpText": "Vanjski URL ove aplikacije uključuje http(s)://, port i URL base",
"ApplyTags": "Primjeni Oznake",
"AptUpdater": "Koristi apt kako bi instalirao ažuriranje",
@ -117,7 +117,7 @@
"Add": "Dodaj",
"Age": "Starost",
"Agenda": "Agenda",
"AddRemotePathMapping": "Daljinsko Mapiranje Portova",
"AddRemotePathMapping": "Dodaj mapiranje mrežne putanje",
"EditRemotePathMapping": "Daljinsko Mapiranje Portova",
"Letterboxd": "Letterboxd",
"Languages": "jezik",
@ -312,5 +312,47 @@
"DownloadClientSettingsRecentPriority": "Prioritet Klijenata",
"DeleteRootFolderMessageText": "Jeste li sigurni da želite obrisati ovaj profil odgode?",
"DeleteSelectedImportListExclusionsMessageText": "Jeste li sigurni da želite izbrisati ovu uvoznu listu isključenja?",
"DeleteSelectedCustomFormats": "Kloniraj Prilagođeni Format"
"DeleteSelectedCustomFormats": "Kloniraj Prilagođeni Format",
"AlternativeTitlesLoadError": "Neuspješno učitavanje alternativnih naslova.",
"AppUpdated": "{appName} Ažuriran",
"AuthenticationRequiredWarning": "Kako bi se spriječio udaljeni pristup bez autentikacije, {appName} sad zahtjeva da autentikacija bude omogućena. Izborno se može onemogućiti autentikacija s lokalnih adresa.",
"AuthenticationRequiredPasswordConfirmationHelpTextWarning": "Potvrdi novu lozinku",
"AddCondition": "Dodaj Uvjet",
"AddAutoTag": "Dodaj AutoOznaku",
"AllTitles": "Svi Naslovi",
"AddConditionImplementation": "Dodaj Uvjet - {implementationName}",
"AddConnection": "Dodaj vezu",
"AddConnectionImplementation": "Dodaj Vezu - {implementationName}",
"AddImportList": "Dodaj Listu Za Uvoz",
"AddImportListImplementation": "Dodaj Listu Za Uvoz - {implementationName}",
"AddIndexerImplementation": "Dodaj Indexer - {implementationName}",
"AddDownloadClientImplementation": "Dodaj Klijenta za Preuzimanje- {implementationName}",
"ApplyChanges": "Primjeni Promjene",
"AnnouncedMovieAvailabilityDescription": "Filmovi se smatraju dostupnim čim su dodani u {appName}.",
"AuthenticationRequired": "Potrebna Autentikacija",
"AudioLanguages": "Audio Jezici",
"AuthenticationRequiredPasswordHelpTextWarning": "Unesi novu lozinku",
"AuthenticationRequiredUsernameHelpTextWarning": "Unesi novo korisničko ime",
"AuthenticationMethod": "Metoda Autentikacije",
"AuthenticationMethodHelpTextWarning": "Molimo odaberi ispravnu metodu autentikacije",
"AutoRedownloadFailed": "Ponovno preuzimanje neuspješno",
"AutoRedownloadFailedFromInteractiveSearch": "Ponovno preuzimanje iz Interaktivne Pretrage neuspješno",
"ApiKeyValidationHealthCheckMessage": "Molimo ažuriraj svoj API ključ da ima barem {length} znakova. Ovo možeš uraditi u postavkama ili konfiguracijskoj datoteci",
"AddRootFolderError": "Neuspješno dodavanje korijenske mape",
"AnalyseVideoFilesHelpText": "Čitanje video informacija kao što su rezolucija, vrijeme izvođenja i kodek iz datoteka. Ovo zahtjeva da {appName} čita dijelove datoteke što može uzrokovati visoku aktivnost diska ili mreže tijekom skeniranja.",
"AddConditionError": "Neuspješno dodavanje novog uvjeta, molimo pokušaj ponovno.",
"AddAutoTagError": "Neuspješno dodavanje automatske oznake, molimo pokušaj ponovno.",
"AddImportListExclusionError": "Neuspješno dodavanje na listu za isključenje, molimo pokušaj ponovno.",
"AddIndexerError": "Neuspješno dodavanje novog indexera, molimo pokušaj ponovno.",
"AddListError": "Neuspješno dodavanje nove liste, molimo pokušaj ponovno.",
"AddNewRestriction": "Dodaj novo ograničenje",
"AddNotificationError": "Neuspješno dodavanje nove obavijesti, molimo pokušaj ponovno.",
"AddQualityProfileError": "Neuspješno dodavanje novog profila kvalitete, molimo pokušaj ponovno.",
"AddReleaseProfile": "Dodaj profil verzije",
"AddRemotePathMappingError": "Neuspješno dodavanje novog mapiranja mrežne putanje, molimo pokušaj ponovno.",
"AddCustomFormatError": "Neuspješno dodavanje novog prilagođenog formata, molimo pokušaj ponovno.",
"AddDelayProfileError": "Neuspješno dodavanje profila odgode, molimo pokušaj ponovno.",
"AddDownloadClientError": "Nesupješno dodavanje klijenta za preuzimanje, molimo pokušaj ponovno.",
"Any": "BIlo koji",
"AppUpdatedVersion": "{appName} je ažuriran na verziju '{version}', kako bi najnovije promjene bile aktivne potrebno je ponovno učitati {appName}"
}

View file

@ -1443,5 +1443,6 @@
"ShowPhysicalReleaseHelpText": "Mutasd a megjelenés dátumát a poszter alatt",
"Logout": "Kijelentkezés",
"SmartReplace": "Intelligens csere",
"SmartReplaceHint": "Dash vagy Space Dash névtől függően"
"SmartReplaceHint": "Dash vagy Space Dash névtől függően",
"FolderNameTokens": "Fájlnév-tokenek"
}

View file

@ -1070,5 +1070,6 @@
"ShowDigitalRelease": "Sýna útgáfudag kvikmyndahússins",
"ShowDigitalReleaseHelpText": "Sýnið útgáfudagsetningu undir veggspjaldi",
"ShowPhysicalRelease": "Líkamlegur útgáfudagur",
"ShowPhysicalReleaseHelpText": "Sýnið útgáfudagsetningu undir veggspjaldi"
"ShowPhysicalReleaseHelpText": "Sýnið útgáfudagsetningu undir veggspjaldi",
"FolderNameTokens": "Auðkenni skráarheita"
}

View file

@ -1470,5 +1470,6 @@
"TomorrowAt": "Domani alle {time}",
"YesterdayAt": "Ieri alle {time}",
"MovieCollectionFolderMultipleMissingRootsHealthCheckMessage": "Diverse cartelle principale sono perse per limportazione: {rootFoldersInfo}",
"ShowTraktRatingPosterHelpText": "Mostra valutazione Tomato sotta la locandina"
"ShowTraktRatingPosterHelpText": "Mostra valutazione Tomato sotta la locandina",
"FolderNameTokens": "Token nome file"
}

View file

@ -1070,5 +1070,6 @@
"ShowDigitalRelease": "シネマのリリース日を表示",
"ShowPhysicalRelease": "物理的なリリース日",
"ShowPhysicalReleaseHelpText": "ポスターの下にリリース日を表示する",
"ShowDigitalReleaseHelpText": "ポスターの下にリリース日を表示する"
"ShowDigitalReleaseHelpText": "ポスターの下にリリース日を表示する",
"FolderNameTokens": "ファイル名トークン"
}

View file

@ -1237,5 +1237,6 @@
"ShowDigitalRelease": "Show Cinema-releasedatum",
"ShowDigitalReleaseHelpText": "Laat releasedatum zien onder poster",
"ShowPhysicalRelease": "Fysieke Release Datum",
"ShowPhysicalReleaseHelpText": "Laat releasedatum zien onder poster"
"ShowPhysicalReleaseHelpText": "Laat releasedatum zien onder poster",
"FolderNameTokens": "Bestandsnaam Tokens"
}

View file

@ -1260,5 +1260,6 @@
"ShowDigitalReleaseHelpText": "Mostrar data de lançamento abaixo do cartaz",
"ShowPhysicalRelease": "Data da versão física",
"ShowPhysicalReleaseHelpText": "Mostrar data de lançamento abaixo do cartaz",
"SmartReplaceHint": "Traço ou Espaço e Traço, dependendo do nome"
"SmartReplaceHint": "Traço ou Espaço e Traço, dependendo do nome",
"FolderNameTokens": "Tokens de nome do ficheiro"
}

View file

@ -1835,5 +1835,6 @@
"InCinemasMovieAvailabilityDescription": "Filmes são considerados como disponíveis assim que entram em cartaz.",
"ReleasedMovieAvailabilityDescription": "Filmes são considerados como disponíveis assim que é lançado em Blu-Ray ou no streaming.",
"SmartReplace": "Substituição inteligente",
"SmartReplaceHint": "Traço ou Espaço e Traço, dependendo do nome"
"SmartReplaceHint": "Traço ou Espaço e Traço, dependendo do nome",
"FolderNameTokens": "Tokens de nome de arquivo"
}

View file

@ -1146,5 +1146,6 @@
"ShowDigitalRelease": "Data lansării Show Cinema",
"ShowDigitalReleaseHelpText": "Afișați data lansării sub afiș",
"ShowPhysicalRelease": "Data de lansare fizică",
"ShowPhysicalReleaseHelpText": "Afișați data lansării sub afiș"
"ShowPhysicalReleaseHelpText": "Afișați data lansării sub afiș",
"FolderNameTokens": "Jetoane cu nume de fișier"
}

View file

@ -1776,5 +1776,6 @@
"LogSizeLimitHelpText": "Максимальный размер файла журнала в МБ перед архивированием. По умолчанию - 1 МБ.",
"ShowTraktRatingPosterHelpText": "Показать рейтинг Tomato под постером",
"SmartReplace": "Умная замена",
"SmartReplaceHint": "Тире или пробел в зависимости от имени"
"SmartReplaceHint": "Тире или пробел в зависимости от имени",
"FolderNameTokens": "Токены имени файла"
}

View file

@ -1068,5 +1068,6 @@
"ShowDigitalRelease": "วันฉายภาพยนตร์",
"ShowDigitalReleaseHelpText": "แสดงวันที่วางจำหน่ายใต้โปสเตอร์",
"ShowPhysicalRelease": "วันที่วางจำหน่ายจริง",
"ShowPhysicalReleaseHelpText": "แสดงวันที่วางจำหน่ายใต้โปสเตอร์"
"ShowPhysicalReleaseHelpText": "แสดงวันที่วางจำหน่ายใต้โปสเตอร์",
"FolderNameTokens": "โทเค็นชื่อไฟล์"
}

View file

@ -1827,5 +1827,6 @@
"Logout": ıkış",
"NoBlocklistItems": "Engellenenler listesi öğesi yok",
"LastSearched": "Son Aranan",
"ShowTraktRatingPosterHelpText": "Posterin altında Tomato derecelendirmesini göster"
"ShowTraktRatingPosterHelpText": "Posterin altında Tomato derecelendirmesini göster",
"FolderNameTokens": "Dosya Adı Belirteçleri"
}

View file

@ -1287,5 +1287,6 @@
"ShowDigitalRelease": "Показати дату виходу в кіно",
"ShowDigitalReleaseHelpText": "Показати дату випуску під плакатом",
"ShowPhysicalRelease": "Дата фізичного випуску",
"ShowPhysicalReleaseHelpText": "Показати дату випуску під плакатом"
"ShowPhysicalReleaseHelpText": "Показати дату випуску під плакатом",
"FolderNameTokens": "Маркери імен файлів"
}

View file

@ -1074,5 +1074,6 @@
"ShowPhysicalReleaseHelpText": "Hiển thị ngày phát hành dưới áp phích",
"ShowDigitalRelease": "Ngày phát hành rạp chiếu phim",
"ShowDigitalReleaseHelpText": "Hiển thị ngày phát hành dưới áp phích",
"ShowPhysicalRelease": "Ngày phát hành vật lý"
"ShowPhysicalRelease": "Ngày phát hành vật lý",
"FolderNameTokens": "Mã thông báo tên tệp"
}

View file

@ -1,5 +1,5 @@
{
"About": "关于关于",
"About": "关于",
"DownloadClientCheckUnableToCommunicateMessage": "无法与{downloadClientName}进行通讯:{errorMessage}",
"DownloadClientCheckNoneAvailableMessage": "无可用的下载客户端",
"DownloadClient": "下载客户端",
@ -113,7 +113,7 @@
"CancelPendingTask": "您确定要取消这个挂起的任务吗?",
"Peers": "用户",
"AllowHardcodedSubs": "允许封装的字幕",
"YouCanAlsoSearch": "您同样可以使用TMDb ID或者IMDb ID搜索影片。例如tmdb71663",
"YouCanAlsoSearch": "您也可以使用 TMDb ID 或 IMDb ID 搜索电影。例如:`tmdb:71663`",
"Yesterday": "昨天",
"Year": "年份",
"Weeks": "周",
@ -121,7 +121,7 @@
"Username": "用户名",
"UseProxy": "使用代理",
"Uppercase": "大写字母",
"UnselectAll": "取消选择全部",
"UnselectAll": "取消全选",
"UnsavedChanges": "未保存更改",
"Unreleased": "未发布",
"Unmonitored": "未追踪项",
@ -135,14 +135,14 @@
"ShowRatings": "显示评分",
"ShowPath": "显示路径",
"ShownClickToHide": "显示,点击隐藏",
"ShowMovieInformationHelpText": "显示影片风格和分级",
"ShowMovieInformation": "显示信息",
"ShowMovieInformationHelpText": "显示电影类型和分级信息",
"ShowMovieInformation": "显示影信息",
"ShowMonitored": "显示追踪状态",
"ShowGenres": "显示类型",
"ShowDateAdded": "显示加入时间",
"ShowCertification": "显示分级",
"ShowDateAdded": "显示添加日期",
"ShowCertification": "显示分级信息",
"ICalShowAsAllDayEvents": "作为全天事件显示",
"ShowAdvanced": "显示高级",
"ShowAdvanced": "高级设置",
"TimeFormat": "时间格式",
"ShortDateFormat": "短日期格式",
"RemotePath": "远程路径",
@ -155,18 +155,18 @@
"SetPermissions": "设定权限",
"SendAnonymousUsageData": "发送匿名使用数据",
"SelectQuality": "选择质量",
"SelectMovie": "选择",
"SelectMovie": "选择影",
"SelectLanguage": "选择语言",
"SelectFolder": "选择文件夹",
"SelectAll": "选择全部",
"SelectAll": "全选",
"Seeders": "种子",
"Security": "安全",
"Seconds": "秒",
"SearchSelected": "搜索已选",
"SearchOnAdd": "添加时搜索",
"SearchMovie": "搜索",
"SearchMovie": "搜索影",
"SearchMissing": "搜索缺失项",
"SearchForMovie": "搜索",
"SearchForMovie": "搜索影",
"SearchForMissing": "搜索缺少",
"SearchFailedPleaseTryAgainLater": "搜索失败,请稍后重试。",
"SearchAll": "搜索全部",
@ -191,7 +191,7 @@
"Restart": "重启",
"ResetAPIKey": "重置API Key",
"Reset": "重置",
"RescanMovieFolderAfterRefresh": "刷新后重新扫描文件夹",
"RescanMovieFolderAfterRefresh": "刷新后重新扫描影文件夹",
"Required": "强制匹配",
"ReplaceWithSpaceDashSpace": "使用空格破折号空格xx - xx替换",
"ReplaceWithSpaceDash": "使用空格破折号xx -xx替换",
@ -205,8 +205,8 @@
"RemovingTag": "正在移除标签",
"RemoveSelected": "移除选中项",
"RemoveRootFolder": "移除根目录",
"RemoveMovieAndKeepFiles": "移除但保留文件",
"RemoveMovieAndDeleteFiles": "移除并删除文件",
"RemoveMovieAndKeepFiles": "移除影但保留文件",
"RemoveMovieAndDeleteFiles": "移除影并删除文件",
"RemoveFromQueue": "从队列中移除",
"RemoveFromDownloadClient": "从下载客户端中移除",
"RemovedFromTaskQueue": "已从任务队列移除",
@ -224,7 +224,7 @@
"RecyclingBinCleanup": "清理回收站",
"RecyclingBin": "回收站",
"Ratings": "评分",
"QuickImport": "自动",
"QuickImport": "自动移",
"QueueIsEmpty": "空队列",
"Queue": "队列",
"PublishedDate": "发布日期",
@ -294,7 +294,7 @@
"Monday": "星期一",
"MissingNotMonitored": "缺失中(未追踪)",
"MissingMonitoredAndConsideredAvailable": "缺失中(已追踪)",
"Missing": "缺失",
"Missing": "缺失",
"MinutesSixty": "60分钟: {sixty}",
"MinutesNinety": "90分钟: {ninety}",
"MinutesHundredTwenty": "120分钟: {hundredTwenty}",
@ -334,8 +334,8 @@
"Indexer": "索引器",
"InCinemasMovieDescription": "上映中电影",
"InCinemasDate": "上映日期",
"InCinemas": "上映日期",
"ImportNotForDownloads": "不要使用该方法从下载客户端导入影片,本方法只限于导入现有的已整理的库,不能导入未整理的文件。",
"InCinemas": "上映",
"ImportNotForDownloads": "请勿用于导入下载客户端的下载内容,本方法只限于导入已整理的现有资源库,不能导入未整理的文件。",
"ImportMovies": "导入电影",
"ImportMechanismHealthCheckMessage": "启用下载完成处理",
"ImportListStatusCheckAllClientMessage": "所有的列表因错误不可用",
@ -449,7 +449,7 @@
"UnableToLoadRootFolders": "无法加载根目录",
"RemotePathMappingsLoadError": "无法加载远程路径映射",
"NamingSettingsLoadError": "无法加载命名设置",
"UnableToLoadMovies": "无法加载",
"UnableToLoadMovies": "无法加载影",
"MetadataLoadError": "无法加载元数据",
"MediaManagementSettingsLoadError": "无法加载媒体管理设置",
"UnableToLoadManualImportItems": "无法加载手动导入项目",
@ -495,8 +495,8 @@
"SslPort": "SSL端口",
"SslCertPath": "SSL证书路径",
"SourcePath": "来源路径",
"Source": "来源",
"SorryThatMovieCannotBeFound": "对不起,未找到。",
"Source": "代码",
"SorryThatMovieCannotBeFound": "对不起,未找到该电影。",
"SkipFreeSpaceCheck": "跳过剩余空间检查",
"SizeOnDisk": "占用磁盘体积",
"Size": "大小",
@ -567,7 +567,7 @@
"MinimumCustomFormatScoreHelpText": "允许下载的最小自定义格式分数",
"Min": "最小的",
"MetadataSettingsMovieSummary": "导入或刷新电影时创建元数据文件",
"MaximumSizeHelpText": "抓取影片最大多少MB设置为0则不限制",
"MaximumSizeHelpText": "抓取发布资源的最大大小MB。设置为零则不限制",
"Max": "最大的",
"MassMovieSearch": "批量搜索电影",
"MarkAsFailedMessageText": "您确定要标记'{0}'为已失败?",
@ -580,7 +580,7 @@
"LoadingMovieExtraFilesFailed": "加载电影附加文件失败",
"LoadingMovieCreditsFailed": "加载电影演职员表失败",
"ListSyncLevelHelpTextWarning": "电影文件将被永久删除,如果您的列表为空,这可能会导致整个资源库被清空",
"ListSyncLevelHelpText": "如果资源库中的电影从您的列表中移除或从未出现在列表中,将根据您的选择进行处理",
"ListSyncLevelHelpText": "如果资源库中的电影从您的列表中移除或从未出现在列表中,将根据您的选择对其进行处理",
"LastWriteTime": "最后写入时间",
"LastDuration": "上一次用时",
"InstallLatest": "安装最新版",
@ -611,7 +611,7 @@
"GrabSelected": "抓取已选",
"GrabRelease": "抓取版本",
"GoToInterp": "跳转到 {0}",
"Genres": "风格",
"Genres": "类型",
"FolderMoveRenameWarning": "这也将根据设置中的电影文件夹格式重命名电影文件夹。",
"FocusSearchBox": "聚焦搜索框",
"Fixed": "已修复",
@ -621,7 +621,7 @@
"ExportCustomFormat": "导出自定义格式",
"ExistingTag": "已有标签",
"ExistingMovies": "已有电影",
"ExcludeTitle": "排除{0}?这会防止{appName}从列表更新中自动添加该影片。",
"ExcludeTitle": "排除 {0} ?这会防止 {appName} 从列表同步中自动添加。",
"ExcludeMovie": "排除的电影",
"Excluded": "排除的",
"Exception": "例外",
@ -662,8 +662,8 @@
"ChmodFolderHelpText": "八进制,当导入和重命名媒体文件夹和文件时应用(不带执行位)",
"ChmodFolder": "修改文件夹权限",
"BranchUpdateMechanism": "外部更新机制使用的分支",
"AvailabilityDelayHelpText": "在可用日期之前或之后搜索影片的总次数",
"AllowHardcodedSubsHelpText": "会自动下载检测到有硬字幕的影片",
"AvailabilityDelayHelpText": "在可用日期之前或之后搜索电影的时间范围",
"AllowHardcodedSubsHelpText": "检测到的硬编码字幕将自动下载",
"AddRestriction": "添加限制",
"AddDelayProfile": "添加延时配置",
"UpgradesAllowed": "允许升级",
@ -690,7 +690,7 @@
"RecyclingBinHelpText": "电影文件将被移动至此以替代永久删除",
"TableOptions": "表格选项",
"UpdateSelected": "更新选择的内容",
"ShowUnknownMovieItems": "显示未知影片条目",
"ShowUnknownMovieItems": "显示未知电影项目",
"RenameMoviesHelpText": "如果 “重命名电影” 未启用,{appName} 将使用现有文件名",
"TorrentsDisabled": "Torrents关闭",
"SomeResultsHiddenFilter": "部分结果已被过滤隐藏",
@ -707,7 +707,7 @@
"ProfilesSettingsSummary": "质量,语言,延迟和发布资源配置",
"WeekColumnHeader": "日期格式",
"YesCancel": "确定,取消",
"RescanAfterRefreshMovieHelpText": "刷新信息后重新扫描影文件夹",
"RescanAfterRefreshMovieHelpText": "刷新影信息后重新扫描影文件夹",
"Updates": "更新",
"UnableToLoadRestrictions": "无法加载限制条件",
"ICalIncludeUnmonitoredMoviesHelpText": "在 iCal 订阅中包含未追踪的电影",
@ -717,8 +717,8 @@
"Type": "类型",
"ReleaseDates": "发布日期",
"RemovedMovieCheckSingleMessage": "电影 “{movie}” 已从 TMDb 移除",
"UpgradeUntilThisQualityIsMetOrExceeded": "升级直到影片质量超出或者满足",
"ShowQualityProfileHelpText": "在海报下方显示媒体质量配置",
"UpgradeUntilThisQualityIsMetOrExceeded": "升级资源直至质量达标或高于标准",
"ShowQualityProfileHelpText": "在海报下方显示质量配置信息",
"ReleaseRejected": "发布资源已拒绝",
"UnmappedFilesOnly": "仅限未映射的文件",
"Quality": "质量",
@ -748,10 +748,10 @@
"Overview": "概览",
"RelativePath": "相对路径",
"Large": "大",
"StandardMovieFormat": "标准格式",
"StandardMovieFormat": "标准影格式",
"UiSettingsSummary": "日历、日期和色弱模式选项",
"Scheduled": "计划中",
"ShowQualityProfile": "显示质量配置文件",
"ShowQualityProfile": "显示质量配置",
"IndexerRssHealthCheckNoAvailableIndexers": "由于最近索引器错误,所有支持 RSS 的索引器暂时不可用",
"KeepAndUnmonitorMovie": "保留并取消追踪电影",
"SupportedIndexers": "{appName} 支持任何使用 Newznab 标准的索引器,以及以下列出的其他搜刮器。",
@ -765,7 +765,7 @@
"ImportRootPath": "将 {appName} 指向包含所有电影的文件夹,而非某部特定电影的文件夹。例如:“{0}” 而非 “{1}”。此外,每部电影必须在「根/资源库」目录中有独立文件夹。",
"TableOptionsColumnsMessage": "选择显示哪些列并排序",
"Forecast": "预报表",
"ShowRelativeDatesHelpText": "显示相对日期(今天昨天等)或绝对日期",
"ShowRelativeDatesHelpText": "显示相对日期(今天/昨天等)或绝对日期",
"ReleaseStatus": "发布状态",
"RecentFolders": "最近文件夹",
"ShowSearchHelpText": "悬停时显示搜索按钮",
@ -790,14 +790,14 @@
"DownloadPropersAndRepacks": "适合的和重封装的Propers and Repacks",
"DoNotPrefer": "不要首选",
"Reorder": "重新排序",
"MinimumAvailability": "最小可用",
"MinimumAvailability": "最小可用条件",
"MinimumAge": "最低间隔",
"ListTagsHelpText": "标签列表项目将被添加和",
"ExtraFileExtensionsHelpText": "导入逗号分隔其他文件(.nfo将做为.nfo-orig被导入",
"AvailabilityDelay": "可用性延迟",
"AcceptConfirmationModal": "接受确认对话框",
"Age": "年龄",
"CustomFormatHelpText": "{appName}会根据满足自定义格式与否给每个发布版本评分,如果一个新的发布版本有更高的分数,有相同或更高的影片质量,则{appName}会抓取该发布版本。",
"CustomFormatHelpText": "{appName} 会根据满足自定义格式与否给每个发布版本评分,如果一个新的发布版本有更高的分数且有相同或更高的质量,则 {appName} 会抓取该发布版本。",
"LookingForReleaseProfiles2": "代替。",
"MountCheckMessage": "包含电影路径的挂载点被设置为只读: ",
"NoAlternativeTitles": "没有其他标题。",
@ -864,7 +864,7 @@
"UsenetDisabled": "Usenet已关闭",
"VisitTheWikiForMoreDetails": "访问wiki获取更多详细信息 ",
"WaitingToProcess": "等待处理",
"Wanted": "待获取",
"Wanted": "待",
"Warn": "警告",
"Week": "周",
"WhitelistedHardcodedSubsHelpText": "这里设置的字幕标签不会被认为是硬编码的",
@ -931,7 +931,7 @@
"GrabReleaseMessageText": "{appName} 无法确定此发布资源为何电影,{appName} 可能无法自动导入此资源,你想要抓取 “{0}” 吗?",
"FeatureRequests": "功能建议",
"Extension": "扩展",
"Discord": "分歧",
"Discord": "Discord",
"CustomFormatUnknownConditionOption": "条件“{implementation}”的未知选项“{key}”",
"Retention": "保留",
"ChownGroupHelpText": "组名称或GID。对于远程文件系统请使用GID。",
@ -943,7 +943,7 @@
"QualityLimitsMovieRuntimeHelpText": "限制会根据电影的播放时长自动调整。",
"QualitySettingsSummary": "质量标准和命名",
"RetentionHelpText": "仅限Usenet设置为零以设置无限保留",
"TorrentDelayHelpText": "延迟几分钟等待获取洪流",
"TorrentDelayHelpText": "抓取种子前需等待的延迟时间(分钟)",
"UsenetDelayHelpText": "延迟几分钟才能等待从Usenet获取发布",
"SqliteVersionCheckUpgradeRequiredMessage": "当前不再支持当前安装的SQLite版本{0}。请升级SQLite至少到版本{1}。",
"ShowCinemaRelease": "显示上映日期",
@ -1019,9 +1019,9 @@
"DiscordUrlInSlackNotification": "你将Discord通知设为Slack通知如设为Discord通知功能更好受到影响的通知是: {0}",
"Database": "数据库",
"RefreshMonitoredIntervalHelpText": "从下载客户端刷新已追踪下载项的频率,最少 1 分钟",
"RssSyncIntervalHelpText": "以分钟间隔设置为0关闭该功能这会停止所有影片的自动抓取下载",
"RssSyncIntervalHelpText": "间隔时间(分钟),设置为零则禁用(这会停止自动抓取发布资源",
"InstanceName": "中文",
"ShowCollectionDetails": "显示收藏状态",
"ShowCollectionDetails": "显示合集状态",
"UnableToLoadCollections": "不能加载收藏",
"Collections": "合集",
"AllCollectionsHiddenDueToFilter": "由于应用了过滤器,所有合集已被隐藏。",
@ -1259,9 +1259,9 @@
"RemoveSelectedBlocklistMessageText": "您确认您想要从阻止列表中移除选中的项目吗?",
"SelectDownloadClientModalTitle": "{modalTitle} - 选择下载客户端",
"SetReleaseGroupModalTitle": "{modalTitle} - 设置发布组",
"ShowRottenTomatoesRating": "显示番茄评分",
"ShowRottenTomatoesRatingHelpText": "在海报下显示烂番茄评分",
"ShowTmdbRating": "显示TMDb评分",
"ShowRottenTomatoesRating": "显示烂番茄指数",
"ShowRottenTomatoesRatingHelpText": "在海报下显示烂番茄指数",
"ShowTmdbRating": "显示 TMDb 评分",
"TableOptionsButton": "表格选项按钮",
"TablePageSize": "页面大小",
"TablePageSizeMaximum": "页面大小不得超过 {maximumValue}",
@ -1291,7 +1291,7 @@
"SubtitleLanguages": "字幕语言",
"True": "是",
"RootFolderPath": "根目录路径",
"ShowImdbRating": "显示IMDb评分",
"ShowImdbRating": "显示 IMDb 评分",
"ShowImdbRatingHelpText": "在海报下显示 IMDb 评分",
"AutoTaggingLoadError": "无法加载自动标记",
"OverrideGrabNoMovie": "电影必须被选中",
@ -1672,9 +1672,9 @@
"DeleteSelectedCustomFormats": "删除自定义命名格式",
"DeleteSelectedCustomFormatsMessageText": "您确定要删除选定的{count}导入列表吗?",
"ReleaseDate": "发布日期",
"ShowDigitalRelease": "显示资源发布日期",
"ShowDigitalReleaseHelpText": "在海报下显示数字版资源发布日期",
"ShowPhysicalRelease": "碟片版发布日期",
"ShowDigitalRelease": "显示数字版发布日期",
"ShowDigitalReleaseHelpText": "在海报下显示数字版发布日期",
"ShowPhysicalRelease": "显示碟片版发布日期",
"ShowPhysicalReleaseHelpText": "在海报下显示碟片版发布日期",
"IncludePopular": "包含热门推荐",
"IncludeTrending": "包含流行推荐",
@ -1826,7 +1826,15 @@
"MinimumCustomFormatScoreIncrementHelpText": "{appName} 将新版本视为升级版本之前,新版本资源相较于现有版本在自定义格式分数上的最小提升",
"Recommended": "已推荐",
"LastSearched": "最近搜索",
"ShowTraktRatingPosterHelpText": "在海报下显示烂番茄评分",
"ShowTraktRatingPosterHelpText": "在海报下显示 Trakt 评分",
"SmartReplace": "智能替换",
"SmartReplaceHint": "短划线或空格短划线取决于名称"
"SmartReplaceHint": "短划线或空格短划线取决于名称",
"NotificationsGotifySettingsPreferredMetadataLinkHelpText": "仅支持单个链接的客户端的元数据链接",
"ShowTraktRating": "显示 Trakt 评分",
"TraktVotes": "Trakt 票数",
"AnnouncedMovieAvailabilityDescription": "一旦添加至 {appName} 便被视为可用的电影。",
"InCinemasMovieAvailabilityDescription": "一旦在影院上映便被视为可用的电影。",
"ReleasedMovieAvailabilityDescription": "一旦蓝光或流媒体版本发布便被视为可用的电影。",
"TraktRating": "Trakt 评分",
"FolderNameTokens": "文件名标记"
}

View file

@ -30,9 +30,9 @@ public class MovieFileMovingService : IMoveMovieFiles
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 @@ public MovieFileMovingService(IUpdateMovieFileService updateMovieFileService,
IDiskProvider diskProvider,
IMediaFileAttributeService mediaFileAttributeService,
IImportScript scriptImportDecider,
IRootFolderService rootFolderService,
IEventAggregator eventAggregator,
IConfigService configService,
IRootFolderService rootFolderService,
Logger logger)
{
_updateMovieFileService = updateMovieFileService;
@ -52,9 +52,9 @@ public MovieFileMovingService(IUpdateMovieFileService updateMovieFileService,
_diskProvider = diskProvider;
_mediaFileAttributeService = mediaFileAttributeService;
_scriptImportDecider = scriptImportDecider;
_rootFolderService = rootFolderService;
_eventAggregator = eventAggregator;
_configService = configService;
_rootFolderService = rootFolderService;
_logger = logger;
}
@ -167,13 +167,17 @@ private void EnsureMovieFolder(MovieFile movieFile, LocalMovie localMovie, strin
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.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 @@ private Movie RefreshMovieInfo(int movieId)
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 @@ public class FileNameBuilder : IBuildFileNames
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 @@ public static string TitleThe(string title)
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 @@ private void AddMovieTokens(Dictionary<string, Func<TokenMatch, string>> tokenHa
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 @@ public static class 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 @@ public MovieController(IBroadcastSignalRMessage signalRBroadcaster,
_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);
}

View file

@ -86,9 +86,16 @@ public ActionResult<TProviderResource> CreateProvider([FromBody] TProviderResour
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<TProviderResource> UpdateProvider([FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false)
public ActionResult<TProviderResource> UpdateProvider([FromRoute] int id, [FromBody] TProviderResource providerResource, [FromQuery] bool forceSave = false)
{
var existingDefinition = _providerFactory.Find(providerResource.Id);
// TODO: Remove fallback to Id from body in next API version bump
var existingDefinition = _providerFactory.Find(id) ?? _providerFactory.Find(providerResource.Id);
if (existingDefinition == null)
{
return NotFound();
}
var providerDefinition = GetDefinition(providerResource, existingDefinition, true, !forceSave, false);
// Compare settings separately because they are not serialized with the definition.
@ -105,7 +112,7 @@ public ActionResult<TProviderResource> UpdateProvider([FromBody] TProviderResour
_providerFactory.Update(providerDefinition);
}
return Accepted(providerResource.Id);
return Accepted(existingDefinition.Id);
}
[HttpPut("bulk")]

View file

@ -1907,6 +1907,15 @@
"DownloadClient"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "forceSave",
"in": "query",
@ -1914,14 +1923,6 @@
"type": "boolean",
"default": false
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@ -2836,6 +2837,15 @@
"ImportList"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "forceSave",
"in": "query",
@ -2843,14 +2853,6 @@
"type": "boolean",
"default": false
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@ -3611,6 +3613,15 @@
"Indexer"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "forceSave",
"in": "query",
@ -3618,14 +3629,6 @@
"type": "boolean",
"default": false
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@ -4520,6 +4523,15 @@
"Metadata"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "forceSave",
"in": "query",
@ -4527,14 +4539,6 @@
"type": "boolean",
"default": false
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
@ -5704,6 +5708,15 @@
"Notification"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"format": "int32"
}
},
{
"name": "forceSave",
"in": "query",
@ -5711,14 +5724,6 @@
"type": "boolean",
"default": false
}
},
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {

View file

@ -20,6 +20,8 @@ public async Task InvokeAsync(HttpContext context)
if (_urlBase.IsNotNullOrWhiteSpace() && context.Request.PathBase.Value.IsNullOrWhiteSpace())
{
context.Response.Redirect($"{_urlBase}{context.Request.Path}{context.Request.QueryString}");
context.Response.StatusCode = 307;
return;
}

View file

@ -2392,9 +2392,9 @@ camelcase@^5.3.1:
integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
caniuse-lite@^1.0.30001646:
version "1.0.30001651"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz#52de59529e8b02b1aedcaaf5c05d9e23c0c28138"
integrity sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==
version "1.0.30001667"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz"
integrity sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw==
chalk@^1.1.3:
version "1.1.3"
@ -2461,15 +2461,6 @@ clean-stack@^2.0.0:
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==
clipboard@2.0.11:
version "2.0.11"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.11.tgz#62180360b97dd668b6b3a84ec226975762a70be5"
integrity sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==
dependencies:
good-listener "^1.2.2"
select "^1.1.2"
tiny-emitter "^2.0.0"
clone-deep@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
@ -2607,7 +2598,21 @@ copy-anything@^2.0.1:
dependencies:
is-what "^3.14.1"
core-js-compat@^3.37.1, core-js-compat@^3.38.0:
copy-to-clipboard@3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz#55ac43a1db8ae639a4bd99511c148cdd1b83a1b0"
integrity sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==
dependencies:
toggle-selection "^1.0.6"
core-js-compat@^3.37.1:
version "3.38.0"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.38.0.tgz#d93393b1aa346b6ee683377b0c31172ccfe607aa"
integrity sha512-75LAicdLa4OJVwFxFbQR3NdnZjNgX6ILpVcVzcC4T2smerB5lELMrJQQQoWV6TiuC/vlaFqgU2tKQx9w5s0e0A==
dependencies:
browserslist "^4.23.3"
core-js-compat@^3.38.0:
version "3.38.1"
resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.38.1.tgz#2bc7a298746ca5a7bcb9c164bcb120f2ebc09a09"
integrity sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==
@ -2869,11 +2874,6 @@ del@^6.1.1:
rimraf "^3.0.2"
slash "^3.0.0"
delegate@^3.1.2:
version "3.2.0"
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
detect-node-es@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
@ -3813,13 +3813,6 @@ globjoin@^0.1.4:
resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43"
integrity sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==
good-listener@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
integrity sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==
dependencies:
delegate "^3.1.2"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
@ -6269,11 +6262,6 @@ section-iterator@^2.0.0:
resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a"
integrity sha512-xvTNwcbeDayXotnV32zLb3duQsP+4XosHpb/F+tu6VzEZFmIjzPdNk6/O+QOOx5XTh08KL2ufdXeCO33p380pQ==
select@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d"
integrity sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==
"semver@2 || 3 || 4 || 5", semver@^5.6.0:
version "5.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
@ -6785,11 +6773,6 @@ time-stamp@^1.0.0:
resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3"
integrity sha512-gLCeArryy2yNTRzTGKbZbloctj64jkZ57hj5zdraXue6aFgd6PmvVtEyiUU+hvU0v7q08oVv8r8ev0tRo6bvgw==
tiny-emitter@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
tiny-invariant@^1.0.2:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
@ -6843,6 +6826,11 @@ to-space-case@^1.0.0:
dependencies:
to-no-case "^1.0.0"
toggle-selection@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
"tough-cookie@^2.3.3 || ^3.0.1 || ^4.0.0":
version "4.1.4"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.4.tgz#945f1461b45b5a8c76821c33ea49c3ac192c1b36"