1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-17 21:26:13 -04:00

Compare commits

...

13 Commits

Author SHA1 Message Date
Bogdan
4e14ce022c New: Bulk manage custom formats 2024-08-25 17:27:30 -07:00
Bogdan
a9b93dd9c6 Fixed: Paths for renamed episode files in Custom Script and Webhook 2024-08-25 17:24:52 -07:00
Bogdan
50d7e8fed4 Fixed: Hide reboot and shutdown UI buttons on docker 2024-08-25 17:24:40 -07:00
Bogdan
402db9128c New: Bypass IP addresses ranges in proxies 2024-08-25 17:24:30 -07:00
bakerboy448
846333ddf0 Fixed: Trim spaces and empty values in Proxy Bypass List 2024-08-25 20:24:16 -04:00
Bogdan
dde28cbd7e Fix disabled style for monitor toggle button 2024-08-25 17:23:33 -07:00
Bogdan
8ceb306bf1 Fixed: Ensure Root Folder exists when Adding Series 2024-08-25 20:23:24 -04:00
Treycos
8af4246ff9 Updated code action fixall value for VSCode 2024-08-25 20:22:42 -04:00
Treycos
a2e06e9e65 Link polymorphic static typing 2024-08-25 20:21:50 -04:00
Treycos
ae7b187e41 Convert Icon to Typescript 2024-08-25 20:21:06 -04:00
Treycos
63b4998c8e Convert Button to TypeScript 2024-08-25 20:20:52 -04:00
Mark McDowall
45665886d6 Bump version to 4.0.9 2024-08-25 16:52:30 -07:00
Weblate
860424ac22 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: GkhnGRBZ <gkhn.gurbuz@hotmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Jason54 <jason54700.jg@gmail.com>
Co-authored-by: Kerk en IT <info@kerkenit.nl>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/nl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/tr/
Translation: Servarr/Sonarr
2024-08-25 16:52:16 -07:00
60 changed files with 1041 additions and 414 deletions

View File

@@ -22,7 +22,7 @@ env:
FRAMEWORK: net6.0
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
SONARR_MAJOR_VERSION: 4
VERSION: 4.0.8
VERSION: 4.0.9
jobs:
backend:

View File

@@ -9,7 +9,7 @@
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll": true
"source.fixAll": "explicit"
},
"typescript.preferences.quoteStyle": "single",

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import TooltipPosition from 'Helpers/Props/TooltipPosition';
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind = kinds.DEFAULT;
let iconKind: IconProps['kind'] = kinds.DEFAULT;
let title = translate('Downloading');
if (status === 'paused') {

View File

@@ -6,6 +6,7 @@ import AppSectionState, {
PagedAppSectionState,
} from 'App/State/AppSectionState';
import Language from 'Language/Language';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion';
@@ -48,6 +49,11 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {}
@@ -66,6 +72,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState {
advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState;
general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState;

View File

@@ -1,73 +0,0 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { PureComponent } from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Icon.css';
class Icon extends PureComponent {
//
// Render
render() {
const {
containerClassName,
className,
name,
kind,
size,
title,
isSpinning,
...otherProps
} = this.props;
const icon = (
<FontAwesomeIcon
className={classNames(
className,
styles[kind]
)}
icon={name}
spin={isSpinning}
style={{
fontSize: `${size}px`
}}
{...otherProps}
/>
);
if (title) {
return (
<span
className={containerClassName}
title={typeof title === 'function' ? title() : title}
>
{icon}
</span>
);
}
return icon;
}
}
Icon.propTypes = {
containerClassName: PropTypes.string,
className: PropTypes.string,
name: PropTypes.object.isRequired,
kind: PropTypes.string.isRequired,
size: PropTypes.number.isRequired,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
isSpinning: PropTypes.bool.isRequired,
fixedWidth: PropTypes.bool.isRequired
};
Icon.defaultProps = {
kind: kinds.DEFAULT,
size: 14,
isSpinning: false,
fixedWidth: false
};
export default Icon;

View File

@@ -0,0 +1,59 @@
import {
FontAwesomeIcon,
FontAwesomeIconProps,
} from '@fortawesome/react-fontawesome';
import classNames from 'classnames';
import React, { ComponentProps } from 'react';
import { kinds } from 'Helpers/Props';
import styles from './Icon.css';
export interface IconProps
extends Omit<
FontAwesomeIconProps,
'icon' | 'spin' | 'name' | 'title' | 'size'
> {
containerClassName?: ComponentProps<'span'>['className'];
name: FontAwesomeIconProps['icon'];
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: number;
isSpinning?: FontAwesomeIconProps['spin'];
title?: string | (() => string);
}
export default function Icon({
containerClassName,
className,
name,
kind = kinds.DEFAULT,
size = 14,
title,
isSpinning = false,
fixedWidth = false,
...otherProps
}: IconProps) {
const icon = (
<FontAwesomeIcon
className={classNames(className, styles[kind])}
icon={name}
spin={isSpinning}
fixedWidth={fixedWidth}
style={{
fontSize: `${size}px`,
}}
{...otherProps}
/>
);
if (title) {
return (
<span
className={containerClassName}
title={typeof title === 'function' ? title() : title}
>
{icon}
</span>
);
}
return icon;
}

View File

@@ -1,54 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import Link from './Link';
import styles from './Button.css';
class Button extends Component {
//
// Render
render() {
const {
className,
buttonGroupPosition,
kind,
size,
children,
...otherProps
} = this.props;
return (
<Link
className={classNames(
className,
styles[kind],
styles[size],
buttonGroupPosition && styles[buttonGroupPosition]
)}
{...otherProps}
>
{children}
</Link>
);
}
}
Button.propTypes = {
className: PropTypes.string.isRequired,
buttonGroupPosition: PropTypes.oneOf(align.all),
kind: PropTypes.oneOf(kinds.all),
size: PropTypes.oneOf(sizes.all),
children: PropTypes.node
};
Button.defaultProps = {
className: styles.button,
kind: kinds.DEFAULT,
size: sizes.MEDIUM
};
export default Button;

View File

@@ -0,0 +1,35 @@
import classNames from 'classnames';
import React from 'react';
import { align, kinds, sizes } from 'Helpers/Props';
import Link, { LinkProps } from './Link';
import styles from './Button.css';
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
buttonGroupPosition?: Extract<
(typeof align.all)[number],
keyof typeof styles
>;
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
children: Required<LinkProps['children']>;
}
export default function Button({
className = styles.button,
buttonGroupPosition,
kind = kinds.DEFAULT,
size = sizes.MEDIUM,
...otherProps
}: ButtonProps) {
return (
<Link
className={classNames(
className,
styles[kind],
styles[size],
buttonGroupPosition && styles[buttonGroupPosition]
)}
{...otherProps}
/>
);
}

View File

@@ -1,96 +1,89 @@
import classNames from 'classnames';
import React, {
ComponentClass,
FunctionComponent,
ComponentPropsWithoutRef,
ElementType,
SyntheticEvent,
useCallback,
} from 'react';
import { Link as RouterLink } from 'react-router-dom';
import styles from './Link.css';
interface ReactRouterLinkProps {
to?: string;
}
export type LinkProps<C extends ElementType = 'button'> =
ComponentPropsWithoutRef<C> & {
component?: C;
to?: string;
target?: string;
isDisabled?: LinkProps<C>['disabled'];
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
};
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
className?: string;
component?:
| string
| FunctionComponent<LinkProps>
| ComponentClass<LinkProps, unknown>;
to?: string;
target?: string;
isDisabled?: boolean;
noRouter?: boolean;
onPress?(event: SyntheticEvent): void;
}
function Link(props: LinkProps) {
const {
className,
component = 'button',
to,
target,
type,
isDisabled,
noRouter = false,
onPress,
...otherProps
} = props;
export default function Link<C extends ElementType = 'button'>({
className,
component,
to,
target,
type,
isDisabled,
noRouter,
onPress,
...otherProps
}: LinkProps<C>) {
const Component = component || 'button';
const onClick = useCallback(
(event: SyntheticEvent) => {
if (!isDisabled && onPress) {
onPress(event);
if (isDisabled) {
return;
}
onPress?.(event);
},
[isDisabled, onPress]
);
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
target,
};
let el = component;
if (to) {
if (/\w+?:\/\//.test(to)) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_blank';
linkProps.rel = 'noreferrer';
} else if (noRouter) {
el = 'a';
linkProps.href = to;
linkProps.target = target || '_self';
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
el = RouterLink;
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
linkProps.target = target;
}
}
if (el === 'button' || el === 'input') {
linkProps.type = type || 'button';
linkProps.disabled = isDisabled;
}
linkProps.className = classNames(
const linkClass = classNames(
className,
styles.link,
to && styles.to,
isDisabled && 'isDisabled'
);
const elementProps = {
...otherProps,
type,
...linkProps,
};
if (to) {
const toLink = /\w+?:\/\//.test(to);
elementProps.onClick = onClick;
if (toLink || noRouter) {
return (
<a
href={to}
target={target || (toLink ? '_blank' : '_self')}
rel={toLink ? 'noreferrer' : undefined}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return React.createElement(el, elementProps);
return (
<RouterLink
to={`${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`}
target={target}
className={linkClass}
onClick={onClick}
{...otherProps}
/>
);
}
return (
<Component
type={type || 'button'}
target={target}
className={linkClass}
disabled={isDisabled}
onClick={onClick}
{...otherProps}
/>
);
}
export default Link;

View File

@@ -3,9 +3,9 @@
padding: 0;
font-size: inherit;
}
.isDisabled {
color: var(--disabledColor);
cursor: not-allowed;
&.isDisabled {
color: var(--disabledColor);
cursor: not-allowed;
}
}

View File

@@ -6,7 +6,7 @@ import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
import styles from './PageHeader.css';
@@ -83,7 +83,8 @@ class PageHeader extends Component {
size={14}
title={translate('Donate')}
/>
<PageHeaderActionsMenuConnector
<PageHeaderActionsMenu
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>

View File

@@ -1,90 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
function PageHeaderActionsMenu(props) {
const {
formsAuth,
onKeyboardShortcutsPress,
onRestartPress,
onShutdownPress
} = props;
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon
name={icons.INTERACTIVE}
title={translate('Menu')}
/>
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon
className={styles.itemIcon}
name={icons.KEYBOARD}
/>
{translate('KeyboardShortcuts')}
</MenuItem>
<MenuItemSeparator />
<MenuItem onPress={onRestartPress}>
<Icon
className={styles.itemIcon}
name={icons.RESTART}
/>
{translate('Restart')}
</MenuItem>
<MenuItem onPress={onShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
{
formsAuth &&
<div className={styles.separator} />
}
{
formsAuth &&
<MenuItem
to={`${window.Sonarr.urlBase}/logout`}
noRouter={true}
>
<Icon
className={styles.itemIcon}
name={icons.LOGOUT}
/>
{translate('Logout')}
</MenuItem>
}
</MenuContent>
</Menu>
</div>
);
}
PageHeaderActionsMenu.propTypes = {
formsAuth: PropTypes.bool.isRequired,
onKeyboardShortcutsPress: PropTypes.func.isRequired,
onRestartPress: PropTypes.func.isRequired,
onShutdownPress: PropTypes.func.isRequired
};
export default PageHeaderActionsMenu;

View File

@@ -0,0 +1,87 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Icon from 'Components/Icon';
import Menu from 'Components/Menu/Menu';
import MenuButton from 'Components/Menu/MenuButton';
import MenuContent from 'Components/Menu/MenuContent';
import MenuItem from 'Components/Menu/MenuItem';
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
import { align, icons, kinds } from 'Helpers/Props';
import { restart, shutdown } from 'Store/Actions/systemActions';
import translate from 'Utilities/String/translate';
import styles from './PageHeaderActionsMenu.css';
interface PageHeaderActionsMenuProps {
onKeyboardShortcutsPress(): void;
}
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
const { onKeyboardShortcutsPress } = props;
const dispatch = useDispatch();
const { authentication, isDocker } = useSelector(
(state: AppState) => state.system.status.item
);
const formsAuth = authentication === 'forms';
const handleRestartPress = useCallback(() => {
dispatch(restart());
}, [dispatch]);
const handleShutdownPress = useCallback(() => {
dispatch(shutdown());
}, [dispatch]);
return (
<div>
<Menu alignMenu={align.RIGHT}>
<MenuButton className={styles.menuButton} aria-label="Menu Button">
<Icon name={icons.INTERACTIVE} title={translate('Menu')} />
</MenuButton>
<MenuContent>
<MenuItem onPress={onKeyboardShortcutsPress}>
<Icon className={styles.itemIcon} name={icons.KEYBOARD} />
{translate('KeyboardShortcuts')}
</MenuItem>
{isDocker ? null : (
<>
<MenuItemSeparator />
<MenuItem onPress={handleRestartPress}>
<Icon className={styles.itemIcon} name={icons.RESTART} />
{translate('Restart')}
</MenuItem>
<MenuItem onPress={handleShutdownPress}>
<Icon
className={styles.itemIcon}
name={icons.SHUTDOWN}
kind={kinds.DANGER}
/>
{translate('Shutdown')}
</MenuItem>
</>
)}
{formsAuth ? (
<>
<MenuItemSeparator />
<MenuItem to={`${window.Sonarr.urlBase}/logout`} noRouter={true}>
<Icon className={styles.itemIcon} name={icons.LOGOUT} />
{translate('Logout')}
</MenuItem>
</>
) : null}
</MenuContent>
</Menu>
</div>
);
}
export default PageHeaderActionsMenu;

View File

@@ -1,56 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { restart, shutdown } from 'Store/Actions/systemActions';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
function createMapStateToProps() {
return createSelector(
(state) => state.system.status,
(status) => {
return {
formsAuth: status.item.authentication === 'forms'
};
}
);
}
const mapDispatchToProps = {
restart,
shutdown
};
class PageHeaderActionsMenuConnector extends Component {
//
// Listeners
onRestartPress = () => {
this.props.restart();
};
onShutdownPress = () => {
this.props.shutdown();
};
//
// Render
render() {
return (
<PageHeaderActionsMenu
{...this.props}
onRestartPress={this.onRestartPress}
onShutdownPress={this.onShutdownPress}
/>
);
}
}
PageHeaderActionsMenuConnector.propTypes = {
restart: PropTypes.func.isRequired,
shutdown: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);

View File

@@ -525,7 +525,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
<>
{indexerFlags ? (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
anchor={<Icon name={icons.FLAG} />}
title={translate('IndexerFlags')}
body={<IndexerFlags indexerFlags={indexerFlags} />}
position={tooltipPositions.LEFT}

View File

@@ -264,7 +264,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
<TableRowCell className={styles.indexerFlags}>
{indexerFlags ? (
<Popover
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
anchor={<Icon name={icons.FLAG} />}
title={translate('IndexerFlags')}
body={<IndexerFlags indexerFlags={indexerFlags} />}
position={tooltipPositions.LEFT}

View File

@@ -1,11 +1,10 @@
import { IconDefinition } from '@fortawesome/free-regular-svg-icons';
import React from 'react';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import styles from './SeriesIndexOverviewInfoRow.css';
interface SeriesIndexOverviewInfoRowProps {
title?: string;
iconName?: IconDefinition;
iconName: IconProps['name'];
label: string | null;
}

View File

@@ -8,6 +8,7 @@ import ParseToolbarButton from 'Parse/ParseToolbarButton';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton';
function CustomFormatSettingsPage() {
return (
@@ -21,6 +22,8 @@ function CustomFormatSettingsPage() {
<PageToolbarSeparator />
<ParseToolbarButton />
<ManageCustomFormatsToolbarButton />
</>
}
/>

View File

@@ -0,0 +1,28 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent';
interface ManageCustomFormatsEditModalProps {
isOpen: boolean;
customFormatIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageCustomFormatsEditModal(
props: ManageCustomFormatsEditModalProps
) {
const { isOpen, customFormatIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageCustomFormatsEditModalContent
customFormatIds={customFormatIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageCustomFormatsEditModal;

View File

@@ -0,0 +1,16 @@
.modalFooter {
composes: modalFooter from '~Components/Modal/ModalFooter.css';
justify-content: space-between;
}
.selected {
font-weight: bold;
}
@media only screen and (max-width: $breakpointExtraSmall) {
.modalFooter {
flex-direction: column;
gap: 10px;
}
}

View File

@@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'modalFooter': string;
'selected': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,125 @@
import React, { useCallback, useState } from 'react';
import FormGroup from 'Components/Form/FormGroup';
import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import Button from 'Components/Link/Button';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { inputTypes } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './ManageCustomFormatsEditModalContent.css';
interface SavePayload {
includeCustomFormatWhenRenaming?: boolean;
}
interface ManageCustomFormatsEditModalContentProps {
customFormatIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
isDisabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
];
function ManageCustomFormatsEditModalContent(
props: ManageCustomFormatsEditModalContentProps
) {
const { customFormatIds, onSavePress, onModalClose } = props;
const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] =
useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (includeCustomFormatWhenRenaming !== NO_CHANGE) {
hasChanges = true;
payload.includeCustomFormatWhenRenaming =
includeCustomFormatWhenRenaming === 'enabled';
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'includeCustomFormatWhenRenaming':
setIncludeCustomFormatWhenRenaming(value);
break;
default:
console.warn(
`EditCustomFormatsModalContent Unknown Input: '${name}'`
);
}
},
[]
);
const selectedCount = customFormatIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedCustomFormats')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('IncludeCustomFormatWhenRenaming')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="includeCustomFormatWhenRenaming"
value={includeCustomFormatWhenRenaming}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountCustomFormatsSelected', {
count: selectedCount,
})}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageCustomFormatsEditModalContent;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent';
interface ManageCustomFormatsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageCustomFormatsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageCustomFormatsModal;

View File

@@ -0,0 +1,16 @@
.leftButtons,
.rightButtons {
display: flex;
flex: 1 0 50%;
flex-wrap: wrap;
}
.rightButtons {
justify-content: flex-end;
}
.deleteButton {
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}

View File

@@ -0,0 +1,9 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'deleteButton': string;
'leftButtons': string;
'rightButtons': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,241 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CustomFormatAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ConfirmModal from 'Components/Modal/ConfirmModal';
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 Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,
setManageCustomFormatsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal';
import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow';
import styles from './ManageCustomFormatsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'includeCustomFormatWhenRenaming',
label: () => translate('IncludeCustomFormatWhenRenaming'),
isSortable: true,
isVisible: true,
},
];
interface ManageCustomFormatsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageCustomFormatsModalContent(
props: ManageCustomFormatsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
sortKey,
sortDirection,
}: CustomFormatAppState = useSelector(
createClientSideCollectionSelector('settings.customFormats')
);
const dispatch = useDispatch();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds: number[] = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const selectedCount = selectedIds.length;
const onSortPress = useCallback(
(value: string) => {
dispatch(setManageCustomFormatsSort({ sortKey: value }));
},
[dispatch]
);
const onDeletePress = useCallback(() => {
setIsDeleteModalOpen(true);
}, [setIsDeleteModalOpen]);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(() => {
dispatch(bulkDeleteCustomFormats({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditCustomFormats({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load custom formats.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageCustomFormats')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoCustomFormatsFound')}</Alert>
) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {
return (
<ManageCustomFormatsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageCustomFormatsEditModal
isOpen={isEditModalOpen}
customFormatIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedCustomFormats')}
message={translate('DeleteSelectedCustomFormatsMessageText', {
count: selectedIds.length,
})}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageCustomFormatsModalContent;

View File

@@ -0,0 +1,6 @@
.name,
.includeCustomFormatWhenRenaming {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

@@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'includeCustomFormatWhenRenaming': string;
'name': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@@ -0,0 +1,54 @@
import React, { useCallback } from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import styles from './ManageCustomFormatsModalRow.css';
interface ManageCustomFormatsModalRowProps {
id: number;
name: string;
includeCustomFormatWhenRenaming: boolean;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
const {
id,
isSelected,
name,
includeCustomFormatWhenRenaming,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.includeCustomFormatWhenRenaming}>
{includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')}
</TableRowCell>
</TableRow>
);
}
export default ManageCustomFormatsModalRow;

View File

@@ -0,0 +1,28 @@
import React from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import ManageCustomFormatsModal from './ManageCustomFormatsModal';
function ManageCustomFormatsToolbarButton() {
const [isManageModalOpen, openManageModal, closeManageModal] =
useModalOpenState(false);
return (
<>
<PageToolbarButton
label={translate('ManageCustomFormats')}
iconName={icons.MANAGE}
onPress={openManageModal}
/>
<ManageCustomFormatsModal
isOpen={isManageModalOpen}
onModalClose={closeManageModal}
/>
</>
);
}
export default ManageCustomFormatsToolbarButton;

View File

@@ -13,4 +13,4 @@
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
}

View File

@@ -220,9 +220,9 @@ function ManageDownloadClientsModalContent(
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
)}
) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table

View File

@@ -13,4 +13,4 @@
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
}

View File

@@ -198,9 +198,9 @@ function ManageImportListsModalContent(
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert>
)}
) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table

View File

@@ -13,4 +13,4 @@
composes: button from '~Components/Link/Button.css';
margin-right: 10px;
}
}

View File

@@ -215,9 +215,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && (
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert>
)}
) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table

View File

@@ -1,7 +1,12 @@
import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createSetClientSideCollectionSortReducer
from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
@@ -22,6 +27,9 @@ export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
export const BULK_EDIT_CUSTOM_FORMATS = 'settings/downloadClients/bulkEditCustomFormats';
export const BULK_DELETE_CUSTOM_FORMATS = 'settings/downloadClients/bulkDeleteCustomFormats';
export const SET_MANAGE_CUSTOM_FORMATS_SORT = 'settings/downloadClients/setManageCustomFormatsSort';
//
// Action Creators
@@ -29,6 +37,9 @@ export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
export const bulkEditCustomFormats = createThunk(BULK_EDIT_CUSTOM_FORMATS);
export const bulkDeleteCustomFormats = createThunk(BULK_DELETE_CUSTOM_FORMATS);
export const setManageCustomFormatsSort = createAction(SET_MANAGE_CUSTOM_FORMATS_SORT);
export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
return {
@@ -48,20 +59,30 @@ export default {
// State
defaultState: {
isSchemaFetching: false,
isSchemaPopulated: false,
isFetching: false,
isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
pendingChanges: {},
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: {
includeCustomFormatWhenRenaming: false
},
error: null,
isDeleting: false,
deleteError: null,
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
sortKey: 'name',
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: ({ name }) => {
return name.toLocaleLowerCase();
}
}
},
//
@@ -83,7 +104,10 @@ export default {
}));
createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch);
}
},
[BULK_EDIT_CUSTOM_FORMATS]: createBulkEditItemHandler(section, '/customformat/bulk'),
[BULK_DELETE_CUSTOM_FORMATS]: createBulkRemoveItemHandler(section, '/customformat/bulk')
},
//
@@ -103,7 +127,9 @@ export default {
newState.pendingChanges = pendingChanges;
return updateSectionState(state, section, newState);
}
},
[SET_MANAGE_CUSTOM_FORMATS_SORT]: createSetClientSideCollectionSortReducer(section)
}
};

View File

@@ -96,8 +96,8 @@ export default {
sortKey: 'name',
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: function(item) {
return item.name.toLowerCase();
name: ({ name }) => {
return name.toLocaleLowerCase();
}
}
},

View File

@@ -101,8 +101,8 @@ export default {
sortKey: 'name',
sortDirection: sortDirections.ASCENDING,
sortPredicates: {
name: function(item) {
return item.name.toLowerCase();
name: ({ name }) => {
return name.toLocaleLowerCase();
}
}
},

View File

@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@@ -97,7 +97,7 @@ function Health() {
{items.map((item) => {
const source = item.source;
let kind = kinds.WARNING;
let kind: IconProps['kind'] = kinds.WARNING;
switch (item.type.toLowerCase()) {
case 'error':
kind = kinds.DANGER;

View File

@@ -2,7 +2,7 @@ import moment from 'moment';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CommandBody } from 'Commands/Command';
import Icon from 'Components/Icon';
import Icon, { IconProps } from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
@@ -19,7 +19,10 @@ import translate from 'Utilities/String/translate';
import QueuedTaskRowNameCell from './QueuedTaskRowNameCell';
import styles from './QueuedTaskRow.css';
function getStatusIconProps(status: string, message: string | undefined) {
function getStatusIconProps(
status: string,
message: string | undefined
): IconProps {
const title = titleCase(status);
switch (status) {

View File

@@ -1,12 +1,14 @@
import ModelBase from 'App/ModelBase';
export interface QualityProfileFormatItem {
format: number;
name: string;
score: number;
}
interface CustomFormat {
id: number;
interface CustomFormat extends ModelBase {
name: string;
includeCustomFormatWhenRenaming: boolean;
}
export default CustomFormat;

View File

@@ -30,7 +30,8 @@ namespace NzbDrone.Common.Http.Proxy
{
if (!string.IsNullOrWhiteSpace(BypassFilter))
{
var hostlist = BypassFilter.Split(',');
var hostlist = BypassFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for (var i = 0; i < hostlist.Length; i++)
{
if (hostlist[i].StartsWith("*"))

View File

@@ -5,6 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="IPAddressRange" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />

View File

@@ -12,7 +12,7 @@ namespace NzbDrone.Core.Test.Http
{
private HttpProxySettings GetProxySettings()
{
return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com", true, null, null);
return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com,172.16.0.0/12", true, null, null);
}
[Test]
@@ -23,6 +23,7 @@ namespace NzbDrone.Core.Test.Http
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://eu.httpbin.org/get")).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://google.com/get")).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://localhost:8654/get")).Should().BeTrue();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.21.0.1:8989/api/v3/indexer/schema")).Should().BeTrue();
}
[Test]
@@ -31,6 +32,7 @@ namespace NzbDrone.Core.Test.Http
var settings = GetProxySettings();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://bing.com/get")).Should().BeFalse();
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.3.0.1:8989/api/v3/indexer/schema")).Should().BeFalse();
}
}
}

View File

@@ -9,10 +9,12 @@ namespace NzbDrone.Core.CustomFormats
public interface ICustomFormatService
{
void Update(CustomFormat customFormat);
void Update(List<CustomFormat> customFormat);
CustomFormat Insert(CustomFormat customFormat);
List<CustomFormat> All();
CustomFormat GetById(int id);
void Delete(int id);
void Delete(List<int> ids);
}
public class CustomFormatService : ICustomFormatService
@@ -51,6 +53,12 @@ namespace NzbDrone.Core.CustomFormats
_cache.Clear();
}
public void Update(List<CustomFormat> customFormat)
{
_formatRepository.UpdateMany(customFormat);
_cache.Clear();
}
public CustomFormat Insert(CustomFormat customFormat)
{
// Add to DB then insert into profiles
@@ -72,5 +80,20 @@ namespace NzbDrone.Core.CustomFormats
_formatRepository.Delete(id);
_cache.Clear();
}
public void Delete(List<int> ids)
{
foreach (var id in ids)
{
var format = _formatRepository.Get(id);
// Remove from profiles before removing from DB
_eventAggregator.PublishEvent(new CustomFormatDeletedEvent(format));
_formatRepository.Delete(id);
}
_cache.Clear();
}
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Linq;
using System.Net;
using NetTools;
using NzbDrone.Common.Http;
using NzbDrone.Common.Http.Proxy;
using NzbDrone.Core.Configuration;
@@ -52,7 +54,15 @@ namespace NzbDrone.Core.Http
// We are utilising the WebProxy implementation here to save us having to reimplement it. This way we use Microsofts implementation
var proxy = new WebProxy(proxySettings.Host + ":" + proxySettings.Port, proxySettings.BypassLocalAddress, proxySettings.BypassListAsArray);
return proxy.IsBypassed((Uri)url);
return proxy.IsBypassed((Uri)url) || IsBypassedByIpAddressRange(proxySettings.BypassListAsArray, url.Host);
}
private static bool IsBypassedByIpAddressRange(string[] bypassList, string host)
{
return bypassList.Any(bypass =>
IPAddressRange.TryParse(bypass, out var ipAddressRange) &&
IPAddress.TryParse(host, out var ipAddress) &&
ipAddressRange.Contains(ipAddress));
}
}
}

View File

@@ -258,6 +258,7 @@
"CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use {appName}'s rename function as a work around.",
"CopyUsingHardlinksSeriesHelpText": "Hardlinks allow {appName} to import seeding torrents to the series folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume",
"CouldNotFindResults": "Couldn't find any results for '{term}'",
"CountCustomFormatsSelected": "{count} custom formats(s) selected",
"CountDownloadClientsSelected": "{count} download client(s) selected",
"CountImportListsSelected": "{count} import list(s) selected",
"CountIndexersSelected": "{count} indexer(s) selected",
@@ -364,6 +365,8 @@
"DeleteRootFolder": "Delete Root Folder",
"DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{path}'?",
"DeleteSelected": "Delete Selected",
"DeleteSelectedCustomFormats": "Delete Custom Format(s)",
"DeleteSelectedCustomFormatsMessageText": "Are you sure you want to delete {count} selected custom format(s)?",
"DeleteSelectedDownloadClients": "Delete Download Client(s)",
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?",
"DeleteSelectedEpisodeFiles": "Delete Selected Episode Files",
@@ -590,6 +593,7 @@
"EditReleaseProfile": "Edit Release Profile",
"EditRemotePathMapping": "Edit Remote Path Mapping",
"EditRestriction": "Edit Restriction",
"EditSelectedCustomFormats": "Edit Selected Custom Formats",
"EditSelectedDownloadClients": "Edit Selected Download Clients",
"EditSelectedImportLists": "Edit Selected Import Lists",
"EditSelectedIndexers": "Edit Selected Indexers",
@@ -1106,6 +1110,7 @@
"Lowercase": "Lowercase",
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
"ManageClients": "Manage Clients",
"ManageCustomFormats": "Manage Custom Formats",
"ManageDownloadClients": "Manage Download Clients",
"ManageEpisodes": "Manage Episodes",
"ManageEpisodesSeason": "Manage Episodes files in this season",
@@ -1255,6 +1260,7 @@
"NoBlocklistItems": "No blocklist items",
"NoChange": "No Change",
"NoChanges": "No Changes",
"NoCustomFormatsFound": "No custom formats found",
"NoDelay": "No Delay",
"NoDownloadClientsFound": "No download clients found",
"NoEpisodeHistory": "No episode history",

View File

@@ -2102,5 +2102,7 @@
"NotificationsTelegramSettingsMetadataLinksHelpText": "Añade un enlace a los metadatos de la serie cuando se envían notificaciones",
"NotificationsTelegramSettingsMetadataLinks": "Enlaces de metadatos",
"DeleteSelected": "Borrar seleccionados",
"DeleteSelectedImportListExclusionsMessageText": "¿Estás seguro que quieres borrar las exclusiones de listas de importación seleccionadas?"
"DeleteSelectedImportListExclusionsMessageText": "¿Estás seguro que quieres borrar las exclusiones de listas de importación seleccionadas?",
"LogSizeLimit": "Límite de tamaño de registro",
"LogSizeLimitHelpText": "Máximo tamaño de archivo de registro en MB antes de archivarlo. Predeterminado es 1MB."
}

View File

@@ -1176,7 +1176,7 @@
"Total": "Total",
"Upcoming": "À venir",
"UpdateAutomaticallyHelpText": "Téléchargez et installez automatiquement les mises à jour. Vous pourrez toujours installer à partir du système : mises à jour",
"UpdateAvailableHealthCheckMessage": "Une nouvelle mise à jour est disponible : {version}",
"UpdateAvailableHealthCheckMessage": "Une nouvelle mise à jour est disponible : {version}",
"UpdateFiltered": "Mise à jour filtrée",
"IconForSpecialsHelpText": "Afficher l'icône pour les épisodes spéciaux (saison 0)",
"Ignored": "Ignoré",
@@ -1724,8 +1724,8 @@
"NotificationsGotifySettingsPriorityHelpText": "Priorité de la notification",
"NotificationsGotifySettingsAppTokenHelpText": "Le jeton d'application généré par Gotify",
"NotificationsGotifySettingsAppToken": "Jeton d'app",
"NotificationsEmbySettingsUpdateLibraryHelpText": "Mettre à jour la bibliothèque lors de l'importation, du changement de nom ou de la suppression",
"NotificationsEmbySettingsSendNotificationsHelpText": "Demandez à Emby d'envoyer des notifications aux fournisseurs configurés. Non pris en charge sur Jellyfin.",
"NotificationsEmbySettingsUpdateLibraryHelpText": "Mise à jour de la bibliothèque en cas d'importation, de renommage ou de suppression",
"NotificationsEmbySettingsSendNotificationsHelpText": "Demander à Emby d'envoyer des notifications aux fournisseurs configurés. Non supporté par Jellyfin.",
"NotificationsEmbySettingsSendNotifications": "Envoyer des notifications",
"NotificationsEmailSettingsServerHelpText": "Nom d'hôte ou adresse IP du serveur de courriel",
"NotificationsEmailSettingsServer": "Serveur",
@@ -2100,5 +2100,9 @@
"SeasonsMonitoredPartial": "Partielle",
"SeasonsMonitoredNone": "Aucune",
"SeasonsMonitoredStatus": "Saisons surveillées",
"NotificationsTelegramSettingsMetadataLinks": "Liens de métadonnées"
"NotificationsTelegramSettingsMetadataLinks": "Liens de métadonnées",
"LogSizeLimitHelpText": "Taille maximale du fichier journal en Mo avant archivage. La valeur par défaut est de 1 Mo.",
"DeleteSelected": "Supprimer la sélection",
"LogSizeLimit": "Limite de taille du journal",
"DeleteSelectedImportListExclusionsMessageText": "Êtes-vous sûr de vouloir supprimer les exclusions de la liste d'importation sélectionnée ?"
}

View File

@@ -207,5 +207,30 @@
"ChangeCategory": "Verander categorie",
"ChownGroup": "chown groep",
"AutoTaggingSpecificationTag": "Tag",
"AddDelayProfileError": "Mislukt om vertragingsprofiel toe te voegen, probeer het later nog eens."
"AddDelayProfileError": "Mislukt om vertragingsprofiel toe te voegen, probeer het later nog eens.",
"BypassDelayIfAboveCustomFormatScoreHelpText": "Schakel omleiding in als de release een score heeft die hoger is dan de geconfigureerde minimale aangepaste formaatscore",
"ConnectionSettingsUrlBaseHelpText": "Voegt een voorvoegsel toe aan de {connectionName} url, zoals {url}",
"CustomFormatsSpecificationRegularExpressionHelpText": "Aangepaste opmaak RegEx is hoofdletterongevoelig",
"CustomFormatsSpecificationRegularExpression": "Reguliere expressie",
"AutoTaggingRequiredHelpText": "Deze {implementationName} voorwaarde moet overeenkomen om de auto tagging regel toe te passen. Anders is een enkele {implementationName} voldoende.",
"BlackholeWatchFolder": "Bekijk map",
"CustomFormatsSpecificationFlag": "Vlag",
"BypassDelayIfAboveCustomFormatScore": "Omzeilen indien boven aangepaste opmaak score",
"BlocklistAndSearch": "Blokkeerlijst en zoeken",
"ChangeCategoryHint": "Verandert download naar de 'Post-Import Categorie' van Downloadclient",
"ChangeCategoryMultipleHint": "Wijzigt downloads naar de 'Post-Import Categorie' van Downloadclient",
"ClearBlocklist": "Blokkeerlijst wissen",
"Clone": "Kloon",
"BlocklistAndSearchHint": "Een vervanger zoeken na het blokkeren",
"BlocklistAndSearchMultipleHint": "Zoekopdrachten voor vervangers starten na het blokkeren van de lijst",
"BlocklistMultipleOnlyHint": "Blocklist zonder te zoeken naar vervangers",
"BlocklistOnly": "Alleen bloklijst",
"BlocklistOnlyHint": "Blokkeer lijst zonder te zoeken naar een vervanger",
"BlocklistFilterHasNoItems": "Het geselecteerde bloklijstfilter bevat geen items",
"BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText": "Minimumscore aangepast formaat vereist om vertraging voor het voorkeursprotocol te omzeilen",
"CountImportListsSelected": "{count} importeer lijst(en) geselecteerd",
"CustomFormatJson": "Aangepast formaat JSON",
"CustomFormatUnknownCondition": "Onbekende aangepaste formaatvoorwaarde '{implementation}'.",
"ClickToChangeIndexerFlags": "Klik om indexeringsvlaggen te wijzigen",
"CustomFormatsSettingsTriggerInfo": "Een Aangepast Formaat wordt toegepast op een uitgave of bestand als het overeenkomt met ten minste één van de verschillende condities die zijn gekozen."
}

View File

@@ -2102,5 +2102,7 @@
"NotificationsTelegramSettingsMetadataLinks": "Links de Metadados",
"NotificationsTelegramSettingsMetadataLinksHelpText": "Adicione links aos metadados da série ao enviar notificações",
"DeleteSelected": "Excluir Selecionado",
"DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja excluir as listas de importação selecionadas das exclusões?"
"DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja excluir as listas de importação selecionadas das exclusões?",
"LogSizeLimit": "Limite de Tamanho do Registro",
"LogSizeLimitHelpText": "Tamanho máximo do arquivo de registro em MB antes do arquivamento. O padrão é 1 MB."
}

View File

@@ -452,7 +452,7 @@
"NotificationsAppriseSettingsStatelessUrlsHelpText": "Bildirimin nereye gönderilmesi gerektiğini belirten, virgülle ayrılmış bir veya daha fazla URL. Kalıcı Depolama kullanılıyorsa boş bırakın.",
"NotificationsDiscordSettingsOnManualInteractionFields": "Manuel Etkileşimlerde",
"NotificationsEmbySettingsSendNotifications": "Bildirim Gönder",
"NotificationsEmbySettingsSendNotificationsHelpText": "MediaBrowser'ın yapılandırılmış sağlayıcılara bildirim göndermesini sağlayın",
"NotificationsEmbySettingsSendNotificationsHelpText": "Emby'nin yapılandırılmış sağlayıcılara bildirim göndermesini sağlayın. Jellyfin'de desteklenmiyor.",
"NotificationsJoinSettingsDeviceIdsHelpText": "Kullanımdan kaldırıldı, bunun yerine Cihaz Adlarını kullanın. Bildirim göndermek istediğiniz Cihaz Kimliklerinin virgülle ayrılmış listesi. Ayarlanmadığı takdirde tüm cihazlar bildirim alacaktır.",
"NotificationsMailgunSettingsApiKeyHelpText": "MailGun'dan oluşturulan API anahtarı",
"NotificationsMailgunSettingsUseEuEndpointHelpText": "AB MailGun uç noktasını kullanmayı etkinleştirin",
@@ -480,7 +480,7 @@
"NotificationsEmailSettingsCcAddressHelpText": "E-posta CC alıcılarının virgülle ayrılmış listesi",
"NotificationsEmailSettingsCcAddress": "CC Adres(ler)i",
"NotificationsEmailSettingsRecipientAddress": "Alıcı Adres(ler)i",
"NotificationsEmbySettingsUpdateLibraryHelpText": "İçe Aktarma, Yeniden Adlandırma veya Silme sırasında Kitaplık Güncellensin mi?",
"NotificationsEmbySettingsUpdateLibraryHelpText": "İçe Aktarma, Yeniden Adlandırma veya Silme sırasında Kitaplığı Güncelleyin",
"NotificationsGotifySettingsAppToken": "Uygulama Jetonu",
"NotificationsJoinSettingsDeviceIds": "Cihaz Kimlikleri",
"NotificationsJoinSettingsNotificationPriority": "Bildirim Önceliği",
@@ -849,5 +849,9 @@
"UnableToImportAutomatically": "Otomatikman İçe Aktarılamıyor",
"Any": "Herhangi",
"ShowTags": "Etiketleri göster",
"ShowTagsHelpText": "Etiketleri posterin altında göster"
"ShowTagsHelpText": "Etiketleri posterin altında göster",
"DeleteSelected": "Seçileni Sil",
"LogSizeLimit": "Log Boyutu Sınırı",
"LogSizeLimitHelpText": "Arşivlemeden önce MB cinsinden maksimum log dosya boyutu. Varsayılan 1 MB'tır.",
"ProgressBarProgress": "İlerleme Çubuğu %{progress} seviyesinde"
}

View File

@@ -253,7 +253,7 @@ namespace NzbDrone.Core.Notifications.CustomScript
environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series)));
environmentVariables.Add("Sonarr_EpisodeFile_Ids", string.Join(",", renamedFiles.Select(e => e.EpisodeFile.Id)));
environmentVariables.Add("Sonarr_EpisodeFile_RelativePaths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.RelativePath)));
environmentVariables.Add("Sonarr_EpisodeFile_Paths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.Path)));
environmentVariables.Add("Sonarr_EpisodeFile_Paths", string.Join("|", renamedFiles.Select(e => Path.Combine(series.Path, e.EpisodeFile.RelativePath))));
environmentVariables.Add("Sonarr_EpisodeFile_PreviousRelativePaths", string.Join("|", renamedFiles.Select(e => e.PreviousRelativePath)));
environmentVariables.Add("Sonarr_EpisodeFile_PreviousPaths", string.Join("|", renamedFiles.Select(e => e.PreviousPath)));

View File

@@ -13,7 +13,7 @@ namespace NzbDrone.Core.Notifications.Webhook
{
Id = episodeFile.Id;
RelativePath = episodeFile.RelativePath;
Path = episodeFile.Path;
Path = System.IO.Path.Combine(episodeFile.Series.Value.Path, episodeFile.RelativePath);
Quality = episodeFile.Quality.Quality.Name;
QualityVersion = episodeFile.Quality.Revision.Version;
ReleaseGroup = episodeFile.ReleaseGroup;

View File

@@ -1,4 +1,5 @@
using FluentValidation.Validators;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.RootFolders;
@@ -19,7 +20,7 @@ namespace NzbDrone.Core.Validation.Paths
{
context.MessageFormatter.AppendArgument("path", context.PropertyValue?.ToString());
return context.PropertyValue == null || _rootFolderService.All().Exists(r => r.Path.PathEquals(context.PropertyValue.ToString()));
return context.PropertyValue == null || _rootFolderService.All().Exists(r => r.Path.IsPathValid(PathValidationType.CurrentOs) && r.Path.PathEquals(context.PropertyValue.ToString()));
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace Sonarr.Api.V3.CustomFormats
{
public class CustomFormatBulkResource
{
public HashSet<int> Ids { get; set; } = new ();
public bool? IncludeCustomFormatWhenRenaming { get; set; }
}
}

View File

@@ -47,6 +47,13 @@ namespace Sonarr.Api.V3.CustomFormats
return _formatService.GetById(id).ToResource(true);
}
[HttpGet]
[Produces("application/json")]
public List<CustomFormatResource> GetAll()
{
return _formatService.All().ToResource(true);
}
[RestPostById]
[Consumes("application/json")]
public ActionResult<CustomFormatResource> Create([FromBody] CustomFormatResource customFormatResource)
@@ -71,11 +78,26 @@ namespace Sonarr.Api.V3.CustomFormats
return Accepted(model.Id);
}
[HttpGet]
[HttpPut("bulk")]
[Consumes("application/json")]
[Produces("application/json")]
public List<CustomFormatResource> GetAll()
public virtual ActionResult<CustomFormatResource> Update([FromBody] CustomFormatBulkResource resource)
{
return _formatService.All().ToResource(true);
if (!resource.Ids.Any())
{
throw new BadRequestException("ids must be provided");
}
var customFormats = resource.Ids.Select(id => _formatService.GetById(id)).ToList();
customFormats.ForEach(existing =>
{
existing.IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? existing.IncludeCustomFormatWhenRenaming;
});
_formatService.Update(customFormats);
return Accepted(customFormats.ConvertAll(cf => cf.ToResource(true)));
}
[RestDeleteById]
@@ -84,12 +106,21 @@ namespace Sonarr.Api.V3.CustomFormats
_formatService.Delete(id);
}
[HttpDelete("bulk")]
[Consumes("application/json")]
public virtual object DeleteFormats([FromBody] CustomFormatBulkResource resource)
{
_formatService.Delete(resource.Ids.ToList());
return new { };
}
[HttpGet("schema")]
public object GetTemplates()
{
var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
var presets = GetPresets();
var presets = GetPresets().ToList();
foreach (var item in schema)
{

View File

@@ -59,6 +59,7 @@ namespace Sonarr.Api.V3.Series
SeriesAncestorValidator seriesAncestorValidator,
SystemFolderValidator systemFolderValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
RootFolderExistsValidator rootFolderExistsValidator,
SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator)
: base(signalRBroadcaster)
{
@@ -88,6 +89,7 @@ namespace Sonarr.Api.V3.Series
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.RootFolderPath)
.IsValidPath()
.SetValidator(rootFolderExistsValidator)
.SetValidator(seriesFolderAsRootFolderValidator)
.When(s => s.Path.IsNullOrWhiteSpace());
PostValidator.RuleFor(s => s.Title).NotEmpty();
@@ -156,6 +158,7 @@ namespace Sonarr.Api.V3.Series
[RestPostById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
{
var series = _addSeriesService.AddSeries(seriesResource.ToModel());
@@ -165,6 +168,7 @@ namespace Sonarr.Api.V3.Series
[RestPutById]
[Consumes("application/json")]
[Produces("application/json")]
public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false)
{
var series = _seriesService.GetSeries(seriesResource.Id);
@@ -175,12 +179,12 @@ namespace Sonarr.Api.V3.Series
var destinationPath = seriesResource.Path;
_commandQueueManager.Push(new MoveSeriesCommand
{
SeriesId = series.Id,
SourcePath = sourcePath,
DestinationPath = destinationPath,
Trigger = CommandTrigger.Manual
});
{
SeriesId = series.Id,
SourcePath = sourcePath,
DestinationPath = destinationPath,
Trigger = CommandTrigger.Manual
});
}
var model = seriesResource.ToModel(series);