mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
15 Commits
v4.0.10.26
...
v4.0.10.26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca0bb14027 | ||
|
|
3e99917e9d | ||
|
|
936cf699ff | ||
|
|
202190d032 | ||
|
|
f739fd0900 | ||
|
|
88f4016fe0 | ||
|
|
78fb20282d | ||
|
|
6677fd1116 | ||
|
|
e28b7c3df6 | ||
|
|
67a1ecb0fe | ||
|
|
5bc943583c | ||
|
|
ceeec091f8 | ||
|
|
675e3cd38a | ||
|
|
45a62a2e59 | ||
|
|
ae7c07e02f |
@@ -1,18 +1,10 @@
|
||||
.inputContainer {
|
||||
margin-right: 20px;
|
||||
min-width: 150px;
|
||||
|
||||
div {
|
||||
margin-top: 10px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 3px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ function Legend(props) {
|
||||
if (showCutoffUnmetIcon) {
|
||||
iconsToShow.push(
|
||||
<LegendIconItem
|
||||
name={translate('Cutoff Not Met')}
|
||||
name={translate('CutoffNotMet')}
|
||||
icon={icons.EPISODE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
fullColorEvents={fullColorEvents}
|
||||
|
||||
@@ -10,6 +10,7 @@ import CaptchaInput from './CaptchaInput';
|
||||
import CheckInput from './CheckInput';
|
||||
import { FormInputButtonProps } from './FormInputButton';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import KeyValueListInput from './KeyValueListInput';
|
||||
import NumberInput from './NumberInput';
|
||||
import OAuthInput from './OAuthInput';
|
||||
import PasswordInput from './PasswordInput';
|
||||
@@ -18,6 +19,7 @@ import DownloadClientSelectInput from './Select/DownloadClientSelectInput';
|
||||
import EnhancedSelectInput from './Select/EnhancedSelectInput';
|
||||
import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput';
|
||||
import IndexerSelectInput from './Select/IndexerSelectInput';
|
||||
import LanguageSelectInput from './Select/LanguageSelectInput';
|
||||
import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput';
|
||||
import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput';
|
||||
import ProviderDataSelectInput from './Select/ProviderOptionSelectInput';
|
||||
@@ -47,6 +49,12 @@ function getComponent(type: InputType) {
|
||||
case inputTypes.DEVICE:
|
||||
return DeviceInput;
|
||||
|
||||
case inputTypes.KEY_VALUE_LIST:
|
||||
return KeyValueListInput;
|
||||
|
||||
case inputTypes.LANGUAGE_SELECT:
|
||||
return LanguageSelectInput;
|
||||
|
||||
case inputTypes.MONITOR_EPISODES_SELECT:
|
||||
return MonitorEpisodesSelectInput;
|
||||
|
||||
|
||||
21
frontend/src/Components/Form/KeyValueListInput.css
Normal file
21
frontend/src/Components/Form/KeyValueListInput.css
Normal file
@@ -0,0 +1,21 @@
|
||||
.inputContainer {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
|
||||
position: relative;
|
||||
min-height: 35px;
|
||||
height: auto;
|
||||
|
||||
&.isFocused {
|
||||
outline: 0;
|
||||
border-color: var(--inputFocusBorderColor);
|
||||
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
|
||||
}
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from '~Components/Form/Input.css';
|
||||
}
|
||||
10
frontend/src/Components/Form/KeyValueListInput.css.d.ts
vendored
Normal file
10
frontend/src/Components/Form/KeyValueListInput.css.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'hasError': string;
|
||||
'hasWarning': string;
|
||||
'inputContainer': string;
|
||||
'isFocused': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
104
frontend/src/Components/Form/KeyValueListInput.tsx
Normal file
104
frontend/src/Components/Form/KeyValueListInput.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { InputOnChange } from 'typings/inputs';
|
||||
import KeyValueListInputItem from './KeyValueListInputItem';
|
||||
import styles from './KeyValueListInput.css';
|
||||
|
||||
interface KeyValue {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeyValueListInputProps {
|
||||
className?: string;
|
||||
name: string;
|
||||
value: KeyValue[];
|
||||
hasError?: boolean;
|
||||
hasWarning?: boolean;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
onChange: InputOnChange<KeyValue[]>;
|
||||
}
|
||||
|
||||
function KeyValueListInput({
|
||||
className = styles.inputContainer,
|
||||
name,
|
||||
value = [],
|
||||
hasError = false,
|
||||
hasWarning = false,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
onChange,
|
||||
}: KeyValueListInputProps): JSX.Element {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleItemChange = useCallback(
|
||||
(index: number | null, itemValue: KeyValue) => {
|
||||
const newValue = [...value];
|
||||
|
||||
if (index === null) {
|
||||
newValue.push(itemValue);
|
||||
} else {
|
||||
newValue.splice(index, 1, itemValue);
|
||||
}
|
||||
|
||||
onChange({ name, value: newValue });
|
||||
},
|
||||
[value, name, onChange]
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
const newValue = [...value];
|
||||
newValue.splice(index, 1);
|
||||
onChange({ name, value: newValue });
|
||||
},
|
||||
[value, name, onChange]
|
||||
);
|
||||
|
||||
const onFocus = useCallback(() => setIsFocused(true), []);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
|
||||
const newValue = value.reduce((acc: KeyValue[], v) => {
|
||||
if (v.key || v.value) {
|
||||
acc.push(v);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (newValue.length !== value.length) {
|
||||
onChange({ name, value: newValue });
|
||||
}
|
||||
}, [value, name, onChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
isFocused && styles.isFocused,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
>
|
||||
{[...value, { key: '', value: '' }].map((v, index) => (
|
||||
<KeyValueListInputItem
|
||||
key={index}
|
||||
index={index}
|
||||
keyValue={v.key}
|
||||
value={v.value}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
isNew={index === value.length}
|
||||
onChange={handleItemChange}
|
||||
onRemove={handleRemoveItem}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyValueListInput;
|
||||
35
frontend/src/Components/Form/KeyValueListInputItem.css
Normal file
35
frontend/src/Components/Form/KeyValueListInputItem.css
Normal file
@@ -0,0 +1,35 @@
|
||||
.itemContainer {
|
||||
display: flex;
|
||||
margin-bottom: 3px;
|
||||
border-bottom: 1px solid var(--inputBorderColor);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.keyInputWrapper {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.valueInputWrapper {
|
||||
flex: 1 0 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
||||
.keyInput,
|
||||
.valueInput {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--textColor);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--helpTextColor);
|
||||
}
|
||||
}
|
||||
12
frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
vendored
Normal file
12
frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'buttonWrapper': string;
|
||||
'itemContainer': string;
|
||||
'keyInput': string;
|
||||
'keyInputWrapper': string;
|
||||
'valueInput': string;
|
||||
'valueInputWrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
89
frontend/src/Components/Form/KeyValueListInputItem.tsx
Normal file
89
frontend/src/Components/Form/KeyValueListInputItem.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './KeyValueListInputItem.css';
|
||||
|
||||
interface KeyValueListInputItemProps {
|
||||
index: number;
|
||||
keyValue: string;
|
||||
value: string;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
isNew: boolean;
|
||||
onChange: (index: number, itemValue: { key: string; value: string }) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
}
|
||||
|
||||
function KeyValueListInputItem({
|
||||
index,
|
||||
keyValue,
|
||||
value,
|
||||
keyPlaceholder = 'Key',
|
||||
valuePlaceholder = 'Value',
|
||||
isNew,
|
||||
onChange,
|
||||
onRemove,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: KeyValueListInputItemProps): JSX.Element {
|
||||
const handleKeyChange = useCallback(
|
||||
({ value: keyValue }: { value: string }) => {
|
||||
onChange(index, { key: keyValue, value });
|
||||
},
|
||||
[index, value, onChange]
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
({ value }: { value: string }) => {
|
||||
onChange(index, { key: keyValue, value });
|
||||
},
|
||||
[index, keyValue, onChange]
|
||||
);
|
||||
|
||||
const handleRemovePress = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [index, onRemove]);
|
||||
|
||||
return (
|
||||
<div className={styles.itemContainer}>
|
||||
<div className={styles.keyInputWrapper}>
|
||||
<TextInput
|
||||
className={styles.keyInput}
|
||||
name="key"
|
||||
value={keyValue}
|
||||
placeholder={keyPlaceholder}
|
||||
onChange={handleKeyChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.valueInputWrapper}>
|
||||
<TextInput
|
||||
className={styles.valueInput}
|
||||
name="value"
|
||||
value={value}
|
||||
placeholder={valuePlaceholder}
|
||||
onChange={handleValueChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonWrapper}>
|
||||
{isNew ? null : (
|
||||
<IconButton
|
||||
name={icons.REMOVE}
|
||||
tabIndex={-1}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyValueListInputItem;
|
||||
@@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
|
||||
return inputTypes.CHECK;
|
||||
case 'device':
|
||||
return inputTypes.DEVICE;
|
||||
case 'keyValueList':
|
||||
return inputTypes.KEY_VALUE_LIST;
|
||||
case 'password':
|
||||
return inputTypes.PASSWORD;
|
||||
case 'number':
|
||||
|
||||
@@ -192,7 +192,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
const { top, bottom } = data.offsets.reference;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (/^botton/.test(data.placement)) {
|
||||
if (/^bottom/.test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
@@ -233,18 +233,12 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
}, [handleWindowClick]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isOpen) {
|
||||
removeListener();
|
||||
} else {
|
||||
addListener();
|
||||
}
|
||||
|
||||
if (!isOpen && onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen, setIsOpen, addListener, removeListener, onOpen]);
|
||||
}, [isOpen, setIsOpen, onOpen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(newValue: ArrayElement<V>) => {
|
||||
@@ -411,6 +405,16 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
addListener();
|
||||
} else {
|
||||
removeListener();
|
||||
}
|
||||
|
||||
return removeListener;
|
||||
}, [isOpen, addListener, removeListener]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Manager>
|
||||
|
||||
@@ -1,43 +1,95 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Language from 'Language/Language';
|
||||
import createFilteredLanguagesSelector from 'Store/Selectors/createFilteredLanguagesSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
interface LanguageSelectInputProps {
|
||||
interface LanguageSelectInputOnChangeProps {
|
||||
name: string;
|
||||
value: number;
|
||||
values: EnhancedSelectInputValue<number>[];
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => void;
|
||||
value: number | string | Language;
|
||||
}
|
||||
|
||||
function LanguageSelectInput({
|
||||
values,
|
||||
interface LanguageSelectInputProps {
|
||||
name: string;
|
||||
value: number | string | Language;
|
||||
includeNoChange: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
includeMixed: boolean;
|
||||
onChange: (payload: LanguageSelectInputOnChangeProps) => void;
|
||||
}
|
||||
|
||||
export default function LanguageSelectInput({
|
||||
value,
|
||||
includeNoChange,
|
||||
includeNoChangeDisabled,
|
||||
includeMixed,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: LanguageSelectInputProps) {
|
||||
const mappedValues = useMemo(() => {
|
||||
const minId = values.reduce(
|
||||
(min: number, v) => (v.key < 1 ? v.key : min),
|
||||
values[0].key
|
||||
const { items } = useSelector(createFilteredLanguagesSelector(true));
|
||||
|
||||
const values = useMemo(() => {
|
||||
const result: EnhancedSelectInputValue<number | string>[] = items.map(
|
||||
(item) => {
|
||||
return {
|
||||
key: item.id,
|
||||
value: item.name,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return values.map(({ key, value }) => {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
dividerAfter: minId < 1 ? key === minId : false,
|
||||
};
|
||||
});
|
||||
}, [values]);
|
||||
if (includeNoChange) {
|
||||
result.unshift({
|
||||
key: 'noChange',
|
||||
value: translate('NoChange'),
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
result.unshift({
|
||||
key: 'mixed',
|
||||
value: `(${translate('Mixed')})`,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [includeNoChange, includeNoChangeDisabled, includeMixed, items]);
|
||||
|
||||
const selectValue =
|
||||
typeof value === 'number' || typeof value === 'string' ? value : value.id;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(payload: LanguageSelectInputOnChangeProps) => {
|
||||
if (typeof value === 'number') {
|
||||
onChange(payload);
|
||||
} else {
|
||||
const language = items.find((i) => i.id === payload.value);
|
||||
|
||||
onChange({
|
||||
...payload,
|
||||
value: language
|
||||
? {
|
||||
id: language.id,
|
||||
name: language.name,
|
||||
}
|
||||
: ({ id: payload.value } as Language),
|
||||
});
|
||||
}
|
||||
},
|
||||
[value, items, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
values={mappedValues}
|
||||
onChange={onChange}
|
||||
value={selectValue}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelectInput;
|
||||
|
||||
@@ -2,6 +2,7 @@ export const AUTO_COMPLETE = 'autoComplete';
|
||||
export const CAPTCHA = 'captcha';
|
||||
export const CHECK = 'check';
|
||||
export const DEVICE = 'device';
|
||||
export const KEY_VALUE_LIST = 'keyValueList';
|
||||
export const MONITOR_EPISODES_SELECT = 'monitorEpisodesSelect';
|
||||
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
|
||||
export const FLOAT = 'float';
|
||||
@@ -31,6 +32,7 @@ export const all = [
|
||||
CAPTCHA,
|
||||
CHECK,
|
||||
DEVICE,
|
||||
KEY_VALUE_LIST,
|
||||
MONITOR_EPISODES_SELECT,
|
||||
MONITOR_NEW_ITEMS_SELECT,
|
||||
FLOAT,
|
||||
|
||||
@@ -66,10 +66,10 @@ class UISettings extends Component {
|
||||
isFetching,
|
||||
error,
|
||||
settings,
|
||||
languages,
|
||||
hasSettings,
|
||||
onInputChange,
|
||||
onSavePress,
|
||||
languages,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
@@ -213,9 +213,8 @@ class UISettings extends Component {
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('UiLanguage')}</FormLabel>
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
type={inputTypes.LANGUAGE_SELECT}
|
||||
name="uiLanguage"
|
||||
values={languages}
|
||||
helpText={translate('UiLanguageHelpText')}
|
||||
helpTextWarning={translate('BrowserReloadRequired')}
|
||||
onChange={onInputChange}
|
||||
@@ -244,8 +243,8 @@ UISettings.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
settings: PropTypes.object.isRequired,
|
||||
hasSettings: PropTypes.bool.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasSettings: PropTypes.bool.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@@ -96,14 +96,22 @@ function merge(initialState, persistedState) {
|
||||
return computedState;
|
||||
}
|
||||
|
||||
const KEY = 'sonarr';
|
||||
|
||||
const config = {
|
||||
slicer,
|
||||
serialize,
|
||||
merge,
|
||||
key: 'sonarr'
|
||||
key: window.Sonarr.instanceName.toLowerCase().replace(/ /g, '_') || KEY
|
||||
};
|
||||
|
||||
export default function createPersistState() {
|
||||
// Migrate existing local storage value to new key if it does not already exist.
|
||||
// Leave old value as-is in case there are multiple instances using the same key.
|
||||
if (config.key !== KEY && localStorage.getItem(KEY) && !localStorage.getItem(config.key)) {
|
||||
localStorage.setItem(config.key, localStorage.getItem(KEY));
|
||||
}
|
||||
|
||||
// Migrate existing local storage before proceeding
|
||||
const persistedState = JSON.parse(localStorage.getItem(config.key));
|
||||
migrate(persistedState);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import { LanguageSettingsAppState } from 'App/State/SettingsAppState';
|
||||
import Language from 'Language/Language';
|
||||
import createLanguagesSelector from './createLanguagesSelector';
|
||||
|
||||
export default function createFilteredLanguagesSelector(filterUnknown = false) {
|
||||
const filterItems = ['Any', 'Original'];
|
||||
|
||||
if (filterUnknown) {
|
||||
filterItems.push('Unknown');
|
||||
}
|
||||
|
||||
return createSelector(createLanguagesSelector(), (languages) => {
|
||||
const { isFetching, isPopulated, error, items } =
|
||||
languages as LanguageSettingsAppState;
|
||||
|
||||
const filteredLanguages = items.filter(
|
||||
(lang: Language) => !filterItems.includes(lang.name)
|
||||
);
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items: filteredLanguages,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,12 @@ export default function translate(
|
||||
key: string,
|
||||
tokens: Record<string, string | number | boolean> = {}
|
||||
) {
|
||||
const { isProduction = true } = window.Sonarr;
|
||||
|
||||
if (!isProduction && !(key in translations)) {
|
||||
console.warn(`Missing translation for key: ${key}`);
|
||||
}
|
||||
|
||||
const translation = translations[key] || key;
|
||||
|
||||
tokens.appName = 'Sonarr';
|
||||
|
||||
1
frontend/typings/Globals.d.ts
vendored
1
frontend/typings/Globals.d.ts
vendored
@@ -7,5 +7,6 @@ interface Window {
|
||||
theme: string;
|
||||
urlBase: string;
|
||||
version: string;
|
||||
isProduction: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
@@ -434,5 +435,14 @@ namespace NzbDrone.Common.Test
|
||||
{
|
||||
parentPath.GetRelativePath(childPath).Should().Be(relativePath);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_be_equal_with_different_unicode_representations()
|
||||
{
|
||||
var path1 = @"C:\Test\file.mkv".AsOsAgnostic().Normalize(NormalizationForm.FormC);
|
||||
var path2 = @"C:\Test\file.mkv".AsOsAgnostic().Normalize(NormalizationForm.FormD);
|
||||
|
||||
path1.PathEquals(path2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,18 @@ namespace NzbDrone.Common.Disk
|
||||
}
|
||||
|
||||
var fi = new FileInfo(path);
|
||||
|
||||
// If the file is a symlink, resolve the target path and get the size of the target file.
|
||||
if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint))
|
||||
{
|
||||
var targetPath = fi.ResolveLinkTarget(true)?.FullName;
|
||||
|
||||
if (targetPath != null)
|
||||
{
|
||||
fi = new FileInfo(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
return fi.Length;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,10 +148,5 @@ namespace NzbDrone.Common.Extensions
|
||||
{
|
||||
return string.Join(separator, source.Select(predicate));
|
||||
}
|
||||
|
||||
public static HashSet<T> ToHashSet<T>(this IEnumerable<T> source, IEqualityComparer<T> comparer = null)
|
||||
{
|
||||
return new HashSet<T>(source, comparer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ namespace NzbDrone.Common.Extensions
|
||||
|
||||
public static bool PathEquals(this string firstPath, string secondPath, StringComparison? comparison = null)
|
||||
{
|
||||
// Normalize paths to ensure unicode characters are represented the same way
|
||||
firstPath = firstPath.Normalize();
|
||||
secondPath = secondPath?.Normalize();
|
||||
|
||||
if (!comparison.HasValue)
|
||||
{
|
||||
comparison = DiskProviderBase.PathStringComparison;
|
||||
|
||||
@@ -21,10 +21,10 @@ namespace NzbDrone.Common
|
||||
{
|
||||
if (OsInfo.IsWindows)
|
||||
{
|
||||
return obj.CleanFilePath().ToLower().GetHashCode();
|
||||
return obj.CleanFilePath().Normalize().ToLower().GetHashCode();
|
||||
}
|
||||
|
||||
return obj.CleanFilePath().GetHashCode();
|
||||
return obj.CleanFilePath().Normalize().GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ namespace NzbDrone.Common.Reflection
|
||||
|| type == typeof(string)
|
||||
|| type == typeof(DateTime)
|
||||
|| type == typeof(Version)
|
||||
|| type == typeof(decimal);
|
||||
|| type == typeof(decimal)
|
||||
|| (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>));
|
||||
}
|
||||
|
||||
public static bool IsReadable(this PropertyInfo propertyInfo)
|
||||
|
||||
@@ -118,7 +118,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.Blackhole
|
||||
|
||||
VerifyCompleted(result);
|
||||
|
||||
result.CanBeRemoved.Should().BeFalse();
|
||||
result.CanBeRemoved.Should().BeTrue();
|
||||
result.CanMoveFiles.Should().BeFalse();
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,14 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
[TestFixture]
|
||||
public class TransmissionFixture : TransmissionFixtureBase<Transmission>
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup_Transmission()
|
||||
{
|
||||
Mocker.GetMock<ITransmissionProxy>()
|
||||
.Setup(v => v.GetClientVersion(It.IsAny<TransmissionSettings>(), It.IsAny<bool>()))
|
||||
.Returns("4.0.6");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void queued_item_should_have_required_properties()
|
||||
{
|
||||
@@ -272,7 +280,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
public void should_only_check_version_number(string version)
|
||||
{
|
||||
Mocker.GetMock<ITransmissionProxy>()
|
||||
.Setup(s => s.GetClientVersion(It.IsAny<TransmissionSettings>()))
|
||||
.Setup(s => s.GetClientVersion(It.IsAny<TransmissionSettings>(), true))
|
||||
.Returns(version);
|
||||
|
||||
Subject.Test().IsValid.Should().BeTrue();
|
||||
|
||||
@@ -29,7 +29,8 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
Host = "127.0.0.1",
|
||||
Port = 2222,
|
||||
Username = "admin",
|
||||
Password = "pass"
|
||||
Password = "pass",
|
||||
TvCategory = ""
|
||||
};
|
||||
|
||||
Subject.Definition = new DownloadClientDefinition();
|
||||
@@ -152,7 +153,7 @@ namespace NzbDrone.Core.Test.Download.DownloadClientTests.TransmissionTests
|
||||
}
|
||||
|
||||
Mocker.GetMock<ITransmissionProxy>()
|
||||
.Setup(s => s.GetTorrents(It.IsAny<TransmissionSettings>()))
|
||||
.Setup(s => s.GetTorrents(null, It.IsAny<TransmissionSettings>()))
|
||||
.Returns(torrents);
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,16 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
result.Languages.Should().Contain(Language.English);
|
||||
}
|
||||
|
||||
[TestCase("Series.Title.S01E01.Original.1080P.WEB.H264-RlsGrp")]
|
||||
[TestCase("Series.Title.S01E01.Orig.1080P.WEB.H264-RlsGrp")]
|
||||
[TestCase("Series / S1E1-10 of 10 [2023, HEVC, HDR10, Dolby Vision, WEB-DL 2160p] [Hybrid] 3 XX + Original")]
|
||||
public void should_parse_original_title_from_release_name(string postTitle)
|
||||
{
|
||||
var result = Parser.Parser.ParseTitle(postTitle);
|
||||
result.Languages.Count.Should().Be(1);
|
||||
result.Languages.Should().Contain(Language.Original);
|
||||
}
|
||||
|
||||
[TestCase("Остання серія (Сезон 1) / The Last Series (Season 1) (2024) WEB-DLRip-AVC 2xUkr/Eng | Sub Ukr/Eng")]
|
||||
[TestCase("Справжня серія (Сезон 1-3) / True Series (Season 1-3) (2014-2019) BDRip-AVC 3xUkr/Eng | Ukr/Eng")]
|
||||
[TestCase("Серія (Сезон 1-3) / The Series (Seasons 1-3) (2019-2022) BDRip-AVC 4xUkr/Eng | Sub 2xUkr/Eng")]
|
||||
|
||||
@@ -89,7 +89,9 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Series Title S01 1080p Blu-ray Remux AVC DTS-HD MA 2.0 - BluDragon", "BluDragon")]
|
||||
[TestCase("Example (2013) S01E01 (1080p iP WEBRip x265 SDR AAC 2.0 English - DarQ)", "DarQ")]
|
||||
[TestCase("Series.Title.S08E03.720p.WEB.DL.AAC2.0.H.264.KCRT", "KCRT")]
|
||||
[TestCase("S02E05 2160p WEB-DL DV HDR ENG DDP5.1 Atmos H265 MP4-BEN THE MAN", "BEN THE MAN")]
|
||||
[TestCase("Series Title S02E05 2160p WEB-DL DV HDR ENG DDP5.1 Atmos H265 MP4-BEN THE MEN", "BEN THE MEN")]
|
||||
[TestCase("Series Title S02E05 2160p AMZN WEB-DL DV HDR10 PLUS DDP5 1 Atmos H265 MKV-BEN THE MEN-xpost", "BEN THE MEN")]
|
||||
[TestCase("Series.S01E05.1080p.WEB-DL.DDP5.1.H264-BEN.THE.MEN", "BEN.THE.MEN")]
|
||||
public void should_parse_exception_release_group(string title, string expected)
|
||||
{
|
||||
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected);
|
||||
|
||||
@@ -101,7 +101,8 @@ namespace NzbDrone.Core.Annotations
|
||||
TagSelect,
|
||||
RootFolder,
|
||||
QualityProfile,
|
||||
SeriesTag
|
||||
SeriesTag,
|
||||
KeyValueList,
|
||||
}
|
||||
|
||||
public enum HiddenType
|
||||
|
||||
@@ -104,9 +104,8 @@ namespace NzbDrone.Core.Download.Clients.Blackhole
|
||||
Status = item.Status
|
||||
};
|
||||
|
||||
queueItem.CanMoveFiles = queueItem.CanBeRemoved =
|
||||
queueItem.DownloadClientInfo.RemoveCompletedDownloads &&
|
||||
!Settings.ReadOnly;
|
||||
queueItem.CanMoveFiles = !Settings.ReadOnly;
|
||||
queueItem.CanBeRemoved = queueItem.DownloadClientInfo.RemoveCompletedDownloads;
|
||||
|
||||
yield return queueItem;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace NzbDrone.Core.Download.Clients.Blackhole
|
||||
{
|
||||
foreach (var item in _scanWatchFolder.GetItems(Settings.WatchFolder, ScanGracePeriod))
|
||||
{
|
||||
yield return new DownloadClientItem
|
||||
var queueItem = new DownloadClientItem
|
||||
{
|
||||
DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false),
|
||||
DownloadId = Definition.Name + "_" + item.DownloadId,
|
||||
@@ -72,10 +72,12 @@ namespace NzbDrone.Core.Download.Clients.Blackhole
|
||||
OutputPath = item.OutputPath,
|
||||
|
||||
Status = item.Status,
|
||||
|
||||
CanBeRemoved = true,
|
||||
CanMoveFiles = true
|
||||
};
|
||||
|
||||
queueItem.CanMoveFiles = true;
|
||||
queueItem.CanBeRemoved = queueItem.DownloadClientInfo.RemoveCompletedDownloads;
|
||||
|
||||
yield return queueItem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.Blocklisting;
|
||||
using NzbDrone.Core.Configuration;
|
||||
@@ -15,6 +17,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
public class Transmission : TransmissionBase
|
||||
{
|
||||
public override string Name => "Transmission";
|
||||
public override bool SupportsLabels => HasClientVersion(4, 0);
|
||||
|
||||
public Transmission(ITransmissionProxy proxy,
|
||||
ITorrentFileInfoReader torrentFileInfoReader,
|
||||
IHttpClient httpClient,
|
||||
@@ -28,9 +33,48 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
}
|
||||
|
||||
public override void MarkItemAsImported(DownloadClientItem downloadClientItem)
|
||||
{
|
||||
if (!SupportsLabels)
|
||||
{
|
||||
throw new NotSupportedException($"{Name} does not support marking items as imported");
|
||||
}
|
||||
|
||||
// set post-import category
|
||||
if (Settings.TvImportedCategory.IsNotNullOrWhiteSpace() &&
|
||||
Settings.TvImportedCategory != Settings.TvCategory)
|
||||
{
|
||||
var hash = downloadClientItem.DownloadId.ToLowerInvariant();
|
||||
var torrent = _proxy.GetTorrents(new[] { hash }, Settings).FirstOrDefault();
|
||||
|
||||
if (torrent == null)
|
||||
{
|
||||
_logger.Warn("Could not find torrent with hash \"{0}\" in Transmission.", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var labels = torrent.Labels.ToHashSet(StringComparer.InvariantCultureIgnoreCase);
|
||||
labels.Add(Settings.TvImportedCategory);
|
||||
|
||||
if (Settings.TvCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
labels.Remove(Settings.TvCategory);
|
||||
}
|
||||
|
||||
_proxy.SetTorrentLabels(hash, labels, Settings);
|
||||
}
|
||||
catch (DownloadClientException ex)
|
||||
{
|
||||
_logger.Warn(ex, "Failed to set post-import torrent label \"{0}\" for {1} in Transmission.", Settings.TvImportedCategory, downloadClientItem.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override ValidationFailure ValidateVersion()
|
||||
{
|
||||
var versionString = _proxy.GetClientVersion(Settings);
|
||||
var versionString = _proxy.GetClientVersion(Settings, true);
|
||||
|
||||
_logger.Debug("Transmission version information: {0}", versionString);
|
||||
|
||||
@@ -44,7 +88,5 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override string Name => "Transmission";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using FluentValidation.Results;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
@@ -18,6 +19,8 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
public abstract class TransmissionBase : TorrentClientBase<TransmissionSettings>
|
||||
{
|
||||
public abstract bool SupportsLabels { get; }
|
||||
|
||||
protected readonly ITransmissionProxy _proxy;
|
||||
|
||||
public TransmissionBase(ITransmissionProxy proxy,
|
||||
@@ -37,7 +40,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
public override IEnumerable<DownloadClientItem> GetItems()
|
||||
{
|
||||
var configFunc = new Lazy<TransmissionConfig>(() => _proxy.GetConfig(Settings));
|
||||
var torrents = _proxy.GetTorrents(Settings);
|
||||
var torrents = _proxy.GetTorrents(null, Settings);
|
||||
|
||||
var items = new List<DownloadClientItem>();
|
||||
|
||||
@@ -45,36 +48,45 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
var outputPath = new OsPath(torrent.DownloadDir);
|
||||
|
||||
if (Settings.TvDirectory.IsNotNullOrWhiteSpace())
|
||||
if (Settings.TvCategory.IsNotNullOrWhiteSpace() && SupportsLabels && torrent.Labels is { Count: > 0 })
|
||||
{
|
||||
if (!new OsPath(Settings.TvDirectory).Contains(outputPath))
|
||||
if (!torrent.Labels.Contains(Settings.TvCategory, StringComparer.InvariantCultureIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (Settings.TvCategory.IsNotNullOrWhiteSpace())
|
||||
else
|
||||
{
|
||||
var directories = outputPath.FullPath.Split('\\', '/');
|
||||
if (!directories.Contains(Settings.TvCategory))
|
||||
if (Settings.TvDirectory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
continue;
|
||||
if (!new OsPath(Settings.TvDirectory).Contains(outputPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else if (Settings.TvCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
var directories = outputPath.FullPath.Split('\\', '/');
|
||||
if (!directories.Contains(Settings.TvCategory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputPath = _remotePathMappingService.RemapRemoteToLocal(Settings.Host, outputPath);
|
||||
|
||||
var item = new DownloadClientItem();
|
||||
item.DownloadId = torrent.HashString.ToUpper();
|
||||
item.Category = Settings.TvCategory;
|
||||
item.Title = torrent.Name;
|
||||
|
||||
item.DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, false);
|
||||
|
||||
item.OutputPath = GetOutputPath(outputPath, torrent);
|
||||
item.TotalSize = torrent.TotalSize;
|
||||
item.RemainingSize = torrent.LeftUntilDone;
|
||||
item.SeedRatio = torrent.DownloadedEver <= 0 ? 0 :
|
||||
(double)torrent.UploadedEver / torrent.DownloadedEver;
|
||||
var item = new DownloadClientItem
|
||||
{
|
||||
DownloadId = torrent.HashString.ToUpper(),
|
||||
Category = Settings.TvCategory,
|
||||
Title = torrent.Name,
|
||||
DownloadClientInfo = DownloadClientItemClientInfo.FromDownloadClient(this, Settings.TvImportedCategory.IsNotNullOrWhiteSpace() && SupportsLabels),
|
||||
OutputPath = GetOutputPath(outputPath, torrent),
|
||||
TotalSize = torrent.TotalSize,
|
||||
RemainingSize = torrent.LeftUntilDone,
|
||||
SeedRatio = torrent.DownloadedEver <= 0 ? 0 : (double)torrent.UploadedEver / torrent.DownloadedEver
|
||||
};
|
||||
|
||||
if (torrent.Eta >= 0)
|
||||
{
|
||||
@@ -300,7 +312,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
try
|
||||
{
|
||||
_proxy.GetTorrents(Settings);
|
||||
_proxy.GetTorrents(null, Settings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -310,5 +322,15 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected bool HasClientVersion(int major, int minor)
|
||||
{
|
||||
var rawVersion = _proxy.GetClientVersion(Settings);
|
||||
|
||||
var versionResult = Regex.Match(rawVersion, @"(?<!\(|(\d|\.)+)(\d|\.)+(?!\)|(\d|\.)+)").Value;
|
||||
var clientVersion = Version.Parse(versionResult);
|
||||
|
||||
return clientVersion >= new Version(major, minor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NLog;
|
||||
@@ -12,15 +15,16 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
public interface ITransmissionProxy
|
||||
{
|
||||
List<TransmissionTorrent> GetTorrents(TransmissionSettings settings);
|
||||
IReadOnlyCollection<TransmissionTorrent> GetTorrents(IReadOnlyCollection<string> hashStrings, TransmissionSettings settings);
|
||||
void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings);
|
||||
void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings);
|
||||
void SetTorrentSeedingConfiguration(string hash, TorrentSeedConfiguration seedConfiguration, TransmissionSettings settings);
|
||||
TransmissionConfig GetConfig(TransmissionSettings settings);
|
||||
string GetProtocolVersion(TransmissionSettings settings);
|
||||
string GetClientVersion(TransmissionSettings settings);
|
||||
string GetClientVersion(TransmissionSettings settings, bool force = false);
|
||||
void RemoveTorrent(string hash, bool removeData, TransmissionSettings settings);
|
||||
void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings);
|
||||
void SetTorrentLabels(string hash, IEnumerable<string> labels, TransmissionSettings settings);
|
||||
}
|
||||
|
||||
public class TransmissionProxy : ITransmissionProxy
|
||||
@@ -28,50 +32,66 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
private readonly IHttpClient _httpClient;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private ICached<string> _authSessionIDCache;
|
||||
private readonly ICached<string> _authSessionIdCache;
|
||||
private readonly ICached<string> _versionCache;
|
||||
|
||||
public TransmissionProxy(ICacheManager cacheManager, IHttpClient httpClient, Logger logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_logger = logger;
|
||||
|
||||
_authSessionIDCache = cacheManager.GetCache<string>(GetType(), "authSessionID");
|
||||
_authSessionIdCache = cacheManager.GetCache<string>(GetType(), "authSessionID");
|
||||
_versionCache = cacheManager.GetCache<string>(GetType(), "versions");
|
||||
}
|
||||
|
||||
public List<TransmissionTorrent> GetTorrents(TransmissionSettings settings)
|
||||
public IReadOnlyCollection<TransmissionTorrent> GetTorrents(IReadOnlyCollection<string> hashStrings, TransmissionSettings settings)
|
||||
{
|
||||
var result = GetTorrentStatus(settings);
|
||||
var result = GetTorrentStatus(hashStrings, settings);
|
||||
|
||||
var torrents = ((JArray)result.Arguments["torrents"]).ToObject<List<TransmissionTorrent>>();
|
||||
var torrents = ((JArray)result.Arguments["torrents"]).ToObject<ReadOnlyCollection<TransmissionTorrent>>();
|
||||
|
||||
return torrents;
|
||||
}
|
||||
|
||||
public void AddTorrentFromUrl(string torrentUrl, string downloadDirectory, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("filename", torrentUrl);
|
||||
arguments.Add("paused", settings.AddPaused);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "filename", torrentUrl },
|
||||
{ "paused", settings.AddPaused }
|
||||
};
|
||||
|
||||
if (!downloadDirectory.IsNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("download-dir", downloadDirectory);
|
||||
}
|
||||
|
||||
if (settings.TvCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("labels", new List<string> { settings.TvCategory });
|
||||
}
|
||||
|
||||
ProcessRequest("torrent-add", arguments, settings);
|
||||
}
|
||||
|
||||
public void AddTorrentFromData(byte[] torrentData, string downloadDirectory, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("metainfo", Convert.ToBase64String(torrentData));
|
||||
arguments.Add("paused", settings.AddPaused);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "metainfo", Convert.ToBase64String(torrentData) },
|
||||
{ "paused", settings.AddPaused }
|
||||
};
|
||||
|
||||
if (!downloadDirectory.IsNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("download-dir", downloadDirectory);
|
||||
}
|
||||
|
||||
if (settings.TvCategory.IsNotNullOrWhiteSpace())
|
||||
{
|
||||
arguments.Add("labels", new List<string> { settings.TvCategory });
|
||||
}
|
||||
|
||||
ProcessRequest("torrent-add", arguments, settings);
|
||||
}
|
||||
|
||||
@@ -82,8 +102,10 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return;
|
||||
}
|
||||
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("ids", new[] { hash });
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hash } }
|
||||
};
|
||||
|
||||
if (seedConfiguration.Ratio != null)
|
||||
{
|
||||
@@ -97,6 +119,12 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
arguments.Add("seedIdleMode", 1);
|
||||
}
|
||||
|
||||
// Avoid extraneous request if no limits are to be set
|
||||
if (arguments.All(arg => arg.Key == "ids"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ProcessRequest("torrent-set", arguments, settings);
|
||||
}
|
||||
|
||||
@@ -107,11 +135,16 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return config.RpcVersion;
|
||||
}
|
||||
|
||||
public string GetClientVersion(TransmissionSettings settings)
|
||||
public string GetClientVersion(TransmissionSettings settings, bool force = false)
|
||||
{
|
||||
var config = GetConfig(settings);
|
||||
var cacheKey = $"version:{$"{GetBaseUrl(settings)}:{settings.Password}".SHA256Hash()}";
|
||||
|
||||
return config.Version;
|
||||
if (force)
|
||||
{
|
||||
_versionCache.Remove(cacheKey);
|
||||
}
|
||||
|
||||
return _versionCache.Get(cacheKey, () => GetConfig(settings).Version, TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
public TransmissionConfig GetConfig(TransmissionSettings settings)
|
||||
@@ -124,21 +157,36 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
public void RemoveTorrent(string hashString, bool removeData, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("ids", new string[] { hashString });
|
||||
arguments.Add("delete-local-data", removeData);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hashString } },
|
||||
{ "delete-local-data", removeData }
|
||||
};
|
||||
|
||||
ProcessRequest("torrent-remove", arguments, settings);
|
||||
}
|
||||
|
||||
public void MoveTorrentToTopInQueue(string hashString, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("ids", new string[] { hashString });
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hashString } }
|
||||
};
|
||||
|
||||
ProcessRequest("queue-move-top", arguments, settings);
|
||||
}
|
||||
|
||||
public void SetTorrentLabels(string hash, IEnumerable<string> labels, TransmissionSettings settings)
|
||||
{
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "ids", new List<string> { hash } },
|
||||
{ "labels", labels.ToImmutableHashSet() }
|
||||
};
|
||||
|
||||
ProcessRequest("torrent-set", arguments, settings);
|
||||
}
|
||||
|
||||
private TransmissionResponse GetSessionVariables(TransmissionSettings settings)
|
||||
{
|
||||
// Retrieve transmission information such as the default download directory, bandwidth throttling and seed ratio.
|
||||
@@ -151,14 +199,9 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return ProcessRequest("session-stats", null, settings);
|
||||
}
|
||||
|
||||
private TransmissionResponse GetTorrentStatus(TransmissionSettings settings)
|
||||
{
|
||||
return GetTorrentStatus(null, settings);
|
||||
}
|
||||
|
||||
private TransmissionResponse GetTorrentStatus(IEnumerable<string> hashStrings, TransmissionSettings settings)
|
||||
{
|
||||
var fields = new string[]
|
||||
var fields = new List<string>
|
||||
{
|
||||
"id",
|
||||
"hashString", // Unique torrent ID. Use this instead of the client id?
|
||||
@@ -179,11 +222,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
"seedIdleLimit",
|
||||
"seedIdleMode",
|
||||
"fileCount",
|
||||
"file-count"
|
||||
"file-count",
|
||||
"labels"
|
||||
};
|
||||
|
||||
var arguments = new Dictionary<string, object>();
|
||||
arguments.Add("fields", fields);
|
||||
var arguments = new Dictionary<string, object>
|
||||
{
|
||||
{ "fields", fields }
|
||||
};
|
||||
|
||||
if (hashStrings != null)
|
||||
{
|
||||
@@ -195,9 +241,14 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
return result;
|
||||
}
|
||||
|
||||
private string GetBaseUrl(TransmissionSettings settings)
|
||||
{
|
||||
return HttpRequestBuilder.BuildBaseUrl(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase);
|
||||
}
|
||||
|
||||
private HttpRequestBuilder BuildRequest(TransmissionSettings settings)
|
||||
{
|
||||
var requestBuilder = new HttpRequestBuilder(settings.UseSsl, settings.Host, settings.Port, settings.UrlBase)
|
||||
var requestBuilder = new HttpRequestBuilder(GetBaseUrl(settings))
|
||||
.Resource("rpc")
|
||||
.Accept(HttpAccept.Json);
|
||||
|
||||
@@ -212,11 +263,11 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
{
|
||||
var authKey = string.Format("{0}:{1}", requestBuilder.BaseUrl, settings.Password);
|
||||
|
||||
var sessionId = _authSessionIDCache.Find(authKey);
|
||||
var sessionId = _authSessionIdCache.Find(authKey);
|
||||
|
||||
if (sessionId == null || reauthenticate)
|
||||
{
|
||||
_authSessionIDCache.Remove(authKey);
|
||||
_authSessionIdCache.Remove(authKey);
|
||||
|
||||
var authLoginRequest = BuildRequest(settings).Build();
|
||||
authLoginRequest.SuppressHttpError = true;
|
||||
@@ -244,7 +295,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
|
||||
_logger.Debug("Transmission authentication succeeded.");
|
||||
|
||||
_authSessionIDCache.Set(authKey, sessionId);
|
||||
_authSessionIdCache.Set(authKey, sessionId);
|
||||
}
|
||||
|
||||
requestBuilder.SetHeader("X-Transmission-Session-Id", sessionId);
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
Host = "localhost";
|
||||
Port = 9091;
|
||||
UrlBase = "/transmission/";
|
||||
TvCategory = "tv-sonarr";
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "Host", Type = FieldType.Textbox)]
|
||||
@@ -59,16 +60,19 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
[FieldDefinition(6, Label = "Category", Type = FieldType.Textbox, HelpText = "DownloadClientSettingsCategorySubFolderHelpText")]
|
||||
public string TvCategory { get; set; }
|
||||
|
||||
[FieldDefinition(7, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")]
|
||||
[FieldDefinition(7, Label = "PostImportCategory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientSettingsPostImportCategoryHelpText")]
|
||||
public string TvImportedCategory { get; set; }
|
||||
|
||||
[FieldDefinition(8, Label = "Directory", Type = FieldType.Textbox, Advanced = true, HelpText = "DownloadClientTransmissionSettingsDirectoryHelpText")]
|
||||
public string TvDirectory { get; set; }
|
||||
|
||||
[FieldDefinition(8, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsRecentPriorityEpisodeHelpText")]
|
||||
[FieldDefinition(9, Label = "DownloadClientSettingsRecentPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsRecentPriorityEpisodeHelpText")]
|
||||
public int RecentTvPriority { get; set; }
|
||||
|
||||
[FieldDefinition(9, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsOlderPriorityEpisodeHelpText")]
|
||||
[FieldDefinition(10, Label = "DownloadClientSettingsOlderPriority", Type = FieldType.Select, SelectOptions = typeof(TransmissionPriority), HelpText = "DownloadClientSettingsOlderPriorityEpisodeHelpText")]
|
||||
public int OlderTvPriority { get; set; }
|
||||
|
||||
[FieldDefinition(10, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)]
|
||||
[FieldDefinition(11, Label = "DownloadClientSettingsAddPaused", Type = FieldType.Checkbox)]
|
||||
public bool AddPaused { get; set; }
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
@@ -11,6 +13,7 @@ namespace NzbDrone.Core.Download.Clients.Transmission
|
||||
public long TotalSize { get; set; }
|
||||
public long LeftUntilDone { get; set; }
|
||||
public bool IsFinished { get; set; }
|
||||
public IReadOnlyCollection<string> Labels { get; set; } = Array.Empty<string>();
|
||||
public long Eta { get; set; }
|
||||
public TransmissionTorrentStatus Status { get; set; }
|
||||
public long SecondsDownloading { get; set; }
|
||||
|
||||
@@ -15,6 +15,9 @@ namespace NzbDrone.Core.Download.Clients.Vuze
|
||||
{
|
||||
private const int MINIMUM_SUPPORTED_PROTOCOL_VERSION = 14;
|
||||
|
||||
public override string Name => "Vuze";
|
||||
public override bool SupportsLabels => false;
|
||||
|
||||
public Vuze(ITransmissionProxy proxy,
|
||||
ITorrentFileInfoReader torrentFileInfoReader,
|
||||
IHttpClient httpClient,
|
||||
@@ -67,7 +70,5 @@ namespace NzbDrone.Core.Download.Clients.Vuze
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override string Name => "Vuze";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ namespace NzbDrone.Core.Extras.Metadata.Consumers.Plex
|
||||
episodeFormat = $"SP{episodesInFile.First():00}";
|
||||
}
|
||||
|
||||
content.Append($"Episode: {episodeFormat}: {episodeFile.RelativePath}");
|
||||
content.AppendLine($"Episode: {episodeFormat}: {episodeFile.RelativePath}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@
|
||||
"CollectionsLoadError": "Unable to load collections",
|
||||
"ColonReplacement": "Colon Replacement",
|
||||
"ColonReplacementFormatHelpText": "Change how {appName} handles colon replacement",
|
||||
"Completed": "Completed",
|
||||
"CompletedDownloadHandling": "Completed Download Handling",
|
||||
"Component": "Component",
|
||||
"Condition": "Condition",
|
||||
@@ -302,6 +303,7 @@
|
||||
"CustomFormatsSpecificationResolution": "Resolution",
|
||||
"CustomFormatsSpecificationSource": "Source",
|
||||
"Cutoff": "Cutoff",
|
||||
"CutoffNotMet": "Cutoff Not Met",
|
||||
"CutoffUnmet": "Cutoff Unmet",
|
||||
"CutoffUnmetLoadError": "Error loading cutoff unmet items",
|
||||
"CutoffUnmetNoItems": "No cutoff unmet items",
|
||||
@@ -542,8 +544,8 @@
|
||||
"DownloadClientStatusSingleClientHealthCheckMessage": "Download clients unavailable due to failures: {downloadClientNames}",
|
||||
"DownloadClientTransmissionSettingsDirectoryHelpText": "Optional location to put downloads in, leave blank to use the default Transmission location",
|
||||
"DownloadClientTransmissionSettingsUrlBaseHelpText": "Adds a prefix to the {clientName} rpc url, eg {url}, defaults to '{defaultUrl}'",
|
||||
"DownloadClientUnavailable": "Download Client Unavailable",
|
||||
"DownloadClientUTorrentTorrentStateError": "uTorrent is reporting an error",
|
||||
"DownloadClientUnavailable": "Download Client Unavailable",
|
||||
"DownloadClientValidationApiKeyIncorrect": "API Key Incorrect",
|
||||
"DownloadClientValidationApiKeyRequired": "API Key Required",
|
||||
"DownloadClientValidationAuthenticationFailure": "Authentication Failure",
|
||||
@@ -1155,6 +1157,7 @@
|
||||
"MediaManagementSettingsSummary": "Naming, file management settings and root folders",
|
||||
"Medium": "Medium",
|
||||
"MegabytesPerMinute": "Megabytes Per Minute",
|
||||
"Menu": "Menu",
|
||||
"Message": "Message",
|
||||
"Metadata": "Metadata",
|
||||
"MetadataLoadError": "Unable to load Metadata",
|
||||
@@ -1433,6 +1436,7 @@
|
||||
"NotificationsSettingsWebhookMethod": "Method",
|
||||
"NotificationsSettingsWebhookMethodHelpText": "Which HTTP method to use submit to the Webservice",
|
||||
"NotificationsSettingsWebhookUrl": "Webhook URL",
|
||||
"NotificationsSettingsWebhookHeaders": "Headers",
|
||||
"NotificationsSignalSettingsGroupIdPhoneNumber": "Group ID / Phone Number",
|
||||
"NotificationsSignalSettingsGroupIdPhoneNumberHelpText": "Group ID / Phone Number of the receiver",
|
||||
"NotificationsSignalSettingsPasswordHelpText": "Password used to authenticate requests toward signal-api",
|
||||
@@ -1575,6 +1579,7 @@
|
||||
"PreferredProtocol": "Preferred Protocol",
|
||||
"PreferredSize": "Preferred Size",
|
||||
"PrefixedRange": "Prefixed Range",
|
||||
"Premiere": "Premiere",
|
||||
"Presets": "Presets",
|
||||
"PreviewRename": "Preview Rename",
|
||||
"PreviewRenameSeason": "Preview Rename for this season",
|
||||
|
||||
@@ -2122,5 +2122,13 @@
|
||||
"FolderNameTokens": "Tokens de nombre de carpeta",
|
||||
"FailedToFetchSettings": "Error al recuperar la configuración",
|
||||
"MetadataPlexSettingsEpisodeMappings": "Asignaciones de episodios",
|
||||
"MetadataPlexSettingsEpisodeMappingsHelpText": "Incluye las asignaciones de episodios para todos los archivos en el archivo .plexmatch"
|
||||
"MetadataPlexSettingsEpisodeMappingsHelpText": "Incluye las asignaciones de episodios para todos los archivos en el archivo .plexmatch",
|
||||
"RecentFolders": "Carpetas recientes",
|
||||
"Warning": "Aviso",
|
||||
"Delay": "Retardo",
|
||||
"DownloadClientUnavailable": "Cliente de descarga no disponible",
|
||||
"FavoriteFolders": "Carpetas favoritas",
|
||||
"ManageFormats": "Gestionar formatos",
|
||||
"FavoriteFolderAdd": "Añadir carpeta favorita",
|
||||
"FavoriteFolderRemove": "Eliminar carpeta favorita"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Backup": "Copie de rezervă",
|
||||
"CloneCondition": "Clonați condiție",
|
||||
"CloneCondition": "Clonează condiție",
|
||||
"ApplyChanges": "Aplicați modificări",
|
||||
"AutomaticAdd": "Adăugare automată",
|
||||
"AllTitles": "Toate titlurile",
|
||||
@@ -21,7 +21,7 @@
|
||||
"ApplyTagsHelpTextReplace": "Înlocuire: înlocuiți etichetele cu etichetele introduse (nu introduceți etichete pentru a șterge toate etichetele)",
|
||||
"CancelPendingTask": "Sigur doriți să anulați această sarcină în așteptare?",
|
||||
"DownloadClientCheckUnableToCommunicateWithHealthCheckMessage": "Nu pot comunica cu {downloadClientName}. {errorMessage}",
|
||||
"CloneCustomFormat": "Clonați format personalizat",
|
||||
"CloneCustomFormat": "Clonează format personalizat",
|
||||
"Close": "Închide",
|
||||
"Delete": "Șterge",
|
||||
"Added": "Adăugat",
|
||||
@@ -205,5 +205,9 @@
|
||||
"OnFileImport": "La import fișier",
|
||||
"FileNameTokens": "Jetoane pentru nume de fișier",
|
||||
"FolderNameTokens": "Jetoane pentru nume de folder",
|
||||
"OnFileUpgrade": "La actualizare fișier"
|
||||
"OnFileUpgrade": "La actualizare fișier",
|
||||
"CloneIndexer": "Clonează Indexer",
|
||||
"CloneProfile": "Clonează Profil",
|
||||
"DownloadClientUnavailable": "Client de descărcare indisponibil",
|
||||
"Clone": "Clonează"
|
||||
}
|
||||
|
||||
@@ -878,5 +878,26 @@
|
||||
"Install": "Kur",
|
||||
"InstallMajorVersionUpdate": "Güncellemeyi Kur",
|
||||
"InstallMajorVersionUpdateMessage": "Bu güncelleştirme yeni bir ana sürüm yükleyecek ve sisteminizle uyumlu olmayabilir. Bu güncelleştirmeyi yüklemek istediğinizden emin misiniz?",
|
||||
"InstallMajorVersionUpdateMessageLink": "Daha fazla bilgi için lütfen [{domain}]({url}) adresini kontrol edin."
|
||||
"InstallMajorVersionUpdateMessageLink": "Daha fazla bilgi için lütfen [{domain}]({url}) adresini kontrol edin.",
|
||||
"AnimeEpisodeTypeDescription": "Kesin bölüm numarası kullanılarak yayınlanan bölümler",
|
||||
"AnimeEpisodeTypeFormat": "Kesin bölüm numarası ({format})",
|
||||
"Warning": "Uyarı",
|
||||
"AirsTimeOn": "{time} {networkLabel} üzerinde",
|
||||
"AirsTomorrowOn": "Yarın {time}'da {networkLabel}'da",
|
||||
"AppUpdatedVersion": "{appName} `{version}` sürümüne güncellendi, en son değişiklikleri almak için {appName} uygulamasını yeniden başlatmanız gerekiyor ",
|
||||
"ApplyTags": "Etiketleri Uygula",
|
||||
"AnalyseVideoFiles": "Video dosyalarını analiz edin",
|
||||
"Anime": "Anime",
|
||||
"AnimeEpisodeFormat": "Anime Bölüm Formatı",
|
||||
"AudioInfo": "Ses Bilgisi",
|
||||
"AuthBasic": "Temel (Tarayıcı Açılır Penceresi)",
|
||||
"ApplyTagsHelpTextHowToApplySeries": "Seçili serilere etiketler nasıl uygulanır?",
|
||||
"AptUpdater": "Güncellemeyi yüklemek için apt'ı kullanın",
|
||||
"AnalyseVideoFilesHelpText": "Çözünürlük, çalışma zamanı ve kodek bilgileri gibi video bilgilerini dosyalardan çıkarın. Bu, {appName} uygulamasının taramalar sırasında yüksek disk veya ağ etkinliğine neden olabilecek dosyanın bölümlerini okumasını gerektirir.",
|
||||
"Delay": "Gecikme",
|
||||
"Fallback": "Geri Çek",
|
||||
"ManageFormats": "Biçimleri Yönet",
|
||||
"FavoriteFolderAdd": "Favori Klasör Ekle",
|
||||
"FavoriteFolderRemove": "Favori Klasörü Kaldır",
|
||||
"FavoriteFolders": "Favori Klasörler"
|
||||
}
|
||||
|
||||
@@ -1953,5 +1953,6 @@
|
||||
"InstallMajorVersionUpdateMessageLink": "请查看 [{domain}]({url}) 以获取更多信息。",
|
||||
"Install": "安装",
|
||||
"InstallMajorVersionUpdate": "安装更新",
|
||||
"InstallMajorVersionUpdateMessage": "此更新将安装新的主要版本,这可能与您的系统不兼容。您确定要安装此更新吗?"
|
||||
"InstallMajorVersionUpdateMessage": "此更新将安装新的主要版本,这可能与您的系统不兼容。您确定要安装此更新吗?",
|
||||
"Fallback": "备选"
|
||||
}
|
||||
|
||||
@@ -567,7 +567,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Manual
|
||||
_logger.ProgressTrace("Manually imported {0} files", imported.Count);
|
||||
}
|
||||
|
||||
var untrackedImports = imported.Where(i => importedTrackedDownload.FirstOrDefault(t => t.ImportResult != i) == null).ToList();
|
||||
var untrackedImports = imported.Where(i => i.Result == ImportResultType.Imported && importedTrackedDownload.FirstOrDefault(t => t.ImportResult != i) == null).ToList();
|
||||
|
||||
if (untrackedImports.Any())
|
||||
{
|
||||
|
||||
@@ -43,6 +43,11 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
request.Credentials = new BasicNetworkCredential(settings.Username, settings.Password);
|
||||
}
|
||||
|
||||
foreach (var header in settings.Headers)
|
||||
{
|
||||
request.Headers.Add(header.Key, header.Value);
|
||||
}
|
||||
|
||||
_httpClient.Execute(request);
|
||||
}
|
||||
catch (HttpException ex)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.Validation;
|
||||
@@ -20,6 +21,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
public WebhookSettings()
|
||||
{
|
||||
Method = Convert.ToInt32(WebhookMethod.POST);
|
||||
Headers = new List<KeyValuePair<string, string>>();
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "NotificationsSettingsWebhookUrl", Type = FieldType.Url)]
|
||||
@@ -34,6 +36,9 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
[FieldDefinition(3, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password)]
|
||||
public string Password { get; set; }
|
||||
|
||||
[FieldDefinition(4, Label = "NotificationsSettingsWebhookHeaders", Type = FieldType.KeyValueList, Advanced = true)]
|
||||
public IEnumerable<KeyValuePair<string, string>> Headers { get; set; }
|
||||
|
||||
public override NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace NzbDrone.Core.Parser
|
||||
new RegexReplace(@".*?[_. ](S\d{2}(?:E\d{2,4})*[_. ].*)", "$1", RegexOptions.Compiled | RegexOptions.IgnoreCase)
|
||||
};
|
||||
|
||||
private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?<english>\b(?:ing|eng)\b)|(?<italian>\b(?:ita|italian)\b)|(?<german>german\b|videomann|ger[. ]dub)|(?<flemish>flemish)|(?<greek>greek)|(?<french>(?:\W|_)(?:FR|VF|VF2|VFF|VFI|VFQ|TRUEFRENCH|FRENCH)(?:\W|_))|(?<russian>\b(?:rus|ru)\b)|(?<hungarian>\b(?:HUNDUB|HUN)\b)|(?<hebrew>\bHebDub\b)|(?<polish>\b(?:PL\W?DUB|DUB\W?PL|LEK\W?PL|PL\W?LEK)\b)|(?<chinese>\[(?:CH[ST]|BIG5|GB)\]|简|繁|字幕)|(?<bulgarian>\bbgaudio\b)|(?<spanish>\b(?:español|castellano|esp|spa(?!\(Latino\)))\b)|(?<ukrainian>\b(?:\dx?)?(?:ukr))|(?<thai>\b(?:THAI)\b)|(?<romanian>\b(?:RoDubbed|ROMANIAN)\b)|(?<catalan>[-,. ]cat[. ](?:DD|subs)|\b(?:catalan|catalán)\b)|(?<latvian>\b(?:lat|lav|lv)\b)|(?<turkish>\b(?:tur)\b)",
|
||||
private static readonly Regex LanguageRegex = new Regex(@"(?:\W|_)(?<english>\b(?:ing|eng)\b)|(?<italian>\b(?:ita|italian)\b)|(?<german>german\b|videomann|ger[. ]dub)|(?<flemish>flemish)|(?<greek>greek)|(?<french>(?:\W|_)(?:FR|VF|VF2|VFF|VFI|VFQ|TRUEFRENCH|FRENCH)(?:\W|_))|(?<russian>\b(?:rus|ru)\b)|(?<hungarian>\b(?:HUNDUB|HUN)\b)|(?<hebrew>\bHebDub\b)|(?<polish>\b(?:PL\W?DUB|DUB\W?PL|LEK\W?PL|PL\W?LEK)\b)|(?<chinese>\[(?:CH[ST]|BIG5|GB)\]|简|繁|字幕)|(?<bulgarian>\bbgaudio\b)|(?<spanish>\b(?:español|castellano|esp|spa(?!\(Latino\)))\b)|(?<ukrainian>\b(?:\dx?)?(?:ukr))|(?<thai>\b(?:THAI)\b)|(?<romanian>\b(?:RoDubbed|ROMANIAN)\b)|(?<catalan>[-,. ]cat[. ](?:DD|subs)|\b(?:catalan|catalán)\b)|(?<latvian>\b(?:lat|lav|lv)\b)|(?<turkish>\b(?:tur)\b)|(?<original>\b(?:orig|original)\b)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex CaseSensitiveLanguageRegex = new Regex(@"(?:(?i)(?<!SUB[\W|_|^]))(?:(?<lithuanian>\bLT\b)|(?<czech>\bCZ\b)|(?<polish>\bPL\b)|(?<bulgarian>\bBG\b)|(?<slovak>\bSK\b))(?:(?i)(?![\W|_|^]SUB))",
|
||||
@@ -470,6 +470,11 @@ namespace NzbDrone.Core.Parser
|
||||
{
|
||||
languages.Add(Language.Turkish);
|
||||
}
|
||||
|
||||
if (match.Groups["original"].Success)
|
||||
{
|
||||
languages.Add(Language.Original);
|
||||
}
|
||||
}
|
||||
|
||||
return languages;
|
||||
|
||||
@@ -564,7 +564,7 @@ namespace NzbDrone.Core.Parser
|
||||
|
||||
// Handle Exception Release Groups that don't follow -RlsGrp; Manual List
|
||||
// name only...be very careful with this last; high chance of false positives
|
||||
private static readonly Regex ExceptionReleaseGroupRegexExact = new Regex(@"(?<releasegroup>(?:D\-Z0N3|Fight-BB|VARYG|E\.N\.D|KRaLiMaRKo|BluDragon|DarQ|KCRT|BEN THE MAN)\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex ExceptionReleaseGroupRegexExact = new Regex(@"(?<releasegroup>(?:D\-Z0N3|Fight-BB|VARYG|E\.N\.D|KRaLiMaRKo|BluDragon|DarQ|KCRT|BEN[_. ]THE[_. ]MEN)\b)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// groups whose releases end with RlsGroup) or RlsGroup]
|
||||
private static readonly Regex ExceptionReleaseGroupRegex = new Regex(@"(?<=[._ \[])(?<releasegroup>(Silence|afm72|Panda|Ghost|MONOLITH|Tigole|Joy|ImE|UTR|t3nzin|Anime Time|Project Angel|Hakata Ramen|HONE|Vyndros|SEV|Garshasp|Kappa|Natty|RCVR|SAMPA|YOGI|r00t|EDGE2020|RZeroX)(?=\]|\)))", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
@@ -5931,8 +5931,21 @@
|
||||
"name": "quality",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/QueueStatus"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -10855,8 +10868,7 @@
|
||||
"nullable": true
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
"$ref": "#/components/schemas/QueueStatus"
|
||||
},
|
||||
"trackedDownloadStatus": {
|
||||
"$ref": "#/components/schemas/TrackedDownloadStatus"
|
||||
@@ -10935,6 +10947,21 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"QueueStatus": {
|
||||
"enum": [
|
||||
"unknown",
|
||||
"queued",
|
||||
"paused",
|
||||
"downloading",
|
||||
"completed",
|
||||
"failed",
|
||||
"warning",
|
||||
"delay",
|
||||
"downloadClientUnavailable",
|
||||
"fallback"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"QueueStatusResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user