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

Compare commits

..

30 Commits

Author SHA1 Message Date
Treycos
7dca9060ca Convert SeriesTitleLink to TypeScript 2024-08-18 19:01:32 -07:00
kephasdev
8af12cc4e7 Fixed: Calculating Custom Formats with languages in queue 2024-08-18 19:00:55 -07:00
Bogdan
aa488019cf Bump babel packages 2024-08-18 19:00:01 -07:00
Bogdan
47a05ecb36 Use autoprefixer in UI build 2024-08-18 19:00:01 -07:00
martylukyy
35baebaf72 New: Configure log file size limit in UI 2024-08-18 18:59:43 -07:00
Mark McDowall
aedcd046fc Fixed: PWA Manifest with URL base
Closes #7107
2024-08-18 18:58:29 -07:00
Bogdan
f45713bff8 Remove provider status on provider deletion 2024-08-18 18:58:10 -07:00
Mark McDowall
911a3d4c1e New: Parse spanish multi-episode releases 2024-08-18 18:57:25 -07:00
Mark McDowall
e16ace54a8 New: Optionally include Custom Format Score for Discord On File Import notifications 2024-08-18 18:57:17 -07:00
Stevie Robinson
84710a31bd New: Track Kometa metadata files
Closes #6851
2024-08-18 18:57:04 -07:00
Weblate
093a239e77 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Fixer <ygj59783@zslsz.com>
Co-authored-by: Gabriel Markowski <gmarkowski62@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: YangForever88 <1026097197@qq.com>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/ro/
Translate-URL: https://translate.servarr.com/projects/servarr/sonarr/zh_CN/
Translation: Servarr/Sonarr
2024-08-18 18:56:50 -07:00
Bogdan
ee69351733 Fixed: Switch to series rating for Discord notifications 2024-08-18 18:55:40 -07:00
Bogdan
e92a67ad78 New: Show indicator on poster for deleted series 2024-08-18 18:55:26 -07:00
Treycos
3eca63a67c Convert Label to TypeScript 2024-08-18 18:54:30 -07:00
Treycos
8484a8beba Convert First Run to TypeScript 2024-08-18 18:52:04 -07:00
Sonarr
cd3a1c18ab Automated API Docs update
ignore-downstream
2024-08-14 20:24:03 -07:00
Bogdan
dc7a16a03a Sort quality profiles by name in custom filters 2024-08-14 20:23:44 -07:00
Bogdan
84338f4c50 Fixed: Stale formats score after changing quality profile for series 2024-08-14 20:23:31 -07:00
Mark McDowall
12ac123d5a Fixed: Prefer episode runtime when determining whether a file is a sample
Closes #7086
2024-08-14 20:22:50 -07:00
Mark McDowall
ef829c6ace New: Parse DarQ release group
Closes #7083
2024-08-14 23:22:37 -04:00
Bogdan
592b6f7f7c Fixed: Persist selected custom filter for interactive searches 2024-08-14 20:22:22 -07:00
Bogdan
be5b449de4 Fixed: Don't display multiple languages if no languages were parsed 2024-08-14 23:22:05 -04:00
Bogdan
9b144e9ade New: Increase max size limit for quality definitions
Closes #7084
2024-08-14 23:20:58 -04:00
Bogdan
9af2f137f4 Skip duplicate import list exclusions 2024-08-14 23:20:25 -04:00
Sonarr
d4bd7865f6 Automated API Docs update
ignore-downstream
2024-08-14 20:19:39 -07:00
Mark McDowall
cf921480ec New: Support for releases with absolute episode number and air date 2024-08-14 20:19:31 -07:00
Bogdan
639b53887d New: Bulk import list exclusions removal 2024-08-14 23:19:12 -04:00
Bogdan
3b29096e40 Fix wiki link for update healthcheck 2024-08-14 20:18:48 -07:00
Bogdan
2d237ae6b7 Cleanup old prop-types for TS 2024-08-14 20:18:48 -07:00
Bogdan
d713b83a36 Fixed: Sending Manual Interaction Required notifications for unknown series
For Discord/Webhooks/CustomScript
2024-08-14 20:18:39 -07:00
114 changed files with 2681 additions and 1611 deletions

View File

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

View File

@@ -16,6 +16,7 @@ const mixinsFiles = [
module.exports = {
plugins: [
'autoprefixer',
['postcss-mixins', {
mixinsFiles
}],

View File

@@ -51,7 +51,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
sourceTitle,
data,
downloadId,
isMarkingAsFailed,
isMarkingAsFailed = false,
shortDateFormat,
timeFormat,
onMarkAsFailedPress,
@@ -93,8 +93,4 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
);
}
HistoryDetailsModal.defaultProps = {
isMarkingAsFailed: false,
};
export default HistoryDetailsModal;

View File

@@ -15,6 +15,7 @@ import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
import useEpisode from 'Episode/useEpisode';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import { QualityModel } from 'Quality/Quality';
import SeriesTitleLink from 'Series/SeriesTitleLink';
import useSeries from 'Series/useSeries';
@@ -31,7 +32,7 @@ interface HistoryRowProps {
id: number;
episodeId: number;
seriesId: number;
languages: object[];
languages: Language[];
quality: QualityModel;
customFormats?: CustomFormat[];
customFormatScore: number;
@@ -61,7 +62,7 @@ function HistoryRow(props: HistoryRowProps) {
date,
data,
downloadId,
isMarkingAsFailed,
isMarkingAsFailed = false,
markAsFailedError,
columns,
} = props;
@@ -268,8 +269,4 @@ function HistoryRow(props: HistoryRowProps) {
);
}
HistoryRow.defaultProps = {
customFormats: [],
};
export default HistoryRow;

View File

@@ -155,10 +155,4 @@ function QueueStatus(props: QueueStatusProps) {
);
}
QueueStatus.defaultProps = {
trackedDownloadStatus: 'ok',
trackedDownloadState: 'downloading',
canFlip: false,
};
export default QueueStatus;

View File

@@ -1,5 +1,4 @@
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
import PropTypes from 'prop-types';
import React from 'react';
import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux';
@@ -20,7 +19,7 @@ function App({ store, history }: AppProps) {
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<AppRoutes app={App} />
<AppRoutes />
</PageConnector>
</ConnectedRouter>
</Provider>
@@ -28,9 +27,4 @@ function App({ store, history }: AppProps) {
);
}
App.propTypes = {
store: PropTypes.object.isRequired,
history: PropTypes.object.isRequired,
};
export default App;

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import React from 'react';
import { Redirect, Route } from 'react-router-dom';
import Blocklist from 'Activity/Blocklist/Blocklist';
@@ -165,8 +164,4 @@ function AppRoutes() {
);
}
AppRoutes.propTypes = {
app: PropTypes.func.isRequired,
};
export default AppRoutes;

View File

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

View File

@@ -8,15 +8,15 @@ import AppSectionState, { AppSectionItemState } from './AppSectionState';
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type UpdateAppState = AppSectionState<Update>;
export type TaskAppState = AppSectionState<Task>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
diskSpace: DiskSpaceAppState;
health: HealthAppState;
updates: UpdateAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updates: UpdateAppState;
}
export default SystemAppState;

View File

@@ -26,7 +26,7 @@ export interface CommandBody {
seriesId?: number;
seriesIds?: number[];
seasonNumber?: number;
[key: string]: string | number | boolean | undefined | number[] | undefined;
[key: string]: string | number | boolean | number[] | undefined;
}
interface Command extends ModelBase {

View File

@@ -12,7 +12,7 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
@@ -78,7 +78,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
return QualityFilterBuilderRowValueConnector;
case filterBuilderValueTypes.QUALITY_PROFILE:
return QualityProfileFilterBuilderRowValueConnector;
return QualityProfileFilterBuilderRowValue;
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
return SeasonsMonitoredStatusFilterBuilderRowValue;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
import sortByProp from 'Utilities/Array/sortByProp';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createQualityProfilesSelector() {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
(qualityProfiles) => {
return qualityProfiles;
}
);
}
function QualityProfileFilterBuilderRowValue(
props: FilterBuilderRowValueProps
) {
const qualityProfiles = useSelector(createQualityProfilesSelector());
const tagList = qualityProfiles
.map(({ id, name }) => ({ id, name }))
.sort(sortByProp('name'));
return <FilterBuilderRowValue {...props} tagList={tagList} />;
}
export default QualityProfileFilterBuilderRowValue;

View File

@@ -1,28 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import FilterBuilderRowValue from './FilterBuilderRowValue';
function createMapStateToProps() {
return createSelector(
(state) => state.settings.qualityProfiles,
(qualityProfiles) => {
const tagList = qualityProfiles.items.map((qualityProfile) => {
const {
id,
name
} = qualityProfile;
return {
id,
name
};
});
return {
tagList
};
}
);
}
export default connect(createMapStateToProps)(FilterBuilderRowValue);

View File

@@ -46,9 +46,9 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
const values = [...seriesTypeOptions];
const {
includeNoChange,
includeNoChange = false,
includeNoChangeDisabled = true,
includeMixed,
includeMixed = false,
} = props;
if (includeNoChange) {
@@ -77,9 +77,4 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
);
}
SeriesTypeSelectInput.defaultProps = {
includeNoChange: false,
includeMixed: false,
};
export default SeriesTypeSelectInput;

View File

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

View 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}
/>
);
}

View File

@@ -212,6 +212,8 @@ class SignalRConnector extends Component {
if (action === 'updated') {
this.props.dispatchUpdateItem({ section, ...body.resource });
repopulatePage('seriesUpdated');
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: body.resource.id });
}

View File

@@ -66,7 +66,9 @@ function Table(props) {
columns.map((column) => {
const {
name,
isVisible
isVisible,
isSortable,
...otherColumnProps
} = column;
if (!isVisible) {
@@ -84,6 +86,7 @@ function Table(props) {
name={name}
isSortable={false}
{...otherProps}
{...otherColumnProps}
>
<TableOptionsModalWrapper
columns={columns}

View File

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

View File

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

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

View File

@@ -0,0 +1,19 @@
{
"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": "__URL_BASE__/",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
}

View File

@@ -1,18 +1,21 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import Popover from 'Components/Tooltip/Popover';
import { kinds, tooltipPositions } from 'Helpers/Props';
import Language from 'Language/Language';
import translate from 'Utilities/String/translate';
function EpisodeLanguages(props) {
const {
className,
languages,
isCutoffNotMet
} = props;
interface EpisodeLanguagesProps {
className?: string;
languages: Language[];
isCutoffNotMet?: boolean;
}
if (!languages) {
function EpisodeLanguages(props: EpisodeLanguagesProps) {
const { className, languages, isCutoffNotMet = true } = props;
// TODO: Typescript - Remove once everything is converted
if (!languages || languages.length === 0) {
return null;
}
@@ -41,15 +44,9 @@ function EpisodeLanguages(props) {
title={translate('Languages')}
body={
<ul>
{
languages.map((language) => {
return (
<li key={language.id}>
{language.name}
</li>
);
})
}
{languages.map((language) => (
<li key={language.id}>{language.name}</li>
))}
</ul>
}
position={tooltipPositions.LEFT}
@@ -57,14 +54,4 @@ function EpisodeLanguages(props) {
);
}
EpisodeLanguages.propTypes = {
className: PropTypes.string,
languages: PropTypes.arrayOf(PropTypes.object),
isCutoffNotMet: PropTypes.bool
};
EpisodeLanguages.defaultProps = {
isCutoffNotMet: true
};
export default EpisodeLanguages;

View File

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

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

View File

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

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

View File

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

View File

@@ -19,5 +19,5 @@ export const all = [
PRIMARY,
PURPLE,
SUCCESS,
WARNING
];
WARNING,
] as const;

View File

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

View File

@@ -130,6 +130,7 @@
.sizeOnDisk,
.qualityProfileName,
.originalLanguageName,
.statusName,
.network,
.links,
.tags {

View File

@@ -24,6 +24,7 @@ interface CssExports {
'seriesNavigationButton': string;
'seriesNavigationButtons': string;
'sizeOnDisk': string;
'statusName': string;
'tags': string;
'title': string;
'titleContainer': string;

View File

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

View File

@@ -152,7 +152,7 @@ class SeriesDetailsConnector extends Component {
// Lifecycle
componentDidMount() {
registerPagePopulator(this.populate);
registerPagePopulator(this.populate, ['seriesUpdated']);
this.populate();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -157,6 +157,7 @@ class GeneralSettings extends Component {
/>
<LoggingSettings
advancedSettings={advancedSettings}
settings={settings}
onInputChange={onInputChange}
/>

View File

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

View File

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

View File

@@ -1,21 +1,28 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons, kinds } from 'Helpers/Props';
import { deleteImportListExclusion } from 'Store/Actions/Settings/importListExclusions';
import ImportListExclusion from 'typings/ImportListExclusion';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import EditImportListExclusionModal from './EditImportListExclusionModal';
import styles from './ImportListExclusionRow.css';
interface ImportListExclusionRowProps extends ImportListExclusion {
onConfirmDeleteImportListExclusion: (id: number) => void;
isSelected: boolean;
onSelectedChange: (options: SelectStateInputProps) => void;
}
function ImportListExclusionRow(props: ImportListExclusionRowProps) {
const { id, title, tvdbId, onConfirmDeleteImportListExclusion } = props;
const { id, tvdbId, title, isSelected, onSelectedChange } = props;
const dispatch = useDispatch();
const [
isEditImportListExclusionModalOpen,
@@ -29,12 +36,18 @@ function ImportListExclusionRow(props: ImportListExclusionRowProps) {
setDeleteImportListExclusionModalClosed,
] = useModalOpenState(false);
const onConfirmDeleteImportListExclusionPress = useCallback(() => {
onConfirmDeleteImportListExclusion(id);
}, [id, onConfirmDeleteImportListExclusion]);
const handleDeletePress = useCallback(() => {
dispatch(deleteImportListExclusion({ id }));
}, [id, dispatch]);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChange}
/>
<TableRowCell>{title}</TableRowCell>
<TableRowCell>{tvdbId}</TableRowCell>
@@ -58,7 +71,7 @@ function ImportListExclusionRow(props: ImportListExclusionRowProps) {
title={translate('DeleteImportListExclusion')}
message={translate('DeleteImportListExclusionMessageText')}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDeleteImportListExclusionPress}
onConfirm={handleDeletePress}
onCancel={setDeleteImportListExclusionModalClosed}
/>
</TableRow>

View File

@@ -0,0 +1,6 @@
.actions {
composes: headerCell from '~Components/Table/TableHeaderCell.css';
width: 35px;
white-space: nowrap;
}

View File

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

View File

@@ -1,28 +1,46 @@
import React, { useCallback, useEffect } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import FieldSet from 'Components/FieldSet';
import IconButton from 'Components/Link/IconButton';
import SpinnerButton from 'Components/Link/SpinnerButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import PageSectionContent from 'Components/Page/PageSectionContent';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import TablePager from 'Components/Table/TablePager';
import TableRow from 'Components/Table/TableRow';
import usePaging from 'Components/Table/usePaging';
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import * as importListExclusionActions from 'Store/Actions/Settings/importListExclusions';
import usePrevious from 'Helpers/Hooks/usePrevious';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { icons, kinds } from 'Helpers/Props';
import {
bulkDeleteImportListExclusions,
clearImportListExclusions,
fetchImportListExclusions,
gotoImportListExclusionPage,
setImportListExclusionSort,
setImportListExclusionTableOption,
} from 'Store/Actions/Settings/importListExclusions';
import { CheckInputChanged } from 'typings/inputs';
import { SelectStateInputProps } from 'typings/props';
import { TableOptionsChangePayload } from 'typings/Table';
import {
registerPagePopulator,
unregisterPagePopulator,
} from 'Utilities/pagePopulator';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import EditImportListExclusionModal from './EditImportListExclusionModal';
import ImportListExclusionRow from './ImportListExclusionRow';
import styles from './ImportListExclusions.css';
const COLUMNS = [
const COLUMNS: Column[] = [
{
name: 'title',
label: () => translate('Title'),
@@ -36,13 +54,15 @@ const COLUMNS = [
isSortable: true,
},
{
className: styles.actions,
name: 'actions',
label: '',
isVisible: true,
isSortable: false,
},
];
function createImportListExlucionsSelector() {
function createImportListExclusionsSelector() {
return createSelector(
(state: AppState) => state.settings.importListExclusions,
(importListExclusions) => {
@@ -54,95 +74,7 @@ function createImportListExlucionsSelector() {
}
function ImportListExclusions() {
const history = useHistory();
const useCurrentPage = history.action === 'POP';
const dispatch = useDispatch();
const fetchImportListExclusions = useCallback(() => {
dispatch(importListExclusionActions.fetchImportListExclusions());
}, [dispatch]);
const deleteImportListExclusion = useCallback(
(payload: { id: number }) => {
dispatch(importListExclusionActions.deleteImportListExclusion(payload));
},
[dispatch]
);
const gotoImportListExclusionFirstPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionFirstPage());
}, [dispatch]);
const gotoImportListExclusionPreviousPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionPreviousPage());
}, [dispatch]);
const gotoImportListExclusionNextPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionNextPage());
}, [dispatch]);
const gotoImportListExclusionLastPage = useCallback(() => {
dispatch(importListExclusionActions.gotoImportListExclusionLastPage());
}, [dispatch]);
const gotoImportListExclusionPage = useCallback(
(page: number) => {
dispatch(
importListExclusionActions.gotoImportListExclusionPage({ page })
);
},
[dispatch]
);
const setImportListExclusionSort = useCallback(
(sortKey: { sortKey: string }) => {
dispatch(
importListExclusionActions.setImportListExclusionSort({ sortKey })
);
},
[dispatch]
);
const setImportListTableOption = useCallback(
(payload: { pageSize: number }) => {
dispatch(
importListExclusionActions.setImportListExclusionTableOption(payload)
);
if (payload.pageSize) {
dispatch(importListExclusionActions.gotoImportListExclusionFirstPage());
}
},
[dispatch]
);
const repopulate = useCallback(() => {
gotoImportListExclusionFirstPage();
}, [gotoImportListExclusionFirstPage]);
useEffect(() => {
registerPagePopulator(repopulate);
if (useCurrentPage) {
fetchImportListExclusions();
} else {
gotoImportListExclusionFirstPage();
}
return () => unregisterPagePopulator(repopulate);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onConfirmDeleteImportListExclusion = useCallback(
(id: number) => {
deleteImportListExclusion({ id });
repopulate();
},
[deleteImportListExclusion, repopulate]
);
const selected = useSelector(createImportListExlucionsSelector());
const requestCurrentPage = useCurrentPage();
const {
isFetching,
@@ -152,9 +84,127 @@ function ImportListExclusions() {
sortKey,
error,
sortDirection,
page,
totalPages,
totalRecords,
...otherProps
} = selected;
isDeleting,
deleteError,
} = useSelector(createImportListExclusionsSelector());
const dispatch = useDispatch();
const [isConfirmDeleteModalOpen, setIsConfirmDeleteModalOpen] =
useState(false);
const previousIsDeleting = usePrevious(isDeleting);
const [selectState, setSelectState] = useSelectState();
const { allSelected, allUnselected, selectedState } = selectState;
const selectedIds = useMemo(() => {
return getSelectedIds(selectedState);
}, [selectedState]);
const handleSelectAllChange = useCallback(
({ value }: CheckInputChanged) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const handleSelectedChange = useCallback(
({ id, value, shiftKey = false }: SelectStateInputProps) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const handleDeleteSelectedPress = useCallback(() => {
setIsConfirmDeleteModalOpen(true);
}, [setIsConfirmDeleteModalOpen]);
const handleDeleteSelectedConfirmed = useCallback(() => {
dispatch(bulkDeleteImportListExclusions({ ids: selectedIds }));
setIsConfirmDeleteModalOpen(false);
}, [selectedIds, setIsConfirmDeleteModalOpen, dispatch]);
const handleConfirmDeleteModalClose = useCallback(() => {
setIsConfirmDeleteModalOpen(false);
}, [setIsConfirmDeleteModalOpen]);
const {
handleFirstPagePress,
handlePreviousPagePress,
handleNextPagePress,
handleLastPagePress,
handlePageSelect,
} = usePaging({
page,
totalPages,
gotoPage: gotoImportListExclusionPage,
});
const handleSortPress = useCallback(
(sortKey: { sortKey: string }) => {
dispatch(setImportListExclusionSort({ sortKey }));
},
[dispatch]
);
const handleTableOptionChange = useCallback(
(payload: TableOptionsChangePayload) => {
dispatch(setImportListExclusionTableOption(payload));
if (payload.pageSize) {
dispatch(gotoImportListExclusionPage({ page: 1 }));
}
},
[dispatch]
);
useEffect(() => {
if (requestCurrentPage) {
dispatch(fetchImportListExclusions());
} else {
dispatch(gotoImportListExclusionPage({ page: 1 }));
}
return () => {
dispatch(clearImportListExclusions());
};
}, [requestCurrentPage, dispatch]);
useEffect(() => {
const repopulate = () => {
dispatch(fetchImportListExclusions());
};
registerPagePopulator(repopulate);
return () => {
unregisterPagePopulator(repopulate);
};
}, [dispatch]);
useEffect(() => {
if (previousIsDeleting && !isDeleting && !deleteError) {
setSelectState({ type: 'unselectAll', items });
dispatch(fetchImportListExclusions());
}
}, [
previousIsDeleting,
isDeleting,
deleteError,
items,
dispatch,
setSelectState,
]);
const [
isAddImportListExclusionModalOpen,
@@ -173,13 +223,17 @@ function ImportListExclusions() {
error={error}
>
<Table
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
columns={COLUMNS}
canModifyColumns={false}
pageSize={pageSize}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={setImportListExclusionSort}
onTableOptionChange={setImportListTableOption}
onTableOptionChange={handleTableOptionChange}
onSelectAllChange={handleSelectAllChange}
onSortPress={handleSortPress}
>
<TableBody>
{items.map((item) => {
@@ -187,16 +241,23 @@ function ImportListExclusions() {
<ImportListExclusionRow
key={item.id}
{...item}
onConfirmDeleteImportListExclusion={
onConfirmDeleteImportListExclusion
}
isSelected={selectedState[item.id] || false}
onSelectedChange={handleSelectedChange}
/>
);
})}
<TableRow>
<TableRowCell />
<TableRowCell />
<TableRowCell colSpan={3}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedIds.length}
onPress={handleDeleteSelectedPress}
>
{translate('Delete')}
</SpinnerButton>
</TableRowCell>
<TableRowCell>
<IconButton
@@ -209,21 +270,31 @@ function ImportListExclusions() {
</Table>
<TablePager
page={page}
totalPages={totalPages}
totalRecords={totalRecords}
pageSize={pageSize}
isFetching={isFetching}
onFirstPagePress={gotoImportListExclusionFirstPage}
onPreviousPagePress={gotoImportListExclusionPreviousPage}
onNextPagePress={gotoImportListExclusionNextPage}
onLastPagePress={gotoImportListExclusionLastPage}
onPageSelect={gotoImportListExclusionPage}
{...otherProps}
onFirstPagePress={handleFirstPagePress}
onPreviousPagePress={handlePreviousPagePress}
onNextPagePress={handleNextPagePress}
onLastPagePress={handleLastPagePress}
onPageSelect={handlePageSelect}
/>
<EditImportListExclusionModal
isOpen={isAddImportListExclusionModalOpen}
onModalClose={setAddImportListExclusionModalClosed}
/>
<ConfirmModal
isOpen={isConfirmDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelected')}
message={translate('DeleteSelectedImportListExclusionsMessageText')}
confirmLabel={translate('DeleteSelected')}
onConfirm={handleDeleteSelectedConfirmed}
onCancel={handleConfirmDeleteModalClose}
/>
</PageSectionContent>
</FieldSet>
);

View File

@@ -7,7 +7,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import { icons } from 'Helpers/Props';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate';
import ImportListsExclusions from './ImportListExclusions/ImportListExclusions';
import ImportListExclusions from './ImportListExclusions/ImportListExclusions';
import ImportListsConnector from './ImportLists/ImportListsConnector';
import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal';
import ImportListOptions from './Options/ImportListOptions';
@@ -113,7 +113,8 @@ class ImportListSettings extends Component {
onChildStateChange={this.onChildStateChange}
/>
<ImportListsExclusions />
<ImportListExclusions />
<ManageImportListsModal
isOpen={isManageImportListsOpen}
onModalClose={this.onManageImportListsModalClose}

View File

@@ -21,7 +21,7 @@ const mapDispatchToProps = {
fetchRootFolders
};
class ListsConnector extends Component {
class ImportListsConnector extends Component {
//
// Lifecycle
@@ -51,10 +51,10 @@ class ListsConnector extends Component {
}
}
ListsConnector.propTypes = {
ImportListsConnector.propTypes = {
fetchImportLists: PropTypes.func.isRequired,
deleteImportList: PropTypes.func.isRequired,
fetchRootFolders: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(ListsConnector);
export default connect(createMapStateToProps, mapDispatchToProps)(ImportListsConnector);

View File

@@ -48,7 +48,6 @@ interface ImportListOptionsPageProps {
function ImportListOptions(props: ImportListOptionsPageProps) {
const { setChildSave, onChildStateChange } = props;
const selected = useSelector(createImportListOptionsSelector());
const {
isSaving,
@@ -58,7 +57,7 @@ function ImportListOptions(props: ImportListOptionsPageProps) {
error,
settings,
hasSettings,
} = selected;
} = useSelector(createImportListOptionsSelector());
const { listSyncLevel, listSyncTag } = settings;

View File

@@ -13,7 +13,7 @@ import QualityDefinitionLimits from './QualityDefinitionLimits';
import styles from './QualityDefinition.css';
const MIN = 0;
const MAX = 400;
const MAX = 1000;
const MIN_DISTANCE = 1;
const slider = {

View File

@@ -1,7 +1,9 @@
import { createAction } from 'redux-actions';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createServerSideCollectionHandlers from 'Store/Actions/Creators/createServerSideCollectionHandlers';
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import createSetTableOptionReducer from 'Store/Actions/Creators/Reducers/createSetTableOptionReducer';
import { createThunk, handleThunks } from 'Store/thunks';
@@ -16,29 +18,26 @@ const section = 'settings.importListExclusions';
// Actions Types
export const FETCH_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/fetchImportListExclusions';
export const GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionFirstPage';
export const GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPreviousPage';
export const GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionNextPage';
export const GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionLastPage';
export const GOTO_IMPORT_LIST_EXCLUSION_PAGE = 'settings/importListExclusions/gotoImportListExclusionPage';
export const SET_IMPORT_LIST_EXCLUSION_SORT = 'settings/importListExclusions/setImportListExclusionSort';
export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption';
export const SAVE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/saveImportListExclusion';
export const DELETE_IMPORT_LIST_EXCLUSION = 'settings/importListExclusions/deleteImportListExclusion';
export const BULK_DELETE_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/bulkDeleteImportListExclusions';
export const CLEAR_IMPORT_LIST_EXCLUSIONS = 'settings/importListExclusions/clearImportListExclusions';
export const SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION = 'settings/importListExclusions/setImportListExclusionTableOption';
export const SET_IMPORT_LIST_EXCLUSION_VALUE = 'settings/importListExclusions/setImportListExclusionValue';
//
// Action Creators
export const fetchImportListExclusions = createThunk(FETCH_IMPORT_LIST_EXCLUSIONS);
export const gotoImportListExclusionFirstPage = createThunk(GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionPreviousPage = createThunk(GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionNextPage = createThunk(GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionLastPage = createThunk(GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE);
export const gotoImportListExclusionPage = createThunk(GOTO_IMPORT_LIST_EXCLUSION_PAGE);
export const setImportListExclusionSort = createThunk(SET_IMPORT_LIST_EXCLUSION_SORT);
export const saveImportListExclusion = createThunk(SAVE_IMPORT_LIST_EXCLUSION);
export const deleteImportListExclusion = createThunk(DELETE_IMPORT_LIST_EXCLUSION);
export const bulkDeleteImportListExclusions = createThunk(BULK_DELETE_IMPORT_LIST_EXCLUSIONS);
export const clearImportListExclusions = createAction(CLEAR_IMPORT_LIST_EXCLUSIONS);
export const setImportListExclusionTableOption = createAction(SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION);
export const setImportListExclusionValue = createAction(SET_IMPORT_LIST_EXCLUSION_VALUE, (payload) => {
@@ -64,6 +63,8 @@ export default {
items: [],
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
pendingChanges: {}
},
@@ -77,16 +78,13 @@ export default {
fetchImportListExclusions,
{
[serverSideCollectionHandlers.FETCH]: FETCH_IMPORT_LIST_EXCLUSIONS,
[serverSideCollectionHandlers.FIRST_PAGE]: GOTO_FIRST_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.PREVIOUS_PAGE]: GOTO_PREVIOUS_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.NEXT_PAGE]: GOTO_NEXT_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.LAST_PAGE]: GOTO_LAST_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.EXACT_PAGE]: GOTO_IMPORT_LIST_EXCLUSION_PAGE,
[serverSideCollectionHandlers.SORT]: SET_IMPORT_LIST_EXCLUSION_SORT
}
),
[SAVE_IMPORT_LIST_EXCLUSION]: createSaveProviderHandler(section, '/importlistexclusion'),
[DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion')
[DELETE_IMPORT_LIST_EXCLUSION]: createRemoveItemHandler(section, '/importlistexclusion'),
[BULK_DELETE_IMPORT_LIST_EXCLUSIONS]: createBulkRemoveItemHandler(section, '/importlistexclusion/bulk')
}),
//
@@ -94,7 +92,19 @@ export default {
reducers: {
[SET_IMPORT_LIST_EXCLUSION_VALUE]: createSetSettingValueReducer(section),
[SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section)
[SET_IMPORT_LIST_EXCLUSION_TABLE_OPTION]: createSetTableOptionReducer(section),
[CLEAR_IMPORT_LIST_EXCLUSIONS]: createClearReducer(section, {
isFetching: false,
isPopulated: false,
error: null,
items: [],
isDeleting: false,
deleteError: null,
pendingChanges: {},
totalPages: 0,
totalRecords: 0
})
}
};

View File

@@ -20,19 +20,19 @@ const section = 'settings.importLists';
//
// Actions Types
export const FETCH_IMPORT_LISTS = 'settings/importlists/fetchImportLists';
export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importlists/fetchImportListSchema';
export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importlists/selectImportListSchema';
export const SET_IMPORT_LIST_VALUE = 'settings/importlists/setImportListValue';
export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importlists/setImportListFieldValue';
export const SAVE_IMPORT_LIST = 'settings/importlists/saveImportList';
export const CANCEL_SAVE_IMPORT_LIST = 'settings/importlists/cancelSaveImportList';
export const DELETE_IMPORT_LIST = 'settings/importlists/deleteImportList';
export const TEST_IMPORT_LIST = 'settings/importlists/testImportList';
export const CANCEL_TEST_IMPORT_LIST = 'settings/importlists/cancelTestImportList';
export const TEST_ALL_IMPORT_LISTS = 'settings/importlists/testAllImportLists';
export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists';
export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists';
export const FETCH_IMPORT_LISTS = 'settings/importLists/fetchImportLists';
export const FETCH_IMPORT_LIST_SCHEMA = 'settings/importLists/fetchImportListSchema';
export const SELECT_IMPORT_LIST_SCHEMA = 'settings/importLists/selectImportListSchema';
export const SET_IMPORT_LIST_VALUE = 'settings/importLists/setImportListValue';
export const SET_IMPORT_LIST_FIELD_VALUE = 'settings/importLists/setImportListFieldValue';
export const SAVE_IMPORT_LIST = 'settings/importLists/saveImportList';
export const CANCEL_SAVE_IMPORT_LIST = 'settings/importLists/cancelSaveImportList';
export const DELETE_IMPORT_LIST = 'settings/importLists/deleteImportList';
export const TEST_IMPORT_LIST = 'settings/importLists/testImportList';
export const CANCEL_TEST_IMPORT_LIST = 'settings/importLists/cancelTestImportList';
export const TEST_ALL_IMPORT_LISTS = 'settings/importLists/testAllImportLists';
export const BULK_EDIT_IMPORT_LISTS = 'settings/importLists/bulkEditImportLists';
export const BULK_DELETE_IMPORT_LISTS = 'settings/importLists/bulkDeleteImportLists';
//
// Action Creators

View File

@@ -77,7 +77,7 @@ export default {
const promise = createAjaxRequest({
method: 'PUT',
url: '/qualityDefinition/update',
url: '/qualitydefinition/update',
data: JSON.stringify(upatedDefinitions),
contentType: 'application/json',
dataType: 'json'

View File

@@ -269,8 +269,9 @@ export const defaultState = {
};
export const persistState = [
'releases.selectedFilterKey',
'releases.episode.selectedFilterKey',
'releases.episode.customFilters',
'releases.season.selectedFilterKey',
'releases.season.customFilters'
];

View File

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

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react';
import React from 'react';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import translate from 'Utilities/String/translate';
@@ -7,22 +7,17 @@ import DiskSpace from './DiskSpace/DiskSpace';
import Health from './Health/Health';
import MoreInfo from './MoreInfo/MoreInfo';
class Status extends Component {
//
// Render
render() {
return (
<PageContent title={translate('Status')}>
<PageContentBody>
<Health />
<DiskSpace />
<About />
<MoreInfo />
</PageContentBody>
</PageContent>
);
}
function Status() {
return (
<PageContent title={translate('Status')}>
<PageContentBody>
<Health />
<DiskSpace />
<About />
<MoreInfo />
</PageContentBody>
</PageContent>
);
}
export default Status;

View File

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

View File

@@ -49,7 +49,7 @@ class CutoffUnmetConnector extends Component {
gotoCutoffUnmetFirstPage
} = this.props;
registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']);
registerPagePopulator(this.repopulate, ['seriesUpdated', 'episodeFileUpdated', 'episodeFileDeleted']);
if (useCurrentPage) {
fetchCutoffUnmet();

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
export type CheckInputChanged = {
export type InputChanged<T = unknown> = {
name: string;
value: boolean;
value: T;
};
export type CheckInputChanged = InputChanged<boolean>;

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -39,22 +39,29 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
Path = @"C:\Test\30 Rock\30.rock.s01e01.avi",
Episodes = episodes,
Series = _series,
Quality = new QualityModel(Quality.HDTV720p)
Quality = new QualityModel(Quality.HDTV720p),
};
}
private void GivenRuntime(int seconds)
{
var runtime = new TimeSpan(0, 0, seconds);
Mocker.GetMock<IVideoFileInfoReader>()
.Setup(s => s.GetRunTime(It.IsAny<string>()))
.Returns(new TimeSpan(0, 0, seconds));
.Returns(runtime);
_localEpisode.MediaInfo = Builder<MediaInfoModel>.CreateNew().With(m => m.RunTime = runtime).Build();
}
[Test]
public void should_return_false_if_season_zero()
{
_localEpisode.Episodes[0].SeasonNumber = 0;
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
}
[Test]
@@ -62,7 +69,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
{
_localEpisode.Path = @"C:\Test\some.show.s01e01.flv";
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
Mocker.GetMock<IVideoFileInfoReader>().Verify(c => c.GetRunTime(It.IsAny<string>()), Times.Never());
}
@@ -72,7 +81,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
{
_localEpisode.Path = @"C:\Test\some.show.s01e01.strm";
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
Mocker.GetMock<IVideoFileInfoReader>().Verify(c => c.GetRunTime(It.IsAny<string>()), Times.Never());
}
@@ -94,7 +105,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
{
GivenRuntime(60);
ShouldBeSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.Sample);
}
[Test]
@@ -102,7 +115,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
{
GivenRuntime(600);
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
}
[Test]
@@ -111,7 +126,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
_series.Runtime = 6;
GivenRuntime(299);
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
}
[Test]
@@ -120,7 +137,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
_series.Runtime = 2;
GivenRuntime(60);
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
}
[Test]
@@ -129,7 +148,9 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
_series.Runtime = 2;
GivenRuntime(10);
ShouldBeSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.Sample);
}
[Test]
@@ -152,7 +173,10 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
GivenRuntime(600);
_series.SeriesType = SeriesTypes.Daily;
_localEpisode.Episodes[0].SeasonNumber = 0;
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
}
[Test]
@@ -161,21 +185,33 @@ namespace NzbDrone.Core.Test.MediaFiles.EpisodeImport
_series.SeriesType = SeriesTypes.Anime;
_localEpisode.Episodes[0].SeasonNumber = 0;
ShouldBeNotSample();
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
}
private void ShouldBeSample()
[Test]
public void should_use_runtime_from_media_info()
{
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.Sample);
GivenRuntime(120);
_localEpisode.Series.Runtime = 30;
_localEpisode.Episodes.First().Runtime = 30;
Subject.IsSample(_localEpisode).Should().Be(DetectSampleResult.Sample);
Mocker.GetMock<IVideoFileInfoReader>().Verify(v => v.GetRunTime(It.IsAny<string>()), Times.Never());
}
private void ShouldBeNotSample()
[Test]
public void should_use_runtime_from_episode_over_series()
{
Subject.IsSample(_localEpisode.Series,
_localEpisode.Path,
_localEpisode.IsSpecial).Should().Be(DetectSampleResult.NotSample);
GivenRuntime(120);
_localEpisode.Series.Runtime = 5;
_localEpisode.Episodes.First().Runtime = 30;
Subject.IsSample(_localEpisode).Should().Be(DetectSampleResult.Sample);
}
}
}

View File

@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using FizzWare.NBuilder;
using FluentAssertions;
using NUnit.Framework;

View File

@@ -34,6 +34,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Series and Title 20201013 Ep7432 [720p WebRip (x264)] [SUBS]", "Series and Title", 2020, 10, 13)]
[TestCase("Series Title (1955) - 1954-01-23 05 00 00 - Cottage for Sale.ts", "Series Title (1955)", 1954, 1, 23)]
[TestCase("Series Title - 30-04-2024 HDTV 1080p H264 AAC", "Series Title", 2024, 4, 30)]
[TestCase("Series On TitleClub E76 2024 08 08 1080p WEB H264-RnB96 [TJET]", "Series On TitleClub", 2024, 8, 8)]
// [TestCase("", "", 0, 0, 0)]
public void should_parse_daily_episode(string postTitle, string title, int year, int month, int day)

View File

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

View File

@@ -87,6 +87,7 @@ namespace NzbDrone.Core.Test.ParserTests
[TestCase("Show Name (2016) Season 1 S01 (1080p AMZN WEB-DL x265 HEVC 10bit EAC3 5 1 RZeroX) QxR", "RZeroX")]
[TestCase("Series Title S01 1080p Blu-ray Remux AVC FLAC 2.0 - KRaLiMaRKo", "KRaLiMaRKo")]
[TestCase("Series Title S01 1080p Blu-ray Remux AVC DTS-HD MA 2.0 - BluDragon", "BluDragon")]
[TestCase("Example (2013) S01E01 (1080p iP WEBRip x265 SDR AAC 2.0 English - DarQ)", "DarQ")]
public void should_parse_exception_release_group(string title, string expected)
{
Parser.Parser.ParseReleaseGroup(title).Should().Be(expected);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -97,7 +97,8 @@ namespace NzbDrone.Core.HealthCheck.Checks
_localizationService.GetLocalizedString("UpdateAvailableHealthCheckMessage", new Dictionary<string, object>
{
{ "version", $"v{latestAvailable.Version}" }
}));
}),
"#new-update-is-available");
}
}

View File

@@ -12,6 +12,7 @@ namespace NzbDrone.Core.ImportLists.Exclusions
List<ImportListExclusion> All();
PagingSpec<ImportListExclusion> Paged(PagingSpec<ImportListExclusion> pagingSpec);
void Delete(int id);
void Delete(List<int> ids);
ImportListExclusion Get(int id);
ImportListExclusion FindByTvdbId(int tvdbId);
ImportListExclusion Update(ImportListExclusion importListExclusion);
@@ -41,6 +42,11 @@ namespace NzbDrone.Core.ImportLists.Exclusions
_repo.Delete(id);
}
public void Delete(List<int> ids)
{
_repo.DeleteMany(ids);
}
public ImportListExclusion Get(int id)
{
return _repo.Get(id);

View File

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

View File

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

View File

@@ -363,10 +363,12 @@
"DeleteRemotePathMappingMessageText": "Are you sure you want to delete this remote path mapping?",
"DeleteRootFolder": "Delete Root Folder",
"DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{path}'?",
"DeleteSelected": "Delete Selected",
"DeleteSelectedDownloadClients": "Delete Download Client(s)",
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?",
"DeleteSelectedEpisodeFiles": "Delete Selected Episode Files",
"DeleteSelectedEpisodeFilesHelpText": "Are you sure you want to delete the selected episode files?",
"DeleteSelectedImportListExclusionsMessageText": "Are you sure you want to delete the selected import list exclusions?",
"DeleteSelectedImportLists": "Delete Import List(s)",
"DeleteSelectedImportListsMessageText": "Are you sure you want to delete {count} selected import list(s)?",
"DeleteSelectedIndexers": "Delete Indexer(s)",
@@ -1095,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",
@@ -1787,8 +1791,8 @@
"SeasonPremieresOnly": "Season Premieres Only",
"Seasons": "Seasons",
"SeasonsMonitoredAll": "All",
"SeasonsMonitoredPartial": "Partial",
"SeasonsMonitoredNone": "None",
"SeasonsMonitoredPartial": "Partial",
"SeasonsMonitoredStatus": "Seasons Monitored",
"SecretToken": "Secret Token",
"Security": "Security",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
using System;
using System;
using System.IO;
using NLog;
using NzbDrone.Core.MediaFiles.MediaInfo;
using NzbDrone.Core.Parser.Model;
using NzbDrone.Core.Tv;
namespace NzbDrone.Core.MediaFiles.EpisodeImport
@@ -9,6 +10,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
public interface IDetectSample
{
DetectSampleResult IsSample(Series series, string path, bool isSpecial);
DetectSampleResult IsSample(LocalEpisode localEpisode);
}
public class DetectSample : IDetectSample
@@ -23,6 +25,51 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
}
public DetectSampleResult IsSample(Series series, string path, bool isSpecial)
{
var extensionResult = IsSample(path, isSpecial);
if (extensionResult != DetectSampleResult.Indeterminate)
{
return extensionResult;
}
var fileRuntime = _videoFileInfoReader.GetRunTime(path);
if (!fileRuntime.HasValue)
{
_logger.Error("Failed to get runtime from the file, make sure ffprobe is available");
return DetectSampleResult.Indeterminate;
}
return IsSample(path, fileRuntime.Value, series.Runtime);
}
public DetectSampleResult IsSample(LocalEpisode localEpisode)
{
var extensionResult = IsSample(localEpisode.Path, localEpisode.IsSpecial);
if (extensionResult != DetectSampleResult.Indeterminate)
{
return extensionResult;
}
var runtime = 0;
foreach (var episode in localEpisode.Episodes)
{
runtime += episode.Runtime > 0 ? episode.Runtime : localEpisode.Series.Runtime;
}
if (localEpisode.MediaInfo == null)
{
_logger.Error("Failed to get runtime from the file, make sure ffprobe is available");
return DetectSampleResult.Indeterminate;
}
return IsSample(localEpisode.Path, localEpisode.MediaInfo.RunTime, runtime);
}
private DetectSampleResult IsSample(string path, bool isSpecial)
{
if (isSpecial)
{
@@ -44,49 +91,45 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport
return DetectSampleResult.NotSample;
}
// TODO: Use MediaInfo from the import process, no need to re-process the file again here
var runTime = _videoFileInfoReader.GetRunTime(path);
return DetectSampleResult.Indeterminate;
}
if (!runTime.HasValue)
{
_logger.Error("Failed to get runtime from the file, make sure ffprobe is available");
return DetectSampleResult.Indeterminate;
}
private DetectSampleResult IsSample(string path, TimeSpan fileRuntime, int expectedRuntime)
{
var minimumRuntime = GetMinimumAllowedRuntime(expectedRuntime);
var minimumRuntime = GetMinimumAllowedRuntime(series);
if (runTime.Value.TotalMinutes.Equals(0))
if (fileRuntime.TotalMinutes.Equals(0))
{
_logger.Error("[{0}] has a runtime of 0, is it a valid video file?", path);
return DetectSampleResult.Sample;
}
if (runTime.Value.TotalSeconds < minimumRuntime)
if (fileRuntime.TotalSeconds < minimumRuntime)
{
_logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, runTime, minimumRuntime);
_logger.Debug("[{0}] appears to be a sample. Runtime: {1} seconds. Expected at least: {2} seconds", path, fileRuntime, minimumRuntime);
return DetectSampleResult.Sample;
}
_logger.Debug("[{0}] does not appear to be a sample. Runtime {1} seconds is more than minimum of {2} seconds", path, runTime, minimumRuntime);
_logger.Debug("[{0}] does not appear to be a sample. Runtime {1} seconds is more than minimum of {2} seconds", path, fileRuntime, minimumRuntime);
return DetectSampleResult.NotSample;
}
private int GetMinimumAllowedRuntime(Series series)
private int GetMinimumAllowedRuntime(int runtime)
{
// Anime short - 15 seconds
if (series.Runtime <= 3)
if (runtime <= 3)
{
return 15;
}
// Webisodes - 90 seconds
if (series.Runtime <= 10)
if (runtime <= 10)
{
return 90;
}
// 30 minute episodes - 5 minutes
if (series.Runtime <= 30)
if (runtime <= 30)
{
return 300;
}

View File

@@ -28,7 +28,7 @@ namespace NzbDrone.Core.MediaFiles.EpisodeImport.Specifications
try
{
var sample = _detectSample.IsSample(localEpisode.Series, localEpisode.Path, localEpisode.IsSpecial);
var sample = _detectSample.IsSample(localEpisode);
if (sample == DetectSampleResult.Sample)
{

View File

@@ -406,18 +406,18 @@ namespace NzbDrone.Core.Notifications.CustomScript
environmentVariables.Add("Sonarr_EventType", "ManualInteractionRequired");
environmentVariables.Add("Sonarr_InstanceName", _configFileProvider.InstanceName);
environmentVariables.Add("Sonarr_ApplicationUrl", _configService.ApplicationUrl);
environmentVariables.Add("Sonarr_Series_Id", series.Id.ToString());
environmentVariables.Add("Sonarr_Series_Title", series.Title);
environmentVariables.Add("Sonarr_Series_TitleSlug", series.TitleSlug);
environmentVariables.Add("Sonarr_Series_Path", series.Path);
environmentVariables.Add("Sonarr_Series_TvdbId", series.TvdbId.ToString());
environmentVariables.Add("Sonarr_Series_TvMazeId", series.TvMazeId.ToString());
environmentVariables.Add("Sonarr_Series_TmdbId", series.TmdbId.ToString());
environmentVariables.Add("Sonarr_Series_ImdbId", series.ImdbId ?? string.Empty);
environmentVariables.Add("Sonarr_Series_Type", series.SeriesType.ToString());
environmentVariables.Add("Sonarr_Series_Year", series.Year.ToString());
environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series.OriginalLanguage).ThreeLetterCode);
environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series.Genres));
environmentVariables.Add("Sonarr_Series_Id", series?.Id.ToString());
environmentVariables.Add("Sonarr_Series_Title", series?.Title);
environmentVariables.Add("Sonarr_Series_TitleSlug", series?.TitleSlug);
environmentVariables.Add("Sonarr_Series_Path", series?.Path);
environmentVariables.Add("Sonarr_Series_TvdbId", series?.TvdbId.ToString());
environmentVariables.Add("Sonarr_Series_TvMazeId", series?.TvMazeId.ToString());
environmentVariables.Add("Sonarr_Series_TmdbId", series?.TmdbId.ToString());
environmentVariables.Add("Sonarr_Series_ImdbId", series?.ImdbId ?? string.Empty);
environmentVariables.Add("Sonarr_Series_Type", series?.SeriesType.ToString());
environmentVariables.Add("Sonarr_Series_Year", series?.Year.ToString());
environmentVariables.Add("Sonarr_Series_OriginalLanguage", IsoLanguages.Get(series?.OriginalLanguage)?.ThreeLetterCode);
environmentVariables.Add("Sonarr_Series_Genres", string.Join("|", series?.Genres ?? new List<string>()));
environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series)));
environmentVariables.Add("Sonarr_Download_Client", message.DownloadClientInfo?.Name ?? string.Empty);
environmentVariables.Add("Sonarr_Download_Client_Type", message.DownloadClientInfo?.Type ?? string.Empty);
@@ -482,6 +482,11 @@ namespace NzbDrone.Core.Notifications.CustomScript
private List<string> GetTagLabels(Series series)
{
if (series == null)
{
return null;
}
return _tagRepository.GetTags(series.Tags)
.Select(s => s.Label)
.Where(l => l.IsNotNullOrWhiteSpace())

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using FluentValidation.Results;
using NzbDrone.Common.Extensions;
@@ -76,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";
@@ -179,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";
@@ -223,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())
@@ -285,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";
@@ -329,12 +338,12 @@ namespace NzbDrone.Core.Notifications.Discord
public override void OnRename(Series series, List<RenamedEpisodeFile> renamedFiles)
{
var attachments = new List<Embed>
{
new Embed
{
Title = series.Title,
}
};
{
new ()
{
Title = series.Title,
}
};
var payload = CreatePayload("Renamed", attachments);
@@ -361,8 +370,8 @@ namespace NzbDrone.Core.Notifications.Discord
Color = (int)DiscordColors.Danger,
Fields = new List<DiscordField>
{
new DiscordField { Name = "Reason", Value = reason.ToString() },
new DiscordField { Name = "File name", Value = string.Format("```{0}```", deletedFile) }
new () { Name = "Reason", Value = reason.ToString() },
new () { Name = "File name", Value = string.Format("```{0}```", deletedFile) }
},
Timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"),
};
@@ -386,7 +395,7 @@ namespace NzbDrone.Core.Notifications.Discord
Title = series.Title,
Description = "Series Added",
Color = (int)DiscordColors.Success,
Fields = new List<DiscordField> { new DiscordField { Name = "Links", Value = GetLinksString(series) } }
Fields = new List<DiscordField> { new () { Name = "Links", Value = GetLinksString(series) } }
};
if (Settings.ImportFields.Contains((int)DiscordImportFieldType.Poster))
@@ -425,7 +434,7 @@ namespace NzbDrone.Core.Notifications.Discord
Title = series.Title,
Description = deleteMessage.DeletedFilesMessage,
Color = (int)DiscordColors.Danger,
Fields = new List<DiscordField> { new DiscordField { Name = "Links", Value = GetLinksString(series) } }
Fields = new List<DiscordField> { new () { Name = "Links", Value = GetLinksString(series) } }
};
if (Settings.ImportFields.Contains((int)DiscordImportFieldType.Poster))
@@ -503,12 +512,12 @@ namespace NzbDrone.Core.Notifications.Discord
Color = (int)DiscordColors.Standard,
Fields = new List<DiscordField>()
{
new DiscordField()
new ()
{
Name = "Previous Version",
Value = updateMessage.PreviousVersion.ToString()
},
new DiscordField()
new ()
{
Name = "New Version",
Value = updateMessage.NewVersion.ToString()
@@ -533,7 +542,7 @@ namespace NzbDrone.Core.Notifications.Discord
Name = Settings.Author.IsNullOrWhiteSpace() ? Environment.MachineName : Settings.Author,
IconUrl = "https://raw.githubusercontent.com/Sonarr/Sonarr/develop/Logo/256.png"
},
Url = $"http://thetvdb.com/?tab=series&id={series.TvdbId}",
Url = series?.TvdbId > 0 ? $"http://thetvdb.com/?tab=series&id={series.TvdbId}" : null,
Description = "Manual interaction needed",
Title = GetTitle(series, episodes),
Color = (int)DiscordColors.Standard,
@@ -545,7 +554,7 @@ namespace NzbDrone.Core.Notifications.Discord
{
embed.Thumbnail = new DiscordImage
{
Url = series.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Poster)?.Url
Url = series?.Images?.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Poster)?.Url
};
}
@@ -553,7 +562,7 @@ namespace NzbDrone.Core.Notifications.Discord
{
embed.Image = new DiscordImage
{
Url = series.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Fanart)?.Url
Url = series?.Images?.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Fanart)?.Url
};
}
@@ -564,26 +573,26 @@ namespace NzbDrone.Core.Notifications.Discord
switch ((DiscordManualInteractionFieldType)field)
{
case DiscordManualInteractionFieldType.Overview:
var overview = episodes.First().Overview ?? "";
var overview = episodes.FirstOrDefault()?.Overview ?? "";
discordField.Name = "Overview";
discordField.Value = overview.Length <= 300 ? overview : $"{overview.AsSpan(0, 300)}...";
break;
case DiscordManualInteractionFieldType.Rating:
discordField.Name = "Rating";
discordField.Value = episodes.First().Ratings.Value.ToString();
discordField.Value = series?.Ratings?.Value.ToString(CultureInfo.InvariantCulture);
break;
case DiscordManualInteractionFieldType.Genres:
discordField.Name = "Genres";
discordField.Value = series.Genres.Take(5).Join(", ");
discordField.Value = series?.Genres.Take(5).Join(", ");
break;
case DiscordManualInteractionFieldType.Quality:
discordField.Name = "Quality";
discordField.Inline = true;
discordField.Value = message.Quality.Quality.Name;
discordField.Value = message.Quality?.Quality?.Name;
break;
case DiscordManualInteractionFieldType.Group:
discordField.Name = "Group";
discordField.Value = message.Episode.ParsedEpisodeInfo.ReleaseGroup;
discordField.Value = message.Episode?.ParsedEpisodeInfo?.ReleaseGroup;
break;
case DiscordManualInteractionFieldType.Size:
discordField.Name = "Size";
@@ -592,7 +601,7 @@ namespace NzbDrone.Core.Notifications.Discord
break;
case DiscordManualInteractionFieldType.DownloadTitle:
discordField.Name = "Download";
discordField.Value = string.Format("```{0}```", message.TrackedDownload.DownloadItem.Title);
discordField.Value = $"```{message.TrackedDownload.DownloadItem.Title}```";
break;
case DiscordManualInteractionFieldType.Links:
discordField.Name = "Links";
@@ -677,10 +686,16 @@ namespace NzbDrone.Core.Notifications.Discord
private string GetLinksString(Series series)
{
var links = new List<string>();
if (series == null)
{
return null;
}
links.Add($"[The TVDB](https://thetvdb.com/?tab=series&id={series.TvdbId})");
links.Add($"[Trakt](https://trakt.tv/search/tvdb/{series.TvdbId}?id_type=show)");
var links = new List<string>
{
$"[The TVDB](https://thetvdb.com/?tab=series&id={series.TvdbId})",
$"[Trakt](https://trakt.tv/search/tvdb/{series.TvdbId}?id_type=show)"
};
if (series.ImdbId.IsNotNullOrWhiteSpace())
{
@@ -692,6 +707,11 @@ namespace NzbDrone.Core.Notifications.Discord
private string GetTitle(Series series, List<Episode> episodes)
{
if (series == null)
{
return null;
}
if (series.SeriesType == SeriesTypes.Daily)
{
var episode = episodes.First();

View File

@@ -31,7 +31,9 @@ namespace NzbDrone.Core.Notifications.Discord
Links,
Release,
Poster,
Fanart
Fanart,
CustomFormats,
CustomFormatScore
}
public enum DiscordManualInteractionFieldType

View File

@@ -28,15 +28,15 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnGrab(GrabMessage message)
{
var attachments = new List<Attachment>
{
new Attachment
{
Fallback = message.Message,
Title = message.Series.Title,
Text = message.Message,
Color = "warning"
}
};
{
new ()
{
Fallback = message.Message,
Title = message.Series.Title,
Text = message.Message,
Color = "warning"
}
};
var payload = CreatePayload($"Grabbed: {message.Message}", attachments);
_proxy.SendPayload(payload, Settings);
@@ -45,15 +45,15 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnDownload(DownloadMessage message)
{
var attachments = new List<Attachment>
{
new Attachment
{
Fallback = message.Message,
Title = message.Series.Title,
Text = message.Message,
Color = "good"
}
};
{
new ()
{
Fallback = message.Message,
Title = message.Series.Title,
Text = message.Message,
Color = "good"
}
};
var payload = CreatePayload($"Imported: {message.Message}", attachments);
_proxy.SendPayload(payload, Settings);
@@ -63,7 +63,7 @@ namespace NzbDrone.Core.Notifications.Slack
{
var attachments = new List<Attachment>
{
new Attachment
new ()
{
Fallback = message.Message,
Title = message.Series.Title,
@@ -79,12 +79,12 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnRename(Series series, List<RenamedEpisodeFile> renamedFiles)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = series.Title,
}
};
{
new ()
{
Title = series.Title,
}
};
var payload = CreatePayload("Renamed", attachments);
@@ -94,12 +94,12 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnEpisodeFileDelete(EpisodeDeleteMessage deleteMessage)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = GetTitle(deleteMessage.Series, deleteMessage.EpisodeFile.Episodes),
}
};
{
new ()
{
Title = GetTitle(deleteMessage.Series, deleteMessage.EpisodeFile.Episodes),
}
};
var payload = CreatePayload("Episode Deleted", attachments);
@@ -109,12 +109,12 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnSeriesAdd(SeriesAddMessage message)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = message.Series.Title,
}
};
{
new ()
{
Title = message.Series.Title,
}
};
var payload = CreatePayload("Series Added", attachments);
@@ -124,13 +124,13 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnSeriesDelete(SeriesDeleteMessage deleteMessage)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = deleteMessage.Series.Title,
Text = deleteMessage.DeletedFilesMessage
}
};
{
new ()
{
Title = deleteMessage.Series.Title,
Text = deleteMessage.DeletedFilesMessage
}
};
var payload = CreatePayload("Series Deleted", attachments);
@@ -140,14 +140,14 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = healthCheck.Source.Name,
Text = healthCheck.Message,
Color = healthCheck.Type == HealthCheck.HealthCheckResult.Warning ? "warning" : "danger"
}
};
{
new ()
{
Title = healthCheck.Source.Name,
Text = healthCheck.Message,
Color = healthCheck.Type == HealthCheck.HealthCheckResult.Warning ? "warning" : "danger"
}
};
var payload = CreatePayload("Health Issue", attachments);
@@ -157,14 +157,14 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnHealthRestored(HealthCheck.HealthCheck previousCheck)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = previousCheck.Source.Name,
Text = $"The following issue is now resolved: {previousCheck.Message}",
Color = "good"
}
};
{
new ()
{
Title = previousCheck.Source.Name,
Text = $"The following issue is now resolved: {previousCheck.Message}",
Color = "good"
}
};
var payload = CreatePayload("Health Issue Resolved", attachments);
@@ -174,14 +174,14 @@ namespace NzbDrone.Core.Notifications.Slack
public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage)
{
var attachments = new List<Attachment>
{
new Attachment
{
Title = Environment.MachineName,
Text = updateMessage.Message,
Color = "good"
}
};
{
new ()
{
Title = Environment.MachineName,
Text = updateMessage.Message,
Color = "good"
}
};
var payload = CreatePayload("Application Updated", attachments);
@@ -192,7 +192,7 @@ namespace NzbDrone.Core.Notifications.Slack
{
var attachments = new List<Attachment>
{
new Attachment
new ()
{
Title = Environment.MachineName,
Text = message.Message,

View File

@@ -229,9 +229,9 @@ namespace NzbDrone.Core.Notifications.Webhook
TvdbId = 1234,
Tags = new List<string> { "test-tag" }
},
Episodes = new List<WebhookEpisode>()
Episodes = new List<WebhookEpisode>
{
new WebhookEpisode()
new ()
{
Id = 123,
EpisodeNumber = 1,
@@ -244,6 +244,11 @@ namespace NzbDrone.Core.Notifications.Webhook
private WebhookSeries GetSeries(Series series)
{
if (series == null)
{
return null;
}
_mediaCoverService.ConvertToLocalUrls(series.Id, series.Images);
return new WebhookSeries(series, GetTagLabels(series));
@@ -251,6 +256,11 @@ namespace NzbDrone.Core.Notifications.Webhook
private List<string> GetTagLabels(Series series)
{
if (series == null)
{
return null;
}
return _tagRepository.GetTags(series.Tags)
.Select(s => s.Label)
.Where(l => l.IsNotNullOrWhiteSpace())

Some files were not shown because too many files have changed in this diff Show More