mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
19 Commits
v4.0.8.215
...
v4.0.8.222
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14005d8d10 | ||
|
|
da7d17f5e8 | ||
|
|
ea331feb88 | ||
|
|
7dca9060ca | ||
|
|
8af12cc4e7 | ||
|
|
aa488019cf | ||
|
|
47a05ecb36 | ||
|
|
35baebaf72 | ||
|
|
aedcd046fc | ||
|
|
f45713bff8 | ||
|
|
911a3d4c1e | ||
|
|
e16ace54a8 | ||
|
|
84710a31bd | ||
|
|
093a239e77 | ||
|
|
ee69351733 | ||
|
|
e92a67ad78 | ||
|
|
3eca63a67c | ||
|
|
8484a8beba | ||
|
|
cd3a1c18ab |
@@ -134,6 +134,12 @@ module.exports = (env) => {
|
||||
{
|
||||
source: 'frontend/src/Content/robots.txt',
|
||||
destination: path.join(distFolder, 'Content/robots.txt')
|
||||
},
|
||||
|
||||
// manifest.json and browserconfig.xml
|
||||
{
|
||||
source: 'frontend/src/Content/*.(json|xml)',
|
||||
destination: path.join(distFolder, 'Content')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const mixinsFiles = [
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
'autoprefixer',
|
||||
['postcss-mixins', {
|
||||
mixinsFiles
|
||||
}],
|
||||
|
||||
@@ -24,7 +24,9 @@ export interface DownloadClientAppState
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export type GeneralAppState = AppSectionItemState<General>;
|
||||
export interface GeneralAppState
|
||||
extends AppSectionItemState<General>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import styles from './Label.css';
|
||||
|
||||
function Label(props) {
|
||||
const {
|
||||
className,
|
||||
kind,
|
||||
size,
|
||||
outline,
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Label.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
outline: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Label.defaultProps = {
|
||||
className: styles.label,
|
||||
kind: kinds.DEFAULT,
|
||||
size: sizes.SMALL,
|
||||
outline: false
|
||||
};
|
||||
|
||||
export default Label;
|
||||
31
frontend/src/Components/Label.tsx
Normal file
31
frontend/src/Components/Label.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps, ReactNode } from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import styles from './Label.css';
|
||||
|
||||
export interface LabelProps extends ComponentProps<'span'> {
|
||||
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
|
||||
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
|
||||
outline?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Label({
|
||||
className = styles.label,
|
||||
kind = kinds.DEFAULT,
|
||||
size = sizes.SMALL,
|
||||
outline = false,
|
||||
...otherProps
|
||||
}: LabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/Content/Images/Icons/mstile-150x150.png"/>
|
||||
<TileColor>#00ccff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "../../../../",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "standalone"
|
||||
}
|
||||
11
frontend/src/Content/browserconfig.xml
Normal file
11
frontend/src/Content/browserconfig.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="__URL_BASE__/Content/Images/Icons/mstile-150x150.png" />
|
||||
<TileColor>
|
||||
#00ccff
|
||||
</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
19
frontend/src/Content/manifest.json
Normal file
19
frontend/src/Content/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "__URL_BASE__/",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector';
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
function AuthenticationRequiredModal(props) {
|
||||
const {
|
||||
isOpen
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AuthenticationRequiredModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AuthenticationRequiredModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default AuthenticationRequiredModal;
|
||||
27
frontend/src/FirstRun/AuthenticationRequiredModal.tsx
Normal file
27
frontend/src/FirstRun/AuthenticationRequiredModal.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
interface AuthenticationRequiredModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function AuthenticationRequiredModal({
|
||||
isOpen,
|
||||
}: AuthenticationRequiredModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AuthenticationRequiredModalContent />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
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, kinds } from 'Helpers/Props';
|
||||
import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AuthenticationRequiredModalContent.css';
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
function AuthenticationRequiredModalContent(props) {
|
||||
const {
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
settings,
|
||||
onInputChange,
|
||||
onSavePress,
|
||||
dispatchFetchStatus
|
||||
} = props;
|
||||
|
||||
const {
|
||||
authenticationMethod,
|
||||
authenticationRequired,
|
||||
username,
|
||||
password,
|
||||
passwordConfirmation
|
||||
} = settings;
|
||||
|
||||
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
|
||||
|
||||
const didMount = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSaving && didMount.current) {
|
||||
dispatchFetchStatus();
|
||||
}
|
||||
|
||||
didMount.current = true;
|
||||
}, [isSaving, dispatchFetchStatus]);
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
showCloseButton={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
{translate('AuthenticationRequired')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Alert
|
||||
className={styles.authRequiredAlert}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{translate('AuthenticationRequiredWarning')}
|
||||
</Alert>
|
||||
|
||||
{
|
||||
isPopulated && !error ?
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText={translate('AuthenticationMethodHelpText')}
|
||||
helpTextWarning={authenticationMethod.value === 'none' ? translate('AuthenticationMethodHelpTextWarning') : undefined}
|
||||
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationRequired"
|
||||
values={authenticationRequiredOptions}
|
||||
helpText={translate('AuthenticationRequiredHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...authenticationRequired}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Username')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
onChange={onInputChange}
|
||||
helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Password')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
onChange={onInputChange}
|
||||
helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="passwordConfirmation"
|
||||
onChange={onInputChange}
|
||||
helpTextWarning={passwordConfirmation?.value ? undefined : translate('AuthenticationRequiredPasswordConfirmationHelpTextWarning')}
|
||||
{...passwordConfirmation}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isPopulated && !error ? <LoadingIndicator /> : null
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<SpinnerButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!authenticationEnabled}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
AuthenticationRequiredModalContent.propTypes = {
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
dispatchFetchStatus: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AuthenticationRequiredModalContent;
|
||||
194
frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx
Normal file
194
frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
authenticationMethodOptions,
|
||||
authenticationRequiredOptions,
|
||||
} from 'Settings/General/SecuritySettings';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchGeneralSettings,
|
||||
saveGeneralSettings,
|
||||
setGeneralSettingsValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AuthenticationRequiredModalContent.css';
|
||||
|
||||
const SECTION = 'general';
|
||||
|
||||
const selector = createSettingsSectionSelector(SECTION);
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
export default function AuthenticationRequiredModalContent() {
|
||||
const { isPopulated, error, isSaving, settings } = useSelector(selector);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
authenticationMethod,
|
||||
authenticationRequired,
|
||||
username,
|
||||
password,
|
||||
passwordConfirmation,
|
||||
} = settings;
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGeneralSettings());
|
||||
|
||||
return () => {
|
||||
dispatch(clearPendingChanges());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(args: InputChanged) => {
|
||||
// @ts-expect-error Actions aren't typed
|
||||
dispatch(setGeneralSettingsValue(args));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const authenticationEnabled =
|
||||
authenticationMethod && authenticationMethod.value !== 'none';
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaving || !wasSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchStatus());
|
||||
}, [isSaving, wasSaving, dispatch]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
dispatch(saveGeneralSettings());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent showCloseButton={false} onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('AuthenticationRequired')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Alert className={styles.authRequiredAlert} kind={kinds.WARNING}>
|
||||
{translate('AuthenticationRequiredWarning')}
|
||||
</Alert>
|
||||
|
||||
{isPopulated && !error ? (
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText={translate('AuthenticationMethodHelpText')}
|
||||
helpTextWarning={
|
||||
authenticationMethod.value === 'none'
|
||||
? translate('AuthenticationMethodHelpTextWarning')
|
||||
: undefined
|
||||
}
|
||||
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationRequired"
|
||||
values={authenticationRequiredOptions}
|
||||
helpText={translate('AuthenticationRequiredHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...authenticationRequired}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Username')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
helpTextWarning={
|
||||
username?.value
|
||||
? undefined
|
||||
: translate('AuthenticationRequiredUsernameHelpTextWarning')
|
||||
}
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Password')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
helpTextWarning={
|
||||
password?.value
|
||||
? undefined
|
||||
: translate('AuthenticationRequiredPasswordHelpTextWarning')
|
||||
}
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="passwordConfirmation"
|
||||
helpTextWarning={
|
||||
passwordConfirmation?.value
|
||||
? undefined
|
||||
: translate(
|
||||
'AuthenticationRequiredPasswordConfirmationHelpTextWarning'
|
||||
)
|
||||
}
|
||||
onChange={onInputChange}
|
||||
{...passwordConfirmation}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<SpinnerButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!authenticationEnabled}
|
||||
onPress={onPress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions';
|
||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
|
||||
|
||||
const SECTION = 'general';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSettingsSectionSelector(SECTION),
|
||||
(sectionSettings) => {
|
||||
return {
|
||||
...sectionSettings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchClearPendingChanges: clearPendingChanges,
|
||||
dispatchSetGeneralSettingsValue: setGeneralSettingsValue,
|
||||
dispatchSaveGeneralSettings: saveGeneralSettings,
|
||||
dispatchFetchGeneralSettings: fetchGeneralSettings,
|
||||
dispatchFetchStatus: fetchStatus
|
||||
};
|
||||
|
||||
class AuthenticationRequiredModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchGeneralSettings();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` });
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.dispatchSetGeneralSettingsValue({ name, value });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.dispatchSaveGeneralSettings();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchClearPendingChanges,
|
||||
dispatchFetchGeneralSettings,
|
||||
dispatchSetGeneralSettingsValue,
|
||||
dispatchSaveGeneralSettings,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<AuthenticationRequiredModalContent
|
||||
{...otherProps}
|
||||
onInputChange={this.onInputChange}
|
||||
onSavePress={this.onSavePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationRequiredModalContentConnector.propTypes = {
|
||||
dispatchClearPendingChanges: PropTypes.func.isRequired,
|
||||
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
|
||||
dispatchSetGeneralSettingsValue: PropTypes.func.isRequired,
|
||||
dispatchSaveGeneralSettings: PropTypes.func.isRequired,
|
||||
dispatchFetchStatus: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector);
|
||||
@@ -19,5 +19,5 @@ export const all = [
|
||||
PRIMARY,
|
||||
PURPLE,
|
||||
SUCCESS,
|
||||
WARNING
|
||||
];
|
||||
WARNING,
|
||||
] as const;
|
||||
@@ -4,4 +4,12 @@ export const MEDIUM = 'medium';
|
||||
export const LARGE = 'large';
|
||||
export const EXTRA_LARGE = 'extraLarge';
|
||||
export const EXTRA_EXTRA_LARGE = 'extraExtraLarge';
|
||||
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE, EXTRA_EXTRA_LARGE];
|
||||
|
||||
export const all = [
|
||||
EXTRA_SMALL,
|
||||
SMALL,
|
||||
MEDIUM,
|
||||
LARGE,
|
||||
EXTRA_LARGE,
|
||||
EXTRA_EXTRA_LARGE,
|
||||
] as const;
|
||||
@@ -130,6 +130,7 @@
|
||||
.sizeOnDisk,
|
||||
.qualityProfileName,
|
||||
.originalLanguageName,
|
||||
.statusName,
|
||||
.network,
|
||||
.links,
|
||||
.tags {
|
||||
|
||||
@@ -24,6 +24,7 @@ interface CssExports {
|
||||
'seriesNavigationButton': string;
|
||||
'seriesNavigationButtons': string;
|
||||
'sizeOnDisk': string;
|
||||
'statusName': string;
|
||||
'tags': string;
|
||||
'title': string;
|
||||
'titleContainer': string;
|
||||
|
||||
@@ -230,7 +230,7 @@ class SeriesDetails extends Component {
|
||||
} = this.state;
|
||||
|
||||
const statusDetails = getSeriesStatusDetails(status);
|
||||
const runningYears = statusDetails.title === translate('Ended') ? `${year}-${getDateYear(lastAired)}` : `${year}-`;
|
||||
const runningYears = status === 'ended' ? `${year}-${getDateYear(lastAired)}` : `${year}-`;
|
||||
|
||||
let episodeFilesCountMessage = translate('SeriesDetailsNoEpisodeFiles');
|
||||
|
||||
@@ -509,13 +509,14 @@ class SeriesDetails extends Component {
|
||||
className={styles.detailsLabel}
|
||||
title={statusDetails.message}
|
||||
size={sizes.LARGE}
|
||||
kind={status === 'deleted' ? kinds.INVERSE : undefined}
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
name={statusDetails.icon}
|
||||
size={17}
|
||||
/>
|
||||
<span className={styles.qualityProfileName}>
|
||||
<span className={styles.statusName}>
|
||||
{statusDetails.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ $hoverScale: 1.05;
|
||||
}
|
||||
}
|
||||
|
||||
.ended {
|
||||
.status {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
@@ -34,8 +34,15 @@ $hoverScale: 1.05;
|
||||
height: 0;
|
||||
border-width: 0 25px 25px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--dangerColor) transparent transparent;
|
||||
color: var(--white);
|
||||
|
||||
&.ended {
|
||||
border-color: transparent var(--dangerColor) transparent transparent;
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
border-color: transparent var(--gray) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
interface CssExports {
|
||||
'actions': string;
|
||||
'content': string;
|
||||
'deleted': string;
|
||||
'details': string;
|
||||
'ended': string;
|
||||
'info': string;
|
||||
@@ -11,6 +12,7 @@ interface CssExports {
|
||||
'overviewContainer': string;
|
||||
'poster': string;
|
||||
'posterContainer': string;
|
||||
'status': string;
|
||||
'tags': string;
|
||||
'title': string;
|
||||
'titleRow': string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import TextTruncate from 'react-text-truncate';
|
||||
@@ -146,9 +147,19 @@ function SeriesIndexOverview(props: SeriesIndexOverviewProps) {
|
||||
<SeriesIndexPosterSelect seriesId={seriesId} />
|
||||
) : null}
|
||||
|
||||
{status === 'ended' && (
|
||||
<div className={styles.ended} title={translate('Ended')} />
|
||||
)}
|
||||
{status === 'ended' ? (
|
||||
<div
|
||||
className={classNames(styles.status, styles.ended)}
|
||||
title={translate('Ended')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{status === 'deleted' ? (
|
||||
<div
|
||||
className={classNames(styles.status, styles.deleted)}
|
||||
title={translate('Deleted')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Link className={styles.link} style={elementStyle} to={link}>
|
||||
<SeriesPoster
|
||||
|
||||
@@ -71,7 +71,7 @@ $hoverScale: 1.05;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ended {
|
||||
.status {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
@@ -80,8 +80,15 @@ $hoverScale: 1.05;
|
||||
height: 0;
|
||||
border-width: 0 25px 25px 0;
|
||||
border-style: solid;
|
||||
border-color: transparent var(--dangerColor) transparent transparent;
|
||||
color: var(--white);
|
||||
|
||||
&.ended {
|
||||
border-color: transparent var(--dangerColor) transparent transparent;
|
||||
}
|
||||
|
||||
&.deleted {
|
||||
border-color: transparent var(--gray) transparent transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
|
||||
@@ -5,11 +5,13 @@ interface CssExports {
|
||||
'container': string;
|
||||
'content': string;
|
||||
'controls': string;
|
||||
'deleted': string;
|
||||
'ended': string;
|
||||
'link': string;
|
||||
'nextAiring': string;
|
||||
'overlayTitle': string;
|
||||
'posterContainer': string;
|
||||
'status': string;
|
||||
'tags': string;
|
||||
'tagsList': string;
|
||||
'title': string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { REFRESH_SERIES, SERIES_SEARCH } from 'Commands/commandNames';
|
||||
@@ -161,7 +162,17 @@ function SeriesIndexPoster(props: SeriesIndexPosterProps) {
|
||||
</Label>
|
||||
|
||||
{status === 'ended' ? (
|
||||
<div className={styles.ended} title={translate('Ended')} />
|
||||
<div
|
||||
className={classNames(styles.status, styles.ended)}
|
||||
title={translate('Ended')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{status === 'deleted' ? (
|
||||
<div
|
||||
className={classNames(styles.status, styles.deleted)}
|
||||
title={translate('Deleted')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Link className={styles.link} style={elementStyle} to={link}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sizes } from 'Helpers/Props';
|
||||
import createSeriesQueueItemsDetailsSelector, {
|
||||
SeriesQueueDetails,
|
||||
} from 'Series/Index/createSeriesQueueDetailsSelector';
|
||||
import { SeriesStatus } from 'Series/Series';
|
||||
import getProgressBarKind from 'Utilities/Series/getProgressBarKind';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SeriesIndexProgressBar.css';
|
||||
@@ -13,7 +14,7 @@ interface SeriesIndexProgressBarProps {
|
||||
seriesId: number;
|
||||
seasonNumber?: number;
|
||||
monitored: boolean;
|
||||
status: string;
|
||||
status: SeriesStatus;
|
||||
episodeCount: number;
|
||||
episodeFileCount: number;
|
||||
totalEpisodeCount: number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Icon from 'Components/Icon';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { SeriesStatus } from 'Series/Series';
|
||||
import { getSeriesStatusDetails } from 'Series/SeriesStatus';
|
||||
import { toggleSeriesMonitored } from 'Store/Actions/seriesActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
@@ -13,7 +14,7 @@ interface SeriesStatusCellProps {
|
||||
className: string;
|
||||
seriesId: number;
|
||||
monitored: boolean;
|
||||
status: string;
|
||||
status: SeriesStatus;
|
||||
isSelectMode: boolean;
|
||||
isSaving: boolean;
|
||||
component?: React.ElementType;
|
||||
|
||||
@@ -15,6 +15,8 @@ export type SeriesMonitor =
|
||||
| 'unmonitorSpecials'
|
||||
| 'none';
|
||||
|
||||
export type SeriesStatus = 'continuing' | 'ended' | 'upcoming' | 'deleted';
|
||||
|
||||
export type MonitorNewItems = 'all' | 'none';
|
||||
|
||||
export interface Image {
|
||||
@@ -86,7 +88,7 @@ interface Series extends ModelBase {
|
||||
seriesType: SeriesType;
|
||||
sortTitle: string;
|
||||
statistics: Statistics;
|
||||
status: string;
|
||||
status: SeriesStatus;
|
||||
tags: number[];
|
||||
title: string;
|
||||
titleSlug: string;
|
||||
|
||||
@@ -1,32 +1,31 @@
|
||||
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { SeriesStatus } from 'Series/Series';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
export function getSeriesStatusDetails(status) {
|
||||
|
||||
export function getSeriesStatusDetails(status: SeriesStatus) {
|
||||
let statusDetails = {
|
||||
icon: icons.SERIES_CONTINUING,
|
||||
title: translate('Continuing'),
|
||||
message: translate('ContinuingSeriesDescription')
|
||||
message: translate('ContinuingSeriesDescription'),
|
||||
};
|
||||
|
||||
if (status === 'deleted') {
|
||||
statusDetails = {
|
||||
icon: icons.SERIES_DELETED,
|
||||
title: translate('Deleted'),
|
||||
message: translate('DeletedSeriesDescription')
|
||||
message: translate('DeletedSeriesDescription'),
|
||||
};
|
||||
} else if (status === 'ended') {
|
||||
statusDetails = {
|
||||
icon: icons.SERIES_ENDED,
|
||||
title: translate('Ended'),
|
||||
message: translate('EndedSeriesDescription')
|
||||
message: translate('EndedSeriesDescription'),
|
||||
};
|
||||
} else if (status === 'upcoming') {
|
||||
statusDetails = {
|
||||
icon: icons.SERIES_CONTINUING,
|
||||
title: translate('Upcoming'),
|
||||
message: translate('UpcomingSeriesDescription')
|
||||
message: translate('UpcomingSeriesDescription'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
|
||||
function SeriesTitleLink({ titleSlug, title }) {
|
||||
const link = `/series/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<Link to={link}>
|
||||
{title}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesTitleLink.propTypes = {
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default SeriesTitleLink;
|
||||
21
frontend/src/Series/SeriesTitleLink.tsx
Normal file
21
frontend/src/Series/SeriesTitleLink.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import Link, { LinkProps } from 'Components/Link/Link';
|
||||
|
||||
export interface SeriesTitleLinkProps extends LinkProps {
|
||||
titleSlug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function SeriesTitleLink({
|
||||
titleSlug,
|
||||
title,
|
||||
...linkProps
|
||||
}: SeriesTitleLinkProps) {
|
||||
const link = `/series/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<Link to={link} {...linkProps}>
|
||||
{title}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -157,6 +157,7 @@ class GeneralSettings extends Component {
|
||||
/>
|
||||
|
||||
<LoggingSettings
|
||||
advancedSettings={advancedSettings}
|
||||
settings={settings}
|
||||
onInputChange={onInputChange}
|
||||
/>
|
||||
|
||||
@@ -30,12 +30,14 @@ const logLevelOptions = [
|
||||
|
||||
function LoggingSettings(props) {
|
||||
const {
|
||||
advancedSettings,
|
||||
settings,
|
||||
onInputChange
|
||||
} = props;
|
||||
|
||||
const {
|
||||
logLevel
|
||||
logLevel,
|
||||
logSizeLimit
|
||||
} = settings;
|
||||
|
||||
return (
|
||||
@@ -52,11 +54,30 @@ function LoggingSettings(props) {
|
||||
{...logLevel}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
advancedSettings={advancedSettings}
|
||||
isAdvanced={true}
|
||||
>
|
||||
<FormLabel>{translate('LogSizeLimit')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.NUMBER}
|
||||
name="logSizeLimit"
|
||||
min={1}
|
||||
max={10}
|
||||
unit="MB"
|
||||
helpText={translate('LogSizeLimitHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...logSizeLimit}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
LoggingSettings.propTypes = {
|
||||
advancedSettings: PropTypes.bool.isRequired,
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ function DiskSpace() {
|
||||
const { freeSpace, totalSpace } = item;
|
||||
|
||||
const diskUsage = 100 - (freeSpace / totalSpace) * 100;
|
||||
let diskUsageKind = kinds.PRIMARY;
|
||||
let diskUsageKind: (typeof kinds.all)[number] = kinds.PRIMARY;
|
||||
|
||||
if (diskUsage > 90) {
|
||||
diskUsageKind = kinds.DANGER;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { SeriesStatus } from 'Series/Series';
|
||||
|
||||
function getProgressBarKind(
|
||||
status: string,
|
||||
status: SeriesStatus,
|
||||
monitored: boolean,
|
||||
progress: number,
|
||||
isDownloading: boolean
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
sizes="16x16"
|
||||
href="/Content/Images/Icons/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/Content/Images/Icons/manifest.json" crossorigin="use-credentials" />
|
||||
<link rel="manifest" href="/Content/manifest.json" crossorigin="use-credentials" />
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="/Content/Images/Icons/safari-pinned-tab.svg"
|
||||
@@ -47,7 +47,7 @@
|
||||
/>
|
||||
<meta
|
||||
name="msapplication-config"
|
||||
content="/Content/Images/Icons/browserconfig.xml"
|
||||
content="/Content/browserconfig.xml"
|
||||
/>
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css">
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
<!-- Android/Apple Phone -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
<meta name="description" content="Sonarr" />
|
||||
|
||||
@@ -33,7 +36,11 @@
|
||||
sizes="16x16"
|
||||
href="/Content/Images/Icons/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/Content/Images/Icons/manifest.json" crossorigin="use-credentials" />
|
||||
<link
|
||||
rel="manifest"
|
||||
href="/Content/manifest.json"
|
||||
crossorigin="use-credentials"
|
||||
/>
|
||||
<link
|
||||
rel="mask-icon"
|
||||
href="/Content/Images/Icons/safari-pinned-tab.svg"
|
||||
@@ -45,10 +52,7 @@
|
||||
href="/favicon.ico"
|
||||
data-no-hash
|
||||
/>
|
||||
<meta
|
||||
name="msapplication-config"
|
||||
content="/Content/Images/Icons/browserconfig.xml"
|
||||
/>
|
||||
<meta name="msapplication-config" content="/Content/browserconfig.xml" />
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="/Content/styles.css" />
|
||||
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css" />
|
||||
@@ -59,7 +63,7 @@
|
||||
body {
|
||||
background-color: var(--pageBackground);
|
||||
color: var(--textColor);
|
||||
font-family: "Roboto", "open sans", "Helvetica Neue", Helvetica, Arial,
|
||||
font-family: 'Roboto', 'open sans', 'Helvetica Neue', Helvetica, Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@@ -209,9 +213,7 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="sign-in">
|
||||
SIGN IN TO CONTINUE
|
||||
</div>
|
||||
<div class="sign-in">SIGN IN TO CONTINUE</div>
|
||||
|
||||
<form
|
||||
role="form"
|
||||
@@ -230,8 +232,8 @@
|
||||
pattern=".{1,}"
|
||||
required
|
||||
title="User name is required"
|
||||
autoFocus="true"
|
||||
autoCapitalize="false"
|
||||
autofocus="true"
|
||||
autocapitalize="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -282,16 +284,16 @@
|
||||
</body>
|
||||
|
||||
<script type="text/javascript">
|
||||
var yearSpan = document.getElementById("year");
|
||||
yearSpan.innerHTML = "2010-" + new Date().getFullYear();
|
||||
var yearSpan = document.getElementById('year');
|
||||
yearSpan.innerHTML = '2010-' + new Date().getFullYear();
|
||||
|
||||
var copyDiv = document.getElementById("copy");
|
||||
copyDiv.classList.remove("hidden");
|
||||
var copyDiv = document.getElementById('copy');
|
||||
copyDiv.classList.remove('hidden');
|
||||
|
||||
if (window.location.search.indexOf("loginFailed=true") > -1) {
|
||||
var loginFailedDiv = document.getElementById("login-failed");
|
||||
if (window.location.search.indexOf('loginFailed=true') > -1) {
|
||||
var loginFailedDiv = document.getElementById('login-failed');
|
||||
|
||||
loginFailedDiv.classList.remove("hidden");
|
||||
loginFailedDiv.classList.remove('hidden');
|
||||
}
|
||||
|
||||
var light = {
|
||||
@@ -311,7 +313,7 @@
|
||||
primaryHoverBorderColor: '#3483e7',
|
||||
failedColor: '#f05050',
|
||||
forgotPasswordColor: '#909fa7',
|
||||
forgotPasswordAltColor: '#748690'
|
||||
forgotPasswordAltColor: '#748690',
|
||||
};
|
||||
|
||||
var dark = {
|
||||
@@ -331,21 +333,16 @@
|
||||
primaryHoverBorderColor: '#3483e7',
|
||||
failedColor: '#f05050',
|
||||
forgotPasswordColor: '#737d83',
|
||||
forgotPasswordAltColor: '#546067'
|
||||
forgotPasswordAltColor: '#546067',
|
||||
};
|
||||
|
||||
var theme = "_THEME_";
|
||||
var theme = '_THEME_';
|
||||
var defaultDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
var finalTheme = theme === 'dark' || (theme === 'auto' && defaultDark) ?
|
||||
dark :
|
||||
light;
|
||||
var finalTheme =
|
||||
theme === 'dark' || (theme === 'auto' && defaultDark) ? dark : light;
|
||||
|
||||
Object.entries(finalTheme).forEach(([key, value]) => {
|
||||
document.documentElement.style.setProperty(
|
||||
`--${key}`,
|
||||
value
|
||||
);
|
||||
document.documentElement.style.setProperty(`--${key}`, value);
|
||||
});
|
||||
|
||||
</script>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type CheckInputChanged = {
|
||||
export type InputChanged<T = unknown> = {
|
||||
name: string;
|
||||
value: boolean;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export type CheckInputChanged = InputChanged<boolean>;
|
||||
|
||||
22
package.json
22
package.json
@@ -84,13 +84,13 @@
|
||||
"typescript": "5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.24.4",
|
||||
"@babel/eslint-parser": "7.24.1",
|
||||
"@babel/plugin-proposal-export-default-from": "7.24.1",
|
||||
"@babel/core": "7.25.2",
|
||||
"@babel/eslint-parser": "7.25.1",
|
||||
"@babel/plugin-proposal-export-default-from": "7.24.7",
|
||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||
"@babel/preset-env": "7.24.4",
|
||||
"@babel/preset-react": "7.24.1",
|
||||
"@babel/preset-typescript": "7.24.1",
|
||||
"@babel/preset-env": "7.25.3",
|
||||
"@babel/preset-react": "7.24.7",
|
||||
"@babel/preset-typescript": "7.24.7",
|
||||
"@types/lodash": "4.14.194",
|
||||
"@types/qs": "6.9.15",
|
||||
"@types/react-document-title": "2.0.9",
|
||||
@@ -102,11 +102,11 @@
|
||||
"@types/webpack-livereload-plugin": "^2.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"autoprefixer": "10.4.14",
|
||||
"babel-loader": "9.1.2",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.1.3",
|
||||
"babel-plugin-inline-classnames": "2.0.1",
|
||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||
"core-js": "3.37.0",
|
||||
"core-js": "3.38.0",
|
||||
"css-loader": "6.7.3",
|
||||
"css-modules-typescript-loader": "4.0.1",
|
||||
"eslint": "8.57.0",
|
||||
@@ -123,11 +123,11 @@
|
||||
"html-webpack-plugin": "5.5.1",
|
||||
"loader-utils": "^3.2.1",
|
||||
"mini-css-extract-plugin": "2.7.5",
|
||||
"postcss": "8.4.38",
|
||||
"postcss": "8.4.41",
|
||||
"postcss-color-function": "4.1.0",
|
||||
"postcss-loader": "7.3.0",
|
||||
"postcss-mixins": "9.0.4",
|
||||
"postcss-nested": "6.0.1",
|
||||
"postcss-nested": "6.2.0",
|
||||
"postcss-simple-vars": "7.0.1",
|
||||
"postcss-url": "10.1.3",
|
||||
"prettier": "2.8.8",
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Download.Aggregation.Aggregators;
|
||||
using NzbDrone.Core.Indexers;
|
||||
@@ -65,11 +66,12 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_indexer_has_multi_languages_configuration()
|
||||
public void should_return_multi_languages_when_indexer_id_has_multi_languages_configuration()
|
||||
{
|
||||
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
|
||||
};
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
@@ -81,6 +83,67 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
_remoteEpisode.Release.Title = releaseTitle;
|
||||
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage, Language.French });
|
||||
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_from_indexer_with_id_when_indexer_id_and_name_are_set()
|
||||
{
|
||||
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
|
||||
var indexerDefinition1 = new IndexerDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "MyIndexer1",
|
||||
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
|
||||
};
|
||||
var indexerDefinition2 = new IndexerDefinition
|
||||
{
|
||||
Id = 2,
|
||||
Name = "MyIndexer2",
|
||||
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.German.Id } }
|
||||
};
|
||||
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.Get(1))
|
||||
.Returns(indexerDefinition1);
|
||||
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.All())
|
||||
.Returns(new List<IndexerDefinition>() { indexerDefinition1, indexerDefinition2 });
|
||||
|
||||
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
|
||||
_remoteEpisode.Release.IndexerId = 1;
|
||||
_remoteEpisode.Release.Indexer = "MyIndexer2";
|
||||
_remoteEpisode.Release.Title = releaseTitle;
|
||||
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage, Language.French });
|
||||
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_multi_languages_when_indexer_name_has_multi_languages_configuration()
|
||||
{
|
||||
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "MyIndexer (Prowlarr)",
|
||||
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
|
||||
};
|
||||
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.FindByName("MyIndexer (Prowlarr)"))
|
||||
.Returns(indexerDefinition);
|
||||
|
||||
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
|
||||
_remoteEpisode.Release.Indexer = "MyIndexer (Prowlarr)";
|
||||
_remoteEpisode.Release.Title = releaseTitle;
|
||||
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage, Language.French });
|
||||
Mocker.GetMock<IIndexerFactory>().Verify(c => c.FindByName("MyIndexer (Prowlarr)"), Times.Once());
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -89,6 +152,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
|
||||
};
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
@@ -100,6 +164,8 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
_remoteEpisode.Release.Title = releaseTitle;
|
||||
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage, Language.French });
|
||||
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -108,6 +174,7 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Settings = new TorrentRssIndexerSettings { }
|
||||
};
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
@@ -119,6 +186,20 @@ namespace NzbDrone.Core.Test.Download.Aggregation.Aggregators
|
||||
_remoteEpisode.Release.Title = releaseTitle;
|
||||
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage });
|
||||
Mocker.GetMock<IIndexerFactory>().Verify(c => c.Get(1), Times.Once());
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_original_when_no_indexer_value()
|
||||
{
|
||||
var releaseTitle = "Series.Title.S01E01.MULTi.1080p.WEB.H265-RlsGroup";
|
||||
|
||||
_remoteEpisode.ParsedEpisodeInfo = GetParsedEpisodeInfo(new List<Language> { }, releaseTitle);
|
||||
_remoteEpisode.Release.Title = releaseTitle;
|
||||
|
||||
Subject.Aggregate(_remoteEpisode).Languages.Should().BeEquivalentTo(new List<Language> { _series.OriginalLanguage });
|
||||
Mocker.GetMock<IIndexerFactory>().VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -7,6 +7,8 @@ using NzbDrone.Core.Download;
|
||||
using NzbDrone.Core.Download.TrackedDownloads;
|
||||
using NzbDrone.Core.History;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Indexers.TorrentRss;
|
||||
using NzbDrone.Core.Languages;
|
||||
using NzbDrone.Core.Parser;
|
||||
using NzbDrone.Core.Parser.Model;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
@@ -84,6 +86,80 @@ namespace NzbDrone.Core.Test.Download.TrackedDownloads
|
||||
trackedDownload.RemoteEpisode.MappedSeasonNumber.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_set_indexer()
|
||||
{
|
||||
var episodeHistory = new EpisodeHistory()
|
||||
{
|
||||
DownloadId = "35238",
|
||||
SourceTitle = "TV Series S01",
|
||||
SeriesId = 5,
|
||||
EpisodeId = 4,
|
||||
EventType = EpisodeHistoryEventType.Grabbed,
|
||||
};
|
||||
episodeHistory.Data.Add("indexer", "MyIndexer (Prowlarr)");
|
||||
Mocker.GetMock<IHistoryService>()
|
||||
.Setup(s => s.FindByDownloadId(It.Is<string>(sr => sr == "35238")))
|
||||
.Returns(new List<EpisodeHistory>()
|
||||
{
|
||||
episodeHistory
|
||||
});
|
||||
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 1,
|
||||
Name = "MyIndexer (Prowlarr)",
|
||||
Settings = new TorrentRssIndexerSettings { MultiLanguages = new List<int> { Language.Original.Id, Language.French.Id } }
|
||||
};
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.Get(indexerDefinition.Id))
|
||||
.Returns(indexerDefinition);
|
||||
Mocker.GetMock<IIndexerFactory>()
|
||||
.Setup(v => v.All())
|
||||
.Returns(new List<IndexerDefinition>() { indexerDefinition });
|
||||
|
||||
var remoteEpisode = new RemoteEpisode
|
||||
{
|
||||
Series = new Series() { Id = 5 },
|
||||
Episodes = new List<Episode> { new Episode { Id = 4 } },
|
||||
ParsedEpisodeInfo = new ParsedEpisodeInfo()
|
||||
{
|
||||
SeriesTitle = "TV Series",
|
||||
SeasonNumber = 1
|
||||
},
|
||||
MappedSeasonNumber = 1
|
||||
};
|
||||
|
||||
Mocker.GetMock<IParsingService>()
|
||||
.Setup(s => s.Map(It.IsAny<ParsedEpisodeInfo>(), It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>(), null))
|
||||
.Returns(remoteEpisode);
|
||||
|
||||
var client = new DownloadClientDefinition()
|
||||
{
|
||||
Id = 1,
|
||||
Protocol = DownloadProtocol.Torrent
|
||||
};
|
||||
|
||||
var item = new DownloadClientItem()
|
||||
{
|
||||
Title = "TV.Series.S01.MULTi.1080p.WEB.H265-RlsGroup",
|
||||
DownloadId = "35238",
|
||||
DownloadClientInfo = new DownloadClientItemClientInfo
|
||||
{
|
||||
Protocol = client.Protocol,
|
||||
Id = client.Id,
|
||||
Name = client.Name
|
||||
}
|
||||
};
|
||||
|
||||
var trackedDownload = Subject.TrackDownload(client, item);
|
||||
|
||||
trackedDownload.Should().NotBeNull();
|
||||
trackedDownload.RemoteEpisode.Should().NotBeNull();
|
||||
trackedDownload.RemoteEpisode.Release.Should().NotBeNull();
|
||||
trackedDownload.RemoteEpisode.Release.Indexer.Should().Be("MyIndexer (Prowlarr)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_parse_as_special_when_source_title_parsing_fails()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.IO;
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Extras.Metadata;
|
||||
using NzbDrone.Core.Extras.Metadata.Consumers.Kometa;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
using NzbDrone.Core.Tv;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
namespace NzbDrone.Core.Test.Extras.Metadata.Consumers.Kometa
|
||||
{
|
||||
[TestFixture]
|
||||
public class FindMetadataFileFixture : CoreTest<KometaMetadata>
|
||||
{
|
||||
private Series _series;
|
||||
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_series = Builder<Series>.CreateNew()
|
||||
.With(s => s.Path = @"C:\Test\TV\The.Series".AsOsAgnostic())
|
||||
.Build();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_null_if_filename_is_not_handled()
|
||||
{
|
||||
var path = Path.Combine(_series.Path, "file.jpg");
|
||||
|
||||
Subject.FindMetadataFile(_series, path).Should().BeNull();
|
||||
}
|
||||
|
||||
[TestCase("Season00")]
|
||||
[TestCase("Season01")]
|
||||
[TestCase("Season02")]
|
||||
public void should_return_season_image(string folder)
|
||||
{
|
||||
var path = Path.Combine(_series.Path, folder + ".jpg");
|
||||
|
||||
Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.SeasonImage);
|
||||
}
|
||||
|
||||
[TestCase(".jpg", MetadataType.EpisodeImage)]
|
||||
public void should_return_metadata_for_episode_if_valid_file_for_episode(string extension, MetadataType type)
|
||||
{
|
||||
var path = Path.Combine(_series.Path, "s01e01" + extension);
|
||||
|
||||
Subject.FindMetadataFile(_series, path).Type.Should().Be(type);
|
||||
}
|
||||
|
||||
[TestCase(".jpg")]
|
||||
public void should_return_null_if_not_valid_file_for_episode(string extension)
|
||||
{
|
||||
var path = Path.Combine(_series.Path, "the.series.episode" + extension);
|
||||
|
||||
Subject.FindMetadataFile(_series, path).Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_return_metadata_if_image_file_is_a_thumb()
|
||||
{
|
||||
var path = Path.Combine(_series.Path, "the.series.s01e01.episode-thumb.jpg");
|
||||
|
||||
Subject.FindMetadataFile(_series, path).Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_return_series_image_for_folder_jpg_in_series_folder()
|
||||
{
|
||||
var path = Path.Combine(_series.Path, "poster.jpg");
|
||||
|
||||
Subject.FindMetadataFile(_series, path).Type.Should().Be(MetadataType.SeriesImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/NzbDrone.Core.Test/Indexers/IndexerRepositoryFixture.cs
Normal file
44
src/NzbDrone.Core.Test/Indexers/IndexerRepositoryFixture.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using FizzWare.NBuilder;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Test.Framework;
|
||||
|
||||
namespace NzbDrone.Core.Test.Indexers
|
||||
{
|
||||
[TestFixture]
|
||||
public class IndexerRepositoryFixture : DbTest<IndexerRepository, IndexerDefinition>
|
||||
{
|
||||
private void GivenIndexers()
|
||||
{
|
||||
var indexers = Builder<IndexerDefinition>.CreateListOfSize(2)
|
||||
.All()
|
||||
.With(c => c.Id = 0)
|
||||
.TheFirst(1)
|
||||
.With(x => x.Name = "MyIndexer (Prowlarr)")
|
||||
.TheNext(1)
|
||||
.With(x => x.Name = "My Second Indexer (Prowlarr)")
|
||||
.BuildList();
|
||||
|
||||
Subject.InsertMany(indexers);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_finds_with_name()
|
||||
{
|
||||
GivenIndexers();
|
||||
var found = Subject.FindByName("MyIndexer (Prowlarr)");
|
||||
found.Should().NotBeNull();
|
||||
found.Name.Should().Be("MyIndexer (Prowlarr)");
|
||||
found.Id.Should().Be(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void should_not_find_with_incorrect_case_name()
|
||||
{
|
||||
GivenIndexers();
|
||||
var found = Subject.FindByName("myindexer (prowlarr)");
|
||||
found.Should().BeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("Босх: Спадок (S2E1-4) / Series: Legacy (S2E1-4) (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, new[] { 1, 2, 3, 4 })]
|
||||
[TestCase("Босх: Спадок / Series: Legacy / S2E1-4 of 10 (2023) WEB-DL 1080p Ukr/Eng | sub Eng", "Series: Legacy", 2, new[] { 1, 2, 3, 4 })]
|
||||
[TestCase("Series Title - S26E96-97-98-99-100 - Episode 5931 + Episode 5932 + Episode 5933 + Episode 5934 + Episode 5935", "Series Title", 26, new[] { 96, 97, 98, 99, 100 })]
|
||||
[TestCase("Series falls - Temporada 1 [HDTV][Cap.111_120]", "Series falls", 1, new[] { 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 })]
|
||||
|
||||
// [TestCase("", "", , new [] { })]
|
||||
public void should_parse_multiple_episodes(string postTitle, string title, int season, int[] episodes)
|
||||
|
||||
@@ -172,6 +172,9 @@ namespace NzbDrone.Core.Test.ParserTests
|
||||
[TestCase("[ReleaseGroup] SeriesTitle S01E1 Webdl 1080p", "SeriesTitle", 1, 1)]
|
||||
[TestCase("[SubsPlus+] Series no Chill - S02E01 (NF WEB 1080p AVC AAC)", "Series no Chill", 2, 1)]
|
||||
[TestCase("[SubsPlus+] Series no Chill - S02E01v2 (NF WEB 1080p AVC AAC)", "Series no Chill", 2, 1)]
|
||||
[TestCase("Series - Temporada 1 - [HDTV 1080p][Cap.101](wolfmax4k.com)", "Series", 1, 1)]
|
||||
[TestCase("Series [HDTV 1080p][Cap.101](wolfmax4k.com)", "Series", 1, 1)]
|
||||
[TestCase("Series [HDTV 1080p][Cap. 101](wolfmax4k.com).mkv", "Series", 1, 1)]
|
||||
|
||||
// [TestCase("", "", 0, 0)]
|
||||
public void should_parse_single_episode(string postTitle, string title, int seasonNumber, int episodeNumber)
|
||||
|
||||
@@ -76,9 +76,18 @@ namespace NzbDrone.Core.Download.Aggregation.Aggregators
|
||||
languages = languages.Except(languagesToRemove).ToList();
|
||||
}
|
||||
|
||||
if ((languages.Count == 0 || (languages.Count == 1 && languages.First() == Language.Unknown)) && releaseInfo is { IndexerId: > 0 } && releaseInfo.Title.IsNotNullOrWhiteSpace())
|
||||
if ((languages.Count == 0 || (languages.Count == 1 && languages.First() == Language.Unknown)) && releaseInfo?.Title?.IsNotNullOrWhiteSpace() == true)
|
||||
{
|
||||
var indexer = _indexerFactory.Get(releaseInfo.IndexerId);
|
||||
IndexerDefinition indexer = null;
|
||||
|
||||
if (releaseInfo is { IndexerId: > 0 })
|
||||
{
|
||||
indexer = _indexerFactory.Get(releaseInfo.IndexerId);
|
||||
}
|
||||
else if (releaseInfo.Indexer?.IsNotNullOrWhiteSpace() == true)
|
||||
{
|
||||
indexer = _indexerFactory.FindByName(releaseInfo.Indexer);
|
||||
}
|
||||
|
||||
if (indexer?.Settings is IIndexerSettings settings && settings.MultiLanguages.Any() && Parser.Parser.HasMultipleLanguages(releaseInfo.Title))
|
||||
{
|
||||
|
||||
@@ -120,8 +120,6 @@ namespace NzbDrone.Core.Download.TrackedDownloads
|
||||
if (parsedEpisodeInfo != null)
|
||||
{
|
||||
trackedDownload.RemoteEpisode = _parsingService.Map(parsedEpisodeInfo, 0, 0, null);
|
||||
|
||||
_aggregationService.Augment(trackedDownload.RemoteEpisode);
|
||||
}
|
||||
|
||||
var downloadHistory = _downloadHistoryService.GetLatestDownloadHistoryItem(downloadItem.DownloadId);
|
||||
@@ -158,17 +156,24 @@ namespace NzbDrone.Core.Download.TrackedDownloads
|
||||
}
|
||||
}
|
||||
|
||||
if (trackedDownload.RemoteEpisode != null &&
|
||||
Enum.TryParse(grabbedEvent?.Data?.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags))
|
||||
if (trackedDownload.RemoteEpisode != null)
|
||||
{
|
||||
trackedDownload.RemoteEpisode.Release ??= new ReleaseInfo();
|
||||
trackedDownload.RemoteEpisode.Release.IndexerFlags = flags;
|
||||
trackedDownload.RemoteEpisode.Release.Indexer = trackedDownload.Indexer;
|
||||
trackedDownload.RemoteEpisode.Release.Title = trackedDownload.RemoteEpisode.ParsedEpisodeInfo?.ReleaseTitle;
|
||||
|
||||
if (Enum.TryParse(grabbedEvent?.Data?.GetValueOrDefault("indexerFlags"), true, out IndexerFlags flags))
|
||||
{
|
||||
trackedDownload.RemoteEpisode.Release.IndexerFlags = flags;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate custom formats
|
||||
if (trackedDownload.RemoteEpisode != null)
|
||||
{
|
||||
_aggregationService.Augment(trackedDownload.RemoteEpisode);
|
||||
|
||||
// Calculate custom formats
|
||||
trackedDownload.RemoteEpisode.CustomFormats = _formatCalculator.ParseCustomFormat(trackedDownload.RemoteEpisode, downloadItem.TotalSize);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Extras.Metadata.Files;
|
||||
using NzbDrone.Core.MediaCover;
|
||||
using NzbDrone.Core.MediaFiles;
|
||||
using NzbDrone.Core.Tv;
|
||||
|
||||
namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
{
|
||||
public class KometaMetadata : MetadataBase<KometaMetadataSettings>
|
||||
{
|
||||
private readonly Logger _logger;
|
||||
private readonly IMapCoversToLocal _mediaCoverService;
|
||||
|
||||
public KometaMetadata(IMapCoversToLocal mediaCoverService,
|
||||
Logger logger)
|
||||
{
|
||||
_mediaCoverService = mediaCoverService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private static readonly Regex SeriesImagesRegex = new Regex(@"^(?<type>poster)\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex SeasonImagesRegex = new Regex(@"^Season(?<season>\d{2,})\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex EpisodeImageRegex = new Regex(@"^S(?<season>\d{2,})E(?<episode>\d{2,})\.(?:png|jpg)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public override string Name => "Kometa";
|
||||
|
||||
public override string GetFilenameAfterMove(Series series, EpisodeFile episodeFile, MetadataFile metadataFile)
|
||||
{
|
||||
if (metadataFile.Type == MetadataType.EpisodeImage)
|
||||
{
|
||||
return GetEpisodeImageFilename(series, episodeFile);
|
||||
}
|
||||
|
||||
_logger.Debug("Unknown episode file metadata: {0}", metadataFile.RelativePath);
|
||||
return Path.Combine(series.Path, metadataFile.RelativePath);
|
||||
}
|
||||
|
||||
public override MetadataFile FindMetadataFile(Series series, string path)
|
||||
{
|
||||
var filename = Path.GetFileName(path);
|
||||
|
||||
if (filename == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var metadata = new MetadataFile
|
||||
{
|
||||
SeriesId = series.Id,
|
||||
Consumer = GetType().Name,
|
||||
RelativePath = series.Path.GetRelativePath(path)
|
||||
};
|
||||
|
||||
if (SeriesImagesRegex.IsMatch(filename))
|
||||
{
|
||||
metadata.Type = MetadataType.SeriesImage;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
var seasonMatch = SeasonImagesRegex.Match(filename);
|
||||
|
||||
if (seasonMatch.Success)
|
||||
{
|
||||
metadata.Type = MetadataType.SeasonImage;
|
||||
|
||||
var seasonNumberMatch = seasonMatch.Groups["season"].Value;
|
||||
|
||||
if (int.TryParse(seasonNumberMatch, out var seasonNumber))
|
||||
{
|
||||
metadata.SeasonNumber = seasonNumber;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
if (EpisodeImageRegex.IsMatch(filename))
|
||||
{
|
||||
metadata.Type = MetadataType.EpisodeImage;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public override MetadataFileResult SeriesMetadata(Series series)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public override MetadataFileResult EpisodeMetadata(Series series, EpisodeFile episodeFile)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public override List<ImageFileResult> SeriesImages(Series series)
|
||||
{
|
||||
if (!Settings.SeriesImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return ProcessSeriesImages(series).ToList();
|
||||
}
|
||||
|
||||
public override List<ImageFileResult> SeasonImages(Series series, Season season)
|
||||
{
|
||||
if (!Settings.SeasonImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return ProcessSeasonImages(series, season).ToList();
|
||||
}
|
||||
|
||||
public override List<ImageFileResult> EpisodeImages(Series series, EpisodeFile episodeFile)
|
||||
{
|
||||
if (!Settings.EpisodeImages)
|
||||
{
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var screenshot = episodeFile.Episodes.Value.First().Images.SingleOrDefault(i => i.CoverType == MediaCoverTypes.Screenshot);
|
||||
|
||||
if (screenshot == null)
|
||||
{
|
||||
_logger.Debug("Episode screenshot not available");
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
|
||||
return new List<ImageFileResult>
|
||||
{
|
||||
new ImageFileResult(GetEpisodeImageFilename(series, episodeFile), screenshot.RemoteUrl)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error(ex, "Unable to process episode image for file: {0}", Path.Combine(series.Path, episodeFile.RelativePath));
|
||||
|
||||
return new List<ImageFileResult>();
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ImageFileResult> ProcessSeriesImages(Series series)
|
||||
{
|
||||
foreach (var image in series.Images)
|
||||
{
|
||||
if (image.CoverType == MediaCoverTypes.Poster)
|
||||
{
|
||||
var source = _mediaCoverService.GetCoverPath(series.Id, image.CoverType);
|
||||
var destination = image.CoverType + Path.GetExtension(source);
|
||||
|
||||
yield return new ImageFileResult(destination, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<ImageFileResult> ProcessSeasonImages(Series series, Season season)
|
||||
{
|
||||
foreach (var image in season.Images)
|
||||
{
|
||||
if (image.CoverType == MediaCoverTypes.Poster)
|
||||
{
|
||||
var filename = string.Format("Season{0:00}.jpg", season.SeasonNumber);
|
||||
|
||||
if (season.SeasonNumber == 0)
|
||||
{
|
||||
filename = "Season00.jpg";
|
||||
}
|
||||
|
||||
yield return new ImageFileResult(filename, image.RemoteUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetEpisodeImageFilename(Series series, EpisodeFile episodeFile)
|
||||
{
|
||||
var filename = string.Format("S{0:00}E{1:00}.jpg", episodeFile.SeasonNumber, episodeFile.Episodes.Value.FirstOrDefault()?.EpisodeNumber);
|
||||
return Path.Combine(series.Path, filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Annotations;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
using NzbDrone.Core.Validation;
|
||||
|
||||
namespace NzbDrone.Core.Extras.Metadata.Consumers.Kometa
|
||||
{
|
||||
public class KometaSettingsValidator : AbstractValidator<KometaMetadataSettings>
|
||||
{
|
||||
}
|
||||
|
||||
public class KometaMetadataSettings : IProviderConfig
|
||||
{
|
||||
private static readonly KometaSettingsValidator Validator = new KometaSettingsValidator();
|
||||
|
||||
public KometaMetadataSettings()
|
||||
{
|
||||
SeriesImages = true;
|
||||
SeasonImages = true;
|
||||
EpisodeImages = true;
|
||||
}
|
||||
|
||||
[FieldDefinition(0, Label = "MetadataSettingsSeriesImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Poster.jpg")]
|
||||
public bool SeriesImages { get; set; }
|
||||
|
||||
[FieldDefinition(1, Label = "MetadataSettingsSeasonImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "Season##.jpg")]
|
||||
public bool SeasonImages { get; set; }
|
||||
|
||||
[FieldDefinition(2, Label = "MetadataSettingsEpisodeImages", Type = FieldType.Checkbox, Section = MetadataSectionType.Image, HelpText = "S##E##.jpg")]
|
||||
public bool EpisodeImages { get; set; }
|
||||
|
||||
public bool IsValid => true;
|
||||
|
||||
public NzbDroneValidationResult Validate()
|
||||
{
|
||||
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,12 @@ namespace NzbDrone.Core.Indexers
|
||||
List<IIndexer> RssEnabled(bool filterBlockedIndexers = true);
|
||||
List<IIndexer> AutomaticSearchEnabled(bool filterBlockedIndexers = true);
|
||||
List<IIndexer> InteractiveSearchEnabled(bool filterBlockedIndexers = true);
|
||||
IndexerDefinition FindByName(string name);
|
||||
}
|
||||
|
||||
public class IndexerFactory : ProviderFactory<IIndexer, IndexerDefinition>, IIndexerFactory
|
||||
{
|
||||
private readonly IIndexerRepository _indexerRepository;
|
||||
private readonly IIndexerStatusService _indexerStatusService;
|
||||
private readonly Logger _logger;
|
||||
|
||||
@@ -28,6 +30,7 @@ namespace NzbDrone.Core.Indexers
|
||||
Logger logger)
|
||||
: base(providerRepository, providers, container, eventAggregator, logger)
|
||||
{
|
||||
_indexerRepository = providerRepository;
|
||||
_indexerStatusService = indexerStatusService;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -82,6 +85,11 @@ namespace NzbDrone.Core.Indexers
|
||||
return enabledIndexers.ToList();
|
||||
}
|
||||
|
||||
public IndexerDefinition FindByName(string name)
|
||||
{
|
||||
return _indexerRepository.FindByName(name);
|
||||
}
|
||||
|
||||
private IEnumerable<IIndexer> FilterBlockedIndexers(IEnumerable<IIndexer> indexers)
|
||||
{
|
||||
var blockedIndexers = _indexerStatusService.GetBlockedProviders().ToDictionary(v => v.ProviderId, v => v);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using NzbDrone.Core.Datastore;
|
||||
using System.Linq;
|
||||
using NzbDrone.Core.Datastore;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
|
||||
@@ -6,6 +7,7 @@ namespace NzbDrone.Core.Indexers
|
||||
{
|
||||
public interface IIndexerRepository : IProviderRepository<IndexerDefinition>
|
||||
{
|
||||
IndexerDefinition FindByName(string name);
|
||||
}
|
||||
|
||||
public class IndexerRepository : ProviderRepository<IndexerDefinition>, IIndexerRepository
|
||||
@@ -14,5 +16,10 @@ namespace NzbDrone.Core.Indexers
|
||||
: base(database, eventAggregator)
|
||||
{
|
||||
}
|
||||
|
||||
public IndexerDefinition FindByName(string name)
|
||||
{
|
||||
return Query(i => i.Name == name).SingleOrDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1097,6 +1097,8 @@
|
||||
"LogLevel": "Log Level",
|
||||
"LogLevelTraceHelpTextWarning": "Trace logging should only be enabled temporarily",
|
||||
"LogOnly": "Log Only",
|
||||
"LogSizeLimit": "Log Size Limit",
|
||||
"LogSizeLimitHelpText": "Maximum log file size in MB before archiving. Default is 1MB.",
|
||||
"Logging": "Logging",
|
||||
"Logout": "Logout",
|
||||
"Logs": "Logs",
|
||||
|
||||
@@ -1401,7 +1401,7 @@
|
||||
"NotificationsEmailSettingsBccAddressHelpText": "Lista separada por coma de destinatarios de e-mail bcc",
|
||||
"NotificationsEmailSettingsName": "E-mail",
|
||||
"NotificationsEmailSettingsRecipientAddress": "Dirección(es) de destinatario",
|
||||
"NotificationsEmbySettingsSendNotificationsHelpText": "Hace que Emby envíe notificaciones a los proveedores configurados. No soportado en Jellyfin.",
|
||||
"NotificationsEmbySettingsSendNotificationsHelpText": "Hacer que Emby envíe notificaciones a los proveedores configurados. No soportado en Jellyfin.",
|
||||
"NotificationsGotifySettingsAppToken": "Token de app",
|
||||
"NotificationsGotifySettingIncludeSeriesPosterHelpText": "Incluye poster de serie en mensaje",
|
||||
"NotificationsJoinSettingsDeviceNames": "Nombres de dispositivo",
|
||||
@@ -1839,7 +1839,7 @@
|
||||
"Titles": "Títulos",
|
||||
"ToggleUnmonitoredToMonitored": "Sin monitorizar, haz clic para monitorizar",
|
||||
"TotalFileSize": "Tamaño total de archivo",
|
||||
"UpdateAvailableHealthCheckMessage": "Hay disponible una nueva actualización: {version}",
|
||||
"UpdateAvailableHealthCheckMessage": "Una nueva actualización está disponible: {version}",
|
||||
"UpgradeUntilCustomFormatScore": "Actualizar hasta la puntuación de formato personalizado",
|
||||
"UrlBase": "URL base",
|
||||
"UseSsl": "Usar SSL",
|
||||
@@ -1919,7 +1919,7 @@
|
||||
"NotificationsDiscordSettingsWebhookUrlHelpText": "URL de canal webhook de Discord",
|
||||
"NotificationsEmailSettingsCcAddress": "Dirección(es) CC",
|
||||
"NotificationsEmbySettingsSendNotifications": "Enviar notificaciones",
|
||||
"NotificationsEmbySettingsUpdateLibraryHelpText": "Actualiza biblioteca al importar, renombrar o borrar",
|
||||
"NotificationsEmbySettingsUpdateLibraryHelpText": "Actualiza la biblioteca al importar, renombrar o borrar",
|
||||
"NotificationsJoinSettingsDeviceIdsHelpText": "En desuso, usar Nombres de dispositivo en su lugar. Lista separada por coma de los IDs de dispositivo a los que te gustaría enviar notificaciones. Si no se establece, todos los dispositivos recibirán notificaciones.",
|
||||
"NotificationsPushoverSettingsExpire": "Caduca",
|
||||
"NotificationsMailgunSettingsSenderDomain": "Dominio del remitente",
|
||||
@@ -2100,5 +2100,7 @@
|
||||
"NoBlocklistItems": "Ningún elemento en la lista de bloqueo",
|
||||
"SeasonsMonitoredPartial": "Parcial",
|
||||
"NotificationsTelegramSettingsMetadataLinksHelpText": "Añade un enlace a los metadatos de la serie cuando se envían notificaciones",
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Enlaces de metadatos"
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Enlaces de metadatos",
|
||||
"DeleteSelected": "Borrar seleccionados",
|
||||
"DeleteSelectedImportListExclusionsMessageText": "¿Estás seguro que quieres borrar las exclusiones de listas de importación seleccionadas?"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"AddAutoTagError": "Nie można dodać nowego tagu automatycznego, spróbuj ponownie.",
|
||||
"AddConditionError": "Nie można dodać nowego warunku, spróbuj ponownie.",
|
||||
"AddConnection": "Dodaj połączenie",
|
||||
"AddCustomFilter": "Dodaj spersonalizowany filtr",
|
||||
"AddCustomFilter": "Dodaj niestandardowy filtr",
|
||||
"Close": "Zamknij",
|
||||
"AddDelayProfile": "Dodaj profil opóźnienia",
|
||||
"AddDownloadClient": "Dodaj klienta pobierania",
|
||||
@@ -42,5 +42,39 @@
|
||||
"AddNewSeriesError": "Nie udało się załadować wyników wyszukiwania, spróbuj ponownie.",
|
||||
"AddConditionImplementation": "Dodaj condition - {implementationName}",
|
||||
"AddConnectionImplementation": "Dodaj Connection - {implementationName}",
|
||||
"AddDownloadClientImplementation": "Dodaj klienta pobierania - {implementationName}"
|
||||
"AddDownloadClientImplementation": "Dodaj klienta pobierania - {implementationName}",
|
||||
"AbsoluteEpisodeNumber": "Absolutny Numer Odcinka",
|
||||
"AddImportList": "Dodaj listę importu",
|
||||
"AddQualityProfileError": "Nie udało się dodać nowego profilu jakości, spróbuj później.",
|
||||
"AddReleaseProfile": "Dodaj Profil Wydania",
|
||||
"AddRemotePathMapping": "Dodaj mapowanie ścieżek zdalnych",
|
||||
"AuthenticationMethod": "Metoda Autoryzacji",
|
||||
"AuthenticationMethodHelpTextWarning": "Wybierz prawidłową metodę autoryzacji",
|
||||
"CutoffUnmet": "Odcięcie niespełnione",
|
||||
"AgeWhenGrabbed": "Wiek (przy złapaniu)",
|
||||
"AppDataDirectory": "Katalog AppData",
|
||||
"BindAddressHelpText": "Prawidłowy adres IP, localhost lub '*' dla wszystkich interfejsów",
|
||||
"RemotePathMappingBadDockerPathHealthCheckMessage": "Używasz Dockera; klient pobierania {downloadClientName} umieszcza pobrane pliki w {path}, lecz nie jest to poprawna ścieżka {osName}. Sprawdź mapowanie ścieżek zdalnych i ustawienia klienta pobierania.",
|
||||
"YesterdayAt": "Wczoraj o {time}",
|
||||
"UpdateMechanismHelpText": "Użyj wbudowanego aktualizatora {appName} lub skryptu",
|
||||
"AuthenticationRequired": "Wymagana Autoryzacja",
|
||||
"AudioLanguages": "Języki Dźwięku",
|
||||
"RemoveFromDownloadClient": "Usuń z Klienta Pobierania",
|
||||
"AddANewPath": "Dodaj nową ścieżkę",
|
||||
"Absolute": "Absolutny",
|
||||
"AddImportListImplementation": "Dodaj Listę Importu - {implementationName}",
|
||||
"AddNotificationError": "Nie udało się dodać nowego powiadomienia, spróbuj później.",
|
||||
"AddNewSeriesSearchForMissingEpisodes": "Zacznij szukać brakujących odcinków",
|
||||
"AddQualityProfile": "Dodaj profil jakości",
|
||||
"AppUpdated": "{appName} Zaktualizowany",
|
||||
"CalendarOptions": "Opcje kalendarza",
|
||||
"AddNewSeries": "Dodaj nowy serial",
|
||||
"DeleteIndexerMessageText": "Czy na pewno chcesz usunąć indeksator „{name}”?",
|
||||
"DeleteBackupMessageText": "Czy na pewno chcesz usunąć kopię zapasową „{name}”?",
|
||||
"DeleteDownloadClientMessageText": "Czy na pewno chcesz usunąć klienta pobierania „{name}”?",
|
||||
"AddDelayProfileError": "Nie można dodać nowego profilu opóźnienia, spróbuj później.",
|
||||
"AddIndexerImplementation": "Dodaj indeks - {implementationName}",
|
||||
"AddNewSeriesHelpText": "Latwo dodać nowy serial, po prostu zacznij pisać nazwę serialu który chcesz dodać.",
|
||||
"Any": "Dowolny",
|
||||
"StartupDirectory": "Katalog Startowy"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"SizeOnDisk": "Tamanho no disco",
|
||||
"SystemTimeHealthCheckMessage": "A hora do sistema está desligada por mais de 1 dia. Tarefas agendadas podem não ser executadas corretamente até que o horário seja corrigido",
|
||||
"Unmonitored": "Não monitorado",
|
||||
"UpdateAvailableHealthCheckMessage": "Nova atualização disponível: {version}",
|
||||
"UpdateAvailableHealthCheckMessage": "Nova atualização está disponível: {version}",
|
||||
"Added": "Adicionado",
|
||||
"ApiKeyValidationHealthCheckMessage": "Atualize sua chave de API para ter pelo menos {length} caracteres. Você pode fazer isso através das configurações ou do arquivo de configuração",
|
||||
"RemoveCompletedDownloads": "Remover downloads concluídos",
|
||||
@@ -2100,5 +2100,7 @@
|
||||
"SeasonsMonitoredStatus": "Temporadas monitoradas",
|
||||
"NoBlocklistItems": "Sem itens na lista de bloqueio",
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Links de Metadados",
|
||||
"NotificationsTelegramSettingsMetadataLinksHelpText": "Adicione links aos metadados da série ao enviar notificações"
|
||||
"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?"
|
||||
}
|
||||
|
||||
@@ -200,5 +200,6 @@
|
||||
"WeekColumnHeader": "Antetul coloanei săptămânii",
|
||||
"TimeFormat": "Format ora",
|
||||
"CustomFilter": "Filtru personalizat",
|
||||
"CustomFilters": "Filtre personalizate"
|
||||
"CustomFilters": "Filtre personalizate",
|
||||
"UpdateAvailableHealthCheckMessage": "O nouă versiune este disponibilă: {version}"
|
||||
}
|
||||
|
||||
@@ -456,8 +456,8 @@
|
||||
"Custom": "自定义",
|
||||
"CreateGroup": "创建组",
|
||||
"CustomFilters": "自定义过滤器",
|
||||
"CustomFormatUnknownCondition": "未知自定义格式条件 '{implementation}'",
|
||||
"CustomFormatUnknownConditionOption": "未知的条件“{key}”的选项“{implementation}”",
|
||||
"CustomFormatUnknownCondition": "未知自定义格式条件'{0}'",
|
||||
"CustomFormatUnknownConditionOption": "未知的条件“{1}”的选项“{0}”",
|
||||
"CustomFormatsLoadError": "无法加载自定义格式",
|
||||
"CustomFormatsSettings": "自定义格式设置",
|
||||
"CustomFormatsSettingsSummary": "自定义格式和设置",
|
||||
@@ -1198,7 +1198,7 @@
|
||||
"UseSeasonFolder": "使用季文件夹",
|
||||
"UseProxy": "使用代理",
|
||||
"Username": "用户名",
|
||||
"UsenetDelayTime": "Usenet延时:{usenetDelay}",
|
||||
"UsenetDelayTime": "Usenet延时:{0}",
|
||||
"UsenetDisabled": "Usenet已关闭",
|
||||
"UtcAirDate": "UTC 播出日期",
|
||||
"VersionNumber": "版本 {version}",
|
||||
|
||||
@@ -77,7 +77,7 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
break;
|
||||
case DiscordGrabFieldType.Rating:
|
||||
discordField.Name = "Rating";
|
||||
discordField.Value = episodes.First().Ratings.Value.ToString();
|
||||
discordField.Value = series.Ratings.Value.ToString(CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DiscordGrabFieldType.Genres:
|
||||
discordField.Name = "Genres";
|
||||
@@ -180,7 +180,7 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
break;
|
||||
case DiscordImportFieldType.Rating:
|
||||
discordField.Name = "Rating";
|
||||
discordField.Value = episodes.First().Ratings.Value.ToString();
|
||||
discordField.Value = series.Ratings.Value.ToString(CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DiscordImportFieldType.Genres:
|
||||
discordField.Name = "Genres";
|
||||
@@ -224,6 +224,14 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
discordField.Name = "Links";
|
||||
discordField.Value = GetLinksString(series);
|
||||
break;
|
||||
case DiscordImportFieldType.CustomFormats:
|
||||
discordField.Name = "Custom Formats";
|
||||
discordField.Value = string.Join("|", message.EpisodeInfo.CustomFormats);
|
||||
break;
|
||||
case DiscordImportFieldType.CustomFormatScore:
|
||||
discordField.Name = "Custom Format Score";
|
||||
discordField.Value = message.EpisodeInfo.CustomFormatScore.ToString();
|
||||
break;
|
||||
}
|
||||
|
||||
if (discordField.Name.IsNotNullOrWhiteSpace() && discordField.Value.IsNotNullOrWhiteSpace())
|
||||
@@ -286,7 +294,7 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
break;
|
||||
case DiscordImportFieldType.Rating:
|
||||
discordField.Name = "Rating";
|
||||
discordField.Value = episodes.First().Ratings.Value.ToString();
|
||||
discordField.Value = series.Ratings.Value.ToString(CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DiscordImportFieldType.Genres:
|
||||
discordField.Name = "Genres";
|
||||
@@ -571,7 +579,7 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
break;
|
||||
case DiscordManualInteractionFieldType.Rating:
|
||||
discordField.Name = "Rating";
|
||||
discordField.Value = episodes.FirstOrDefault()?.Ratings?.Value.ToString(CultureInfo.InvariantCulture);
|
||||
discordField.Value = series?.Ratings?.Value.ToString(CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case DiscordManualInteractionFieldType.Genres:
|
||||
discordField.Name = "Genres";
|
||||
|
||||
@@ -31,7 +31,9 @@ namespace NzbDrone.Core.Notifications.Discord
|
||||
Links,
|
||||
Release,
|
||||
Poster,
|
||||
Fanart
|
||||
Fanart,
|
||||
CustomFormats,
|
||||
CustomFormatScore
|
||||
}
|
||||
|
||||
public enum DiscordManualInteractionFieldType
|
||||
|
||||
@@ -342,6 +342,10 @@ namespace NzbDrone.Core.Parser
|
||||
new Regex(@"^(?<title>.+?)[-_. ]+?(?:S|Season|Saison|Series|Stagione)[-_. ]?(?<season>\d{4}(?![-_. ]?\d+))(\W+|_|$)(?<extras>EXTRAS|SUBPACK)?(?!\\)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Spanish tracker releases
|
||||
new Regex(@"^(?<title>.+?)(?:(?:[-_. ]+?Temporada.+?|\[.+?\])\[Cap)(?:[-_. ]+(?<season>(?<!\d+)\d{1,2})(?<episode>(?<!e|x)(?:[1-9][0-9]|[0][1-9])))+(?:\])",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Supports 103/113 naming
|
||||
new Regex(@"^(?<title>.+?)?(?:(?:[_.-](?<![()\[!]))+(?<season>(?<!\d+)[1-9])(?<episode>[1-9][0-9]|[0][1-9])(?![a-z]|\d+))+(?:[_.]|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
@@ -399,10 +403,6 @@ namespace NzbDrone.Core.Parser
|
||||
new Regex(@"^(?:(?<season>(?<!\d+)(?:\d{1,2})(?!\d+))(?:-(?<episode>\d{2,3}(?!\d+))))",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Spanish tracker releases
|
||||
new Regex(@"^(?<title>.+?)(?:(?:[-_. ]+?Temporada.+?|\[.+?\])\[Cap[-_.])(?<season>(?<!\d+)\d{1,2})(?<episode>(?<!e|x)(?:[1-9][0-9]|[0][1-9]))(?:\])",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
// Anime Range - Title Absolute Episode Number (ep01-12)
|
||||
new Regex(@"^(?:\[(?<subgroup>.+?)\][-_. ]?)?(?<title>.+?)(?:_|\s|\.)+(?:e|ep)(?<absoluteepisode>\d{2,3}(\.\d{1,2})?)-(?<absoluteepisode>(?<!\d+)\d{1,2}(\.\d{1,2})?(?!\d+|-)).*?(?<hash>[(\[]\w{8}[)\]])?$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled),
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace NzbDrone.Core.ThingiProvider.Status
|
||||
where TModel : ProviderStatusBase, new()
|
||||
{
|
||||
TModel FindByProviderId(int providerId);
|
||||
void DeleteByProviderId(int providerId);
|
||||
}
|
||||
|
||||
public class ProviderStatusRepository<TModel> : BasicRepository<TModel>, IProviderStatusRepository<TModel>
|
||||
@@ -22,5 +23,10 @@ namespace NzbDrone.Core.ThingiProvider.Status
|
||||
{
|
||||
return Query(c => c.ProviderId == providerId).SingleOrDefault();
|
||||
}
|
||||
|
||||
public void DeleteByProviderId(int providerId)
|
||||
{
|
||||
Delete(c => c.ProviderId == providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,12 +151,7 @@ namespace NzbDrone.Core.ThingiProvider.Status
|
||||
|
||||
public virtual void HandleAsync(ProviderDeletedEvent<TProvider> message)
|
||||
{
|
||||
var providerStatus = _providerStatusRepository.FindByProviderId(message.ProviderId);
|
||||
|
||||
if (providerStatus != null)
|
||||
{
|
||||
_providerStatusRepository.Delete(providerStatus);
|
||||
}
|
||||
_providerStatusRepository.DeleteByProviderId(message.ProviderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ namespace Sonarr.Api.V3.Config
|
||||
.Must((resource, path) => IsValidSslCertificate(resource)).WithMessage("Invalid SSL certificate file or password")
|
||||
.When(c => c.EnableSsl);
|
||||
|
||||
SharedValidator.RuleFor(c => c.LogSizeLimit).InclusiveBetween(1, 10);
|
||||
|
||||
SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'main' is the default");
|
||||
SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace Sonarr.Api.V3.Config
|
||||
public string Password { get; set; }
|
||||
public string PasswordConfirmation { get; set; }
|
||||
public string LogLevel { get; set; }
|
||||
public int LogSizeLimit { get; set; }
|
||||
public string ConsoleLogLevel { get; set; }
|
||||
public string Branch { get; set; }
|
||||
public string ApiKey { get; set; }
|
||||
@@ -65,6 +66,7 @@ namespace Sonarr.Api.V3.Config
|
||||
// Username
|
||||
// Password
|
||||
LogLevel = model.LogLevel,
|
||||
LogSizeLimit = model.LogSizeLimit,
|
||||
ConsoleLogLevel = model.ConsoleLogLevel,
|
||||
Branch = model.Branch,
|
||||
ApiKey = model.ApiKey,
|
||||
|
||||
@@ -3301,6 +3301,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/importlistexclusion/bulk": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"ImportListExclusion"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ImportListExclusionBulkResource"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ImportListExclusionBulkResource"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ImportListExclusionBulkResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v3/indexer": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -8827,6 +8858,10 @@
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"logSizeLimit": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"consoleLogLevel": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
@@ -9013,6 +9048,21 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ImportListExclusionBulkResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ids": {
|
||||
"uniqueItems": true,
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ImportListExclusionResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace Sonarr.Http.Authentication
|
||||
|
||||
await HttpContext.SignInAsync(AuthenticationType.Forms.ToString(), new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties);
|
||||
|
||||
if (returnUrl.IsNullOrWhiteSpace())
|
||||
if (returnUrl.IsNullOrWhiteSpace() || !Url.IsLocalUrl(returnUrl))
|
||||
{
|
||||
return Redirect(_configFileProvider.UrlBase + "/");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
@@ -6,29 +6,22 @@ using NzbDrone.Core.Configuration;
|
||||
|
||||
namespace Sonarr.Http.Frontend.Mappers
|
||||
{
|
||||
public class BrowserConfig : StaticResourceMapperBase
|
||||
public class BrowserConfig : UrlBaseReplacementResourceMapperBase
|
||||
{
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
|
||||
public BrowserConfig(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
|
||||
: base(diskProvider, logger)
|
||||
: base(diskProvider, configFileProvider, logger)
|
||||
{
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_configFileProvider = configFileProvider;
|
||||
FilePath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "Content", "browserconfig.xml");
|
||||
}
|
||||
|
||||
public override string Map(string resourceUrl)
|
||||
{
|
||||
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
|
||||
path = path.Trim(Path.DirectorySeparatorChar);
|
||||
|
||||
return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "xml");
|
||||
return FilePath;
|
||||
}
|
||||
|
||||
public override bool CanHandle(string resourceUrl)
|
||||
{
|
||||
return resourceUrl.StartsWith("/content/images/icons/browserconfig");
|
||||
return resourceUrl.StartsWith("/Content/browserconfig");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
@@ -6,29 +6,22 @@ using NzbDrone.Core.Configuration;
|
||||
|
||||
namespace Sonarr.Http.Frontend.Mappers
|
||||
{
|
||||
public class ManifestMapper : StaticResourceMapperBase
|
||||
public class ManifestMapper : UrlBaseReplacementResourceMapperBase
|
||||
{
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
|
||||
public ManifestMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
|
||||
: base(diskProvider, logger)
|
||||
: base(diskProvider, configFileProvider, logger)
|
||||
{
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_configFileProvider = configFileProvider;
|
||||
FilePath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "Content", "manifest.json");
|
||||
}
|
||||
|
||||
public override string Map(string resourceUrl)
|
||||
{
|
||||
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
|
||||
path = path.Trim(Path.DirectorySeparatorChar);
|
||||
|
||||
return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "json");
|
||||
return FilePath;
|
||||
}
|
||||
|
||||
public override bool CanHandle(string resourceUrl)
|
||||
{
|
||||
return resourceUrl.StartsWith("/Content/Images/Icons/manifest");
|
||||
return resourceUrl.StartsWith("/Content/manifest");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ namespace Sonarr.Http.Frontend.Mappers
|
||||
{
|
||||
resourceUrl = resourceUrl.ToLowerInvariant();
|
||||
|
||||
if (resourceUrl.StartsWith("/content/images/icons/manifest") ||
|
||||
resourceUrl.StartsWith("/content/images/icons/browserconfig"))
|
||||
if (resourceUrl.StartsWith("/content/manifest") ||
|
||||
resourceUrl.StartsWith("/content/browserconfig"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.IO;
|
||||
using NLog;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Core.Configuration;
|
||||
|
||||
namespace Sonarr.Http.Frontend.Mappers
|
||||
{
|
||||
public abstract class UrlBaseReplacementResourceMapperBase : StaticResourceMapperBase
|
||||
{
|
||||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly string _urlBase;
|
||||
|
||||
private string _generatedContent;
|
||||
|
||||
public UrlBaseReplacementResourceMapperBase(IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
|
||||
: base(diskProvider, logger)
|
||||
{
|
||||
_diskProvider = diskProvider;
|
||||
_urlBase = configFileProvider.UrlBase;
|
||||
}
|
||||
|
||||
protected string FilePath;
|
||||
|
||||
public override string Map(string resourceUrl)
|
||||
{
|
||||
return FilePath;
|
||||
}
|
||||
|
||||
protected override Stream GetContentStream(string filePath)
|
||||
{
|
||||
var text = GetFileText();
|
||||
|
||||
var stream = new MemoryStream();
|
||||
var writer = new StreamWriter(stream);
|
||||
writer.Write(text);
|
||||
writer.Flush();
|
||||
stream.Position = 0;
|
||||
return stream;
|
||||
}
|
||||
|
||||
protected virtual string GetFileText()
|
||||
{
|
||||
if (RuntimeInfo.IsProduction && _generatedContent != null)
|
||||
{
|
||||
return _generatedContent;
|
||||
}
|
||||
|
||||
var text = _diskProvider.ReadAllText(FilePath);
|
||||
|
||||
text = text.Replace("__URL_BASE__", _urlBase);
|
||||
|
||||
_generatedContent = text;
|
||||
|
||||
return _generatedContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user