mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-05 19:24:17 -04:00
Compare commits
13 Commits
v4.0.9.233
...
quality-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
974c4a601b | ||
|
|
9549038121 | ||
|
|
2f193ac58a | ||
|
|
e893ca4f1c | ||
|
|
039d7775ed | ||
|
|
87bd5e62f2 | ||
|
|
a9072ac460 | ||
|
|
55aaaa5c40 | ||
|
|
ee99c3895d | ||
|
|
e1e10e195c | ||
|
|
0b9a212f33 | ||
|
|
0e384ee3aa | ||
|
|
d903529389 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -162,3 +162,6 @@ src/.idea/
|
||||
|
||||
# API doc generation
|
||||
.config/
|
||||
|
||||
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||
.idea/
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './CircularProgressBar.css';
|
||||
|
||||
class CircularProgressBar extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
progress: 0
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._progressStep();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const progress = this.props.progress;
|
||||
|
||||
if (prevProps.progress !== progress) {
|
||||
this._cancelProgressStep();
|
||||
this._progressStep();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._cancelProgressStep();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_progressStep() {
|
||||
this.requestAnimationFrame = window.requestAnimationFrame(() => {
|
||||
this.setState({
|
||||
progress: this.state.progress + 1
|
||||
}, () => {
|
||||
if (this.state.progress < this.props.progress) {
|
||||
this._progressStep();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_cancelProgressStep() {
|
||||
if (this.requestAnimationFrame) {
|
||||
window.cancelAnimationFrame(this.requestAnimationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
size,
|
||||
strokeWidth,
|
||||
strokeColor,
|
||||
showProgressText
|
||||
} = this.props;
|
||||
|
||||
const progress = this.state.progress;
|
||||
|
||||
const center = size / 2;
|
||||
const radius = center - strokeWidth;
|
||||
const circumference = Math.PI * (radius * 2);
|
||||
const sizeInPixels = `${size}px`;
|
||||
const strokeDashoffset = ((100 - progress) / 100) * circumference;
|
||||
const progressText = `${Math.round(progress)}%`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClassName}
|
||||
style={{
|
||||
width: sizeInPixels,
|
||||
height: sizeInPixels,
|
||||
lineHeight: sizeInPixels
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<circle
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
strokeDasharray={circumference}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
strokeDashoffset
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{
|
||||
showProgressText &&
|
||||
<div className={styles.circularProgressBarText}>
|
||||
{progressText}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CircularProgressBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
containerClassName: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
progress: PropTypes.number.isRequired,
|
||||
strokeWidth: PropTypes.number,
|
||||
strokeColor: PropTypes.string,
|
||||
showProgressText: PropTypes.bool
|
||||
};
|
||||
|
||||
CircularProgressBar.defaultProps = {
|
||||
className: styles.circularProgressBar,
|
||||
containerClassName: styles.circularProgressBarContainer,
|
||||
size: 60,
|
||||
strokeWidth: 5,
|
||||
strokeColor: '#35c5f4',
|
||||
showProgressText: false
|
||||
};
|
||||
|
||||
export default CircularProgressBar;
|
||||
99
frontend/src/Components/CircularProgressBar.tsx
Normal file
99
frontend/src/Components/CircularProgressBar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import styles from './CircularProgressBar.css';
|
||||
|
||||
interface CircularProgressBarProps {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
size?: number;
|
||||
progress: number;
|
||||
strokeWidth?: number;
|
||||
strokeColor?: string;
|
||||
showProgressText?: boolean;
|
||||
}
|
||||
|
||||
function CircularProgressBar({
|
||||
className = styles.circularProgressBar,
|
||||
containerClassName = styles.circularProgressBarContainer,
|
||||
size = 60,
|
||||
strokeWidth = 5,
|
||||
strokeColor = '#35c5f4',
|
||||
showProgressText = false,
|
||||
progress,
|
||||
}: CircularProgressBarProps) {
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
const raf = React.useRef<number>(0);
|
||||
const center = size / 2;
|
||||
const radius = center - strokeWidth;
|
||||
const circumference = Math.PI * (radius * 2);
|
||||
const sizeInPixels = `${size}px`;
|
||||
const strokeDashoffset = ((100 - currentProgress) / 100) * circumference;
|
||||
const progressText = `${Math.round(currentProgress)}%`;
|
||||
|
||||
const handleAnimation = useCallback(
|
||||
(p: number) => {
|
||||
setCurrentProgress((prevProgress) => {
|
||||
if (prevProgress < p) {
|
||||
return prevProgress + Math.min(1, p - prevProgress);
|
||||
}
|
||||
|
||||
return prevProgress;
|
||||
});
|
||||
},
|
||||
[setCurrentProgress]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress > currentProgress) {
|
||||
cancelAnimationFrame(raf.current);
|
||||
|
||||
raf.current = requestAnimationFrame(() => handleAnimation(progress));
|
||||
}
|
||||
}, [progress, currentProgress, handleAnimation]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
return () => cancelAnimationFrame(raf.current);
|
||||
},
|
||||
// We only want to run this effect once
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClassName}
|
||||
style={{
|
||||
width: sizeInPixels,
|
||||
height: sizeInPixels,
|
||||
lineHeight: sizeInPixels,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className={className}
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<circle
|
||||
fill="transparent"
|
||||
r={radius}
|
||||
cx={center}
|
||||
cy={center}
|
||||
strokeDasharray={circumference}
|
||||
style={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth,
|
||||
strokeDashoffset,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{showProgressText && (
|
||||
<div className={styles.circularProgressBarText}>{progressText}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CircularProgressBar;
|
||||
@@ -1,114 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ProgressBar.css';
|
||||
|
||||
function ProgressBar(props) {
|
||||
const {
|
||||
className,
|
||||
containerClassName,
|
||||
title,
|
||||
progress,
|
||||
precision,
|
||||
showText,
|
||||
text,
|
||||
kind,
|
||||
size,
|
||||
width
|
||||
} = props;
|
||||
|
||||
const progressPercent = `${progress.toFixed(precision)}%`;
|
||||
const progressText = text || progressPercent;
|
||||
const actualWidth = width ? `${width}px` : '100%';
|
||||
|
||||
return (
|
||||
<ColorImpairedConsumer>
|
||||
{(enableColorImpairedMode) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
styles[size]
|
||||
)}
|
||||
title={title}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
{
|
||||
showText && width ?
|
||||
<div
|
||||
className={classNames(styles.backTextContainer, styles[kind])}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div className={styles.backText}>
|
||||
<div>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
enableColorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
role="meter"
|
||||
aria-label={translate('ProgressBarProgress', { progress: progress.toFixed(0) })}
|
||||
aria-valuenow={progress.toFixed(0)}
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
style={{ width: progressPercent }}
|
||||
/>
|
||||
|
||||
{
|
||||
showText ?
|
||||
<div
|
||||
className={classNames(styles.frontTextContainer, styles[kind])}
|
||||
style={{ width: progressPercent }}
|
||||
>
|
||||
<div
|
||||
className={styles.frontText}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div>
|
||||
{progressText}
|
||||
</div>
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ColorImpairedConsumer>
|
||||
);
|
||||
}
|
||||
|
||||
ProgressBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
containerClassName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
progress: PropTypes.number.isRequired,
|
||||
precision: PropTypes.number.isRequired,
|
||||
showText: PropTypes.bool.isRequired,
|
||||
text: PropTypes.string,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
width: PropTypes.number
|
||||
};
|
||||
|
||||
ProgressBar.defaultProps = {
|
||||
className: styles.progressBar,
|
||||
containerClassName: styles.container,
|
||||
precision: 1,
|
||||
showText: false,
|
||||
kind: kinds.PRIMARY,
|
||||
size: sizes.MEDIUM
|
||||
};
|
||||
|
||||
export default ProgressBar;
|
||||
94
frontend/src/Components/ProgressBar.tsx
Normal file
94
frontend/src/Components/ProgressBar.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ProgressBar.css';
|
||||
|
||||
interface ProgressBarProps {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
title?: string;
|
||||
progress: number;
|
||||
precision?: number;
|
||||
showText?: boolean;
|
||||
text?: string;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
function ProgressBar({
|
||||
className = styles.progressBar,
|
||||
containerClassName = styles.container,
|
||||
title,
|
||||
progress,
|
||||
precision = 1,
|
||||
showText = false,
|
||||
text,
|
||||
kind = 'primary',
|
||||
size = 'medium',
|
||||
width,
|
||||
}: ProgressBarProps) {
|
||||
const progressPercent = `${progress.toFixed(precision)}%`;
|
||||
const progressText = text || progressPercent;
|
||||
const actualWidth = width ? `${width}px` : '100%';
|
||||
|
||||
return (
|
||||
<ColorImpairedConsumer>
|
||||
{(enableColorImpairedMode) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(containerClassName, styles[size])}
|
||||
title={title}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
{showText && width ? (
|
||||
<div
|
||||
className={classNames(styles.backTextContainer, styles[kind])}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div className={styles.backText}>
|
||||
<div>{progressText}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
enableColorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
role="meter"
|
||||
aria-label={translate('ProgressBarProgress', {
|
||||
progress: progress.toFixed(0),
|
||||
})}
|
||||
aria-valuenow={Math.floor(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
style={{ width: progressPercent }}
|
||||
/>
|
||||
|
||||
{showText ? (
|
||||
<div
|
||||
className={classNames(styles.frontTextContainer, styles[kind])}
|
||||
style={{ width: progressPercent }}
|
||||
>
|
||||
<div
|
||||
className={styles.frontText}
|
||||
style={{ width: actualWidth }}
|
||||
>
|
||||
<div>{progressText}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</ColorImpairedConsumer>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProgressBar;
|
||||
@@ -65,8 +65,7 @@ function Tooltip(props: TooltipProps) {
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
|
||||
|
||||
setTimeout(() => {
|
||||
closeTimeout.current = window.setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 100);
|
||||
}, [setIsOpen]);
|
||||
@@ -111,18 +110,18 @@ function Tooltip(props: TooltipProps) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentTimeout = closeTimeout.current;
|
||||
|
||||
if (updater.current && isOpen) {
|
||||
updater.current();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (currentTimeout) {
|
||||
window.clearTimeout(currentTimeout);
|
||||
if (closeTimeout.current) {
|
||||
window.clearTimeout(closeTimeout.current);
|
||||
}
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
import styles from './SeriesDetailsLinks.css';
|
||||
|
||||
function SeriesDetailsLinks(props) {
|
||||
const {
|
||||
tvdbId,
|
||||
tvMazeId,
|
||||
imdbId,
|
||||
tmdbId
|
||||
} = props;
|
||||
type SeriesDetailsLinksProps = Pick<
|
||||
Series,
|
||||
'tvdbId' | 'tvMazeId' | 'imdbId' | 'tmdbId'
|
||||
>;
|
||||
|
||||
function SeriesDetailsLinks(props: SeriesDetailsLinksProps) {
|
||||
const { tvdbId, tvMazeId, imdbId, tmdbId } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.links}>
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`http://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
to={`https://www.thetvdb.com/?tab=series&id=${tvdbId}`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
@@ -30,7 +30,7 @@ function SeriesDetailsLinks(props) {
|
||||
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`http://trakt.tv/search/tvdb/${tvdbId}?id_type=show`}
|
||||
to={`https://trakt.tv/search/tvdb/${tvdbId}?id_type=show`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
@@ -41,27 +41,26 @@ function SeriesDetailsLinks(props) {
|
||||
</Label>
|
||||
</Link>
|
||||
|
||||
{
|
||||
!!tvMazeId &&
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`http://www.tvmaze.com/shows/${tvMazeId}/_`}
|
||||
{tvMazeId ? (
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://www.tvmaze.com/shows/${tvMazeId}/_`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
TV Maze
|
||||
</Label>
|
||||
</Link>
|
||||
}
|
||||
TV Maze
|
||||
</Label>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{
|
||||
!!imdbId &&
|
||||
{imdbId ? (
|
||||
<>
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`http://imdb.com/title/${imdbId}/`}
|
||||
to={`https://imdb.com/title/${imdbId}/`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
@@ -71,32 +70,38 @@ function SeriesDetailsLinks(props) {
|
||||
IMDB
|
||||
</Label>
|
||||
</Link>
|
||||
}
|
||||
|
||||
{
|
||||
!!tmdbId &&
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://www.themoviedb.org/tv/${tmdbId}`}
|
||||
to={`http://mdblist.com/show/${imdbId}`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
TMDB
|
||||
MDBList
|
||||
</Label>
|
||||
</Link>
|
||||
}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{tmdbId ? (
|
||||
<Link
|
||||
className={styles.link}
|
||||
to={`https://www.themoviedb.org/tv/${tmdbId}`}
|
||||
>
|
||||
<Label
|
||||
className={styles.linkLabel}
|
||||
kind={kinds.INFO}
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
TMDB
|
||||
</Label>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesDetailsLinks.propTypes = {
|
||||
tvdbId: PropTypes.number.isRequired,
|
||||
tvMazeId: PropTypes.number,
|
||||
imdbId: PropTypes.string,
|
||||
tmdbId: PropTypes.number
|
||||
};
|
||||
|
||||
export default SeriesDetailsLinks;
|
||||
@@ -1,11 +1,14 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './NoSeries.css';
|
||||
|
||||
function NoSeries(props) {
|
||||
interface NoSeriesProps {
|
||||
totalItems: number;
|
||||
}
|
||||
|
||||
function NoSeries(props: NoSeriesProps) {
|
||||
const { totalItems } = props;
|
||||
|
||||
if (totalItems > 0) {
|
||||
@@ -25,19 +28,13 @@ function NoSeries(props) {
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
to="/add/import"
|
||||
kind={kinds.PRIMARY}
|
||||
>
|
||||
<Button to="/add/import" kind={kinds.PRIMARY}>
|
||||
{translate('ImportExistingSeries')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button
|
||||
to="/add/new"
|
||||
kind={kinds.PRIMARY}
|
||||
>
|
||||
<Button to="/add/new" kind={kinds.PRIMARY}>
|
||||
{translate('AddNewSeries')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -45,8 +42,4 @@ function NoSeries(props) {
|
||||
);
|
||||
}
|
||||
|
||||
NoSeries.propTypes = {
|
||||
totalItems: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default NoSeries;
|
||||
@@ -19,8 +19,10 @@ export type SeriesStatus = 'continuing' | 'ended' | 'upcoming' | 'deleted';
|
||||
|
||||
export type MonitorNewItems = 'all' | 'none';
|
||||
|
||||
export type CoverType = 'poster' | 'banner' | 'fanart' | 'season';
|
||||
|
||||
export interface Image {
|
||||
coverType: string;
|
||||
coverType: CoverType;
|
||||
url: string;
|
||||
remoteUrl: string;
|
||||
}
|
||||
@@ -71,7 +73,7 @@ interface Series extends ModelBase {
|
||||
firstAired: string;
|
||||
genres: string[];
|
||||
images: Image[];
|
||||
imdbId: string;
|
||||
imdbId?: string;
|
||||
monitored: boolean;
|
||||
monitorNewItems: MonitorNewItems;
|
||||
network: string;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SeriesImage from './SeriesImage';
|
||||
|
||||
const bannerPlaceholder = 'data:image/png;base64,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';
|
||||
|
||||
function SeriesBanner(props) {
|
||||
return (
|
||||
<SeriesImage
|
||||
{...props}
|
||||
coverType="banner"
|
||||
placeholder={bannerPlaceholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesBanner.propTypes = {
|
||||
...SeriesImage.propTypes,
|
||||
coverType: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
overflow: PropTypes.bool,
|
||||
size: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
SeriesBanner.defaultProps = {
|
||||
size: 70
|
||||
};
|
||||
|
||||
export default SeriesBanner;
|
||||
23
frontend/src/Series/SeriesBanner.tsx
Normal file
23
frontend/src/Series/SeriesBanner.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import SeriesImage, { SeriesImageProps } from './SeriesImage';
|
||||
|
||||
const bannerPlaceholder =
|
||||
'data:image/png;base64,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';
|
||||
|
||||
interface SeriesBannerProps
|
||||
extends Omit<SeriesImageProps, 'coverType' | 'placeholder'> {
|
||||
size?: 35 | 70;
|
||||
}
|
||||
|
||||
function SeriesBanner({ size = 70, ...otherProps }: SeriesBannerProps) {
|
||||
return (
|
||||
<SeriesImage
|
||||
{...otherProps}
|
||||
size={size}
|
||||
coverType="banner"
|
||||
placeholder={bannerPlaceholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesBanner;
|
||||
@@ -1,198 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import LazyLoad from 'react-lazyload';
|
||||
|
||||
function findImage(images, coverType) {
|
||||
return images.find((image) => image.coverType === coverType);
|
||||
}
|
||||
|
||||
function getUrl(image, coverType, size) {
|
||||
const imageUrl = image?.url;
|
||||
|
||||
if (imageUrl) {
|
||||
return imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`);
|
||||
}
|
||||
}
|
||||
|
||||
class SeriesImage extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const pixelRatio = Math.max(Math.round(window.devicePixelRatio), 1);
|
||||
|
||||
const {
|
||||
images,
|
||||
coverType,
|
||||
size
|
||||
} = props;
|
||||
|
||||
const image = findImage(images, coverType);
|
||||
|
||||
this.state = {
|
||||
pixelRatio,
|
||||
image,
|
||||
url: getUrl(image, coverType, pixelRatio * size),
|
||||
isLoaded: false,
|
||||
hasError: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.state.url && this.props.onError) {
|
||||
this.props.onError();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const {
|
||||
images,
|
||||
coverType,
|
||||
placeholder,
|
||||
size,
|
||||
onError
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
image,
|
||||
pixelRatio
|
||||
} = this.state;
|
||||
|
||||
const nextImage = findImage(images, coverType);
|
||||
|
||||
if (nextImage && (!image || nextImage.url !== image.url)) {
|
||||
this.setState({
|
||||
image: nextImage,
|
||||
url: getUrl(nextImage, coverType, pixelRatio * size),
|
||||
hasError: false
|
||||
// Don't reset isLoaded, as we want to immediately try to
|
||||
// show the new image, whether an image was shown previously
|
||||
// or the placeholder was shown.
|
||||
});
|
||||
} else if (!nextImage && image) {
|
||||
this.setState({
|
||||
image: nextImage,
|
||||
url: placeholder,
|
||||
hasError: false
|
||||
});
|
||||
|
||||
if (onError) {
|
||||
onError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onError = () => {
|
||||
this.setState({
|
||||
hasError: true
|
||||
});
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError();
|
||||
}
|
||||
};
|
||||
|
||||
onLoad = () => {
|
||||
this.setState({
|
||||
isLoaded: true,
|
||||
hasError: false
|
||||
});
|
||||
|
||||
if (this.props.onLoad) {
|
||||
this.props.onLoad();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
style,
|
||||
placeholder,
|
||||
size,
|
||||
lazy,
|
||||
overflow
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
url,
|
||||
hasError,
|
||||
isLoaded
|
||||
} = this.state;
|
||||
|
||||
if (hasError || !url) {
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
style={style}
|
||||
src={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (lazy) {
|
||||
return (
|
||||
<LazyLoad
|
||||
height={size}
|
||||
offset={100}
|
||||
overflow={overflow}
|
||||
placeholder={
|
||||
<img
|
||||
className={className}
|
||||
style={style}
|
||||
src={placeholder}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<img
|
||||
className={className}
|
||||
style={style}
|
||||
src={url}
|
||||
onError={this.onError}
|
||||
onLoad={this.onLoad}
|
||||
rel="noreferrer"
|
||||
/>
|
||||
</LazyLoad>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
style={style}
|
||||
src={isLoaded ? url : placeholder}
|
||||
onError={this.onError}
|
||||
onLoad={this.onLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SeriesImage.propTypes = {
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
coverType: PropTypes.string.isRequired,
|
||||
placeholder: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
lazy: PropTypes.bool.isRequired,
|
||||
overflow: PropTypes.bool.isRequired,
|
||||
onError: PropTypes.func,
|
||||
onLoad: PropTypes.func
|
||||
};
|
||||
|
||||
SeriesImage.defaultProps = {
|
||||
size: 250,
|
||||
lazy: true,
|
||||
overflow: false
|
||||
};
|
||||
|
||||
export default SeriesImage;
|
||||
128
frontend/src/Series/SeriesImage.tsx
Normal file
128
frontend/src/Series/SeriesImage.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import LazyLoad from 'react-lazyload';
|
||||
import { CoverType, Image } from './Series';
|
||||
|
||||
function findImage(images: Image[], coverType: CoverType) {
|
||||
return images.find((image) => image.coverType === coverType);
|
||||
}
|
||||
|
||||
function getUrl(image: Image, coverType: CoverType, size: number) {
|
||||
const imageUrl = image?.url;
|
||||
|
||||
return imageUrl
|
||||
? imageUrl.replace(`${coverType}.jpg`, `${coverType}-${size}.jpg`)
|
||||
: null;
|
||||
}
|
||||
|
||||
export interface SeriesImageProps {
|
||||
className?: string;
|
||||
style?: object;
|
||||
images: Image[];
|
||||
coverType: CoverType;
|
||||
placeholder: string;
|
||||
size?: number;
|
||||
lazy?: boolean;
|
||||
overflow?: boolean;
|
||||
onError?: () => void;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const pixelRatio = Math.max(Math.round(window.devicePixelRatio), 1);
|
||||
|
||||
function SeriesImage({
|
||||
className,
|
||||
style,
|
||||
images,
|
||||
coverType,
|
||||
placeholder,
|
||||
size = 250,
|
||||
lazy = true,
|
||||
overflow = false,
|
||||
onError,
|
||||
onLoad,
|
||||
}: SeriesImageProps) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const image = useRef<Image | null>(null);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
setHasError(false);
|
||||
setIsLoaded(true);
|
||||
onLoad?.();
|
||||
}, [setHasError, setIsLoaded, onLoad]);
|
||||
|
||||
const handleError = useCallback(() => {
|
||||
setHasError(true);
|
||||
setIsLoaded(false);
|
||||
onError?.();
|
||||
}, [setHasError, setIsLoaded, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextImage = findImage(images, coverType);
|
||||
|
||||
if (nextImage && (!image.current || nextImage.url !== image.current.url)) {
|
||||
// Don't reset isLoaded, as we want to immediately try to
|
||||
// show the new image, whether an image was shown previously
|
||||
// or the placeholder was shown.
|
||||
image.current = nextImage;
|
||||
|
||||
setUrl(getUrl(nextImage, coverType, pixelRatio * size));
|
||||
setHasError(false);
|
||||
} else if (!nextImage) {
|
||||
if (image.current) {
|
||||
image.current = null;
|
||||
setUrl(placeholder);
|
||||
setHasError(false);
|
||||
onError?.();
|
||||
}
|
||||
}
|
||||
}, [images, coverType, placeholder, size, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image.current) {
|
||||
onError?.();
|
||||
}
|
||||
// This should only run once when the component mounts,
|
||||
// so we don't need to include the other dependencies.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (hasError || !url) {
|
||||
return <img className={className} style={style} src={placeholder} />;
|
||||
}
|
||||
|
||||
if (lazy) {
|
||||
return (
|
||||
<LazyLoad
|
||||
height={size}
|
||||
offset={100}
|
||||
overflow={overflow}
|
||||
placeholder={
|
||||
<img className={className} style={style} src={placeholder} />
|
||||
}
|
||||
>
|
||||
<img
|
||||
className={className}
|
||||
style={style}
|
||||
src={url}
|
||||
rel="noreferrer"
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
</LazyLoad>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
className={className}
|
||||
style={style}
|
||||
src={isLoaded ? url : placeholder}
|
||||
onError={handleError}
|
||||
onLoad={handleLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesImage;
|
||||
@@ -1,29 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import SeriesImage from './SeriesImage';
|
||||
|
||||
const posterPlaceholder = 'data:image/png;base64,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';
|
||||
|
||||
function SeriesPoster(props) {
|
||||
return (
|
||||
<SeriesImage
|
||||
{...props}
|
||||
coverType="poster"
|
||||
placeholder={posterPlaceholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SeriesPoster.propTypes = {
|
||||
...SeriesImage.propTypes,
|
||||
coverType: PropTypes.string,
|
||||
placeholder: PropTypes.string,
|
||||
overflow: PropTypes.bool,
|
||||
size: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
SeriesPoster.defaultProps = {
|
||||
size: 250
|
||||
};
|
||||
|
||||
export default SeriesPoster;
|
||||
23
frontend/src/Series/SeriesPoster.tsx
Normal file
23
frontend/src/Series/SeriesPoster.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import SeriesImage, { SeriesImageProps } from './SeriesImage';
|
||||
|
||||
const posterPlaceholder =
|
||||
'data:image/png;base64,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';
|
||||
|
||||
interface SeriesPosterProps
|
||||
extends Omit<SeriesImageProps, 'coverType' | 'placeholder'> {
|
||||
size?: 250 | 500;
|
||||
}
|
||||
|
||||
function SeriesPoster({ size = 250, ...otherProps }: SeriesPosterProps) {
|
||||
return (
|
||||
<SeriesImage
|
||||
{...otherProps}
|
||||
size={size}
|
||||
coverType="poster"
|
||||
placeholder={posterPlaceholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SeriesPoster;
|
||||
@@ -1,3 +1,7 @@
|
||||
.saveError {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -5,6 +5,7 @@ interface CssExports {
|
||||
'header': string;
|
||||
'megabytesPerMinute': string;
|
||||
'quality': string;
|
||||
'saveError': string;
|
||||
'sizeLimit': string;
|
||||
'sizeLimitHelpText': string;
|
||||
'sizeLimitHelpTextContainer': string;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import PageSectionContent from 'Components/Page/PageSectionContent';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import QualityDefinitionConnector from './QualityDefinitionConnector';
|
||||
import styles from './QualityDefinitions.css';
|
||||
@@ -15,15 +17,44 @@ class QualityDefinitions extends Component {
|
||||
const {
|
||||
items,
|
||||
advancedSettings,
|
||||
saveError,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
console.log(saveError);
|
||||
|
||||
return (
|
||||
<FieldSet legend={translate('QualityDefinitions')}>
|
||||
<PageSectionContent
|
||||
errorMessage={translate('QualityDefinitionsLoadError')}
|
||||
{...otherProps}
|
||||
>
|
||||
{
|
||||
saveError ?
|
||||
<div className={styles.saveError}>
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('QualityDefinitionsSaveError')}
|
||||
<ul>
|
||||
{
|
||||
Array.isArray(saveError.responseJSON) ?
|
||||
saveError.responseJSON.map((error, index) => {
|
||||
return (
|
||||
<li key={index}>
|
||||
{error.errorMessage}
|
||||
</li>
|
||||
);
|
||||
}) :
|
||||
<li>
|
||||
{
|
||||
JSON.stringify(saveError.responseJSON)
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</Alert>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.quality}>
|
||||
{translate('Quality')}
|
||||
@@ -72,6 +103,7 @@ class QualityDefinitions extends Component {
|
||||
QualityDefinitions.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
saveError: PropTypes.object,
|
||||
defaultProfile: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
advancedSettings: PropTypes.bool.isRequired
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using FluentValidation.TestHelper;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Core.Qualities;
|
||||
using Sonarr.Api.V3.Qualities;
|
||||
|
||||
namespace NzbDrone.Api.Test.v3.Qualities;
|
||||
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class QualityDefinitionResourceValidatorTests
|
||||
{
|
||||
private readonly QualityDefinitionResourceValidator _validator = new ();
|
||||
|
||||
[Test]
|
||||
public void Validate_fails_when_min_size_is_below_min_limit()
|
||||
{
|
||||
var resource = new QualityDefinitionResource { MinSize = QualityDefinitionLimits.Min - 1 };
|
||||
|
||||
var result = _validator.TestValidate(resource);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(r => r.MinSize)
|
||||
.WithErrorCode("GreaterThanOrEqualTo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_fails_when_min_size_is_above_preferred_size()
|
||||
{
|
||||
var resource = new QualityDefinitionResource
|
||||
{
|
||||
MinSize = 10,
|
||||
PreferredSize = 5
|
||||
};
|
||||
|
||||
var result = _validator.TestValidate(resource);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(r => r.MinSize)
|
||||
.WithErrorCode("LessThanOrEqualTo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_passes_when_min_size_is_within_limits()
|
||||
{
|
||||
var resource = new QualityDefinitionResource
|
||||
{
|
||||
MinSize = QualityDefinitionLimits.Min,
|
||||
PreferredSize = QualityDefinitionLimits.Max
|
||||
};
|
||||
|
||||
var result = _validator.TestValidate(resource);
|
||||
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_fails_when_max_size_is_below_preferred_size()
|
||||
{
|
||||
var resource = new QualityDefinitionResource
|
||||
{
|
||||
MaxSize = 5,
|
||||
PreferredSize = 10
|
||||
};
|
||||
|
||||
var result = _validator.TestValidate(resource);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(r => r.MaxSize)
|
||||
.WithErrorCode("GreaterThanOrEqualTo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_fails_when_max_size_exceeds_max_limit()
|
||||
{
|
||||
var resource = new QualityDefinitionResource { MaxSize = QualityDefinitionLimits.Max + 1 };
|
||||
|
||||
var result = _validator.TestValidate(resource);
|
||||
|
||||
result.ShouldHaveValidationErrorFor(r => r.MaxSize)
|
||||
.WithErrorCode("LessThanOrEqualTo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_passes_when_max_size_is_within_limits()
|
||||
{
|
||||
var resource = new QualityDefinitionResource
|
||||
{
|
||||
MaxSize = QualityDefinitionLimits.Max,
|
||||
PreferredSize = QualityDefinitionLimits.Min
|
||||
};
|
||||
|
||||
var result = _validator.TestValidate(resource);
|
||||
|
||||
result.ShouldNotHaveAnyValidationErrors();
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ namespace NzbDrone.Core.ImportLists.Trakt.List
|
||||
{
|
||||
var link = Settings.BaseUrl.Trim();
|
||||
|
||||
link += $"/users/{Settings.Username.Trim()}/lists/{Settings.Listname.ToUrlSlug()}/items/shows?limit={Settings.Limit}";
|
||||
link += $"/users/{Settings.Username.Trim()}/lists/{Settings.Listname.ToUrlSlug()}/items/show,season,episode?limit={Settings.Limit}";
|
||||
|
||||
var request = new ImportListRequest(link, HttpAccept.Json);
|
||||
|
||||
|
||||
@@ -1591,6 +1591,7 @@
|
||||
"QualityCutoffNotMet": "Quality cutoff has not been met",
|
||||
"QualityDefinitions": "Quality Definitions",
|
||||
"QualityDefinitionsLoadError": "Unable to load Quality Definitions",
|
||||
"QualityDefinitionsSaveError": "Unable to save Quality Definitions",
|
||||
"QualityLimitsSeriesRuntimeHelpText": "Limits are automatically adjusted for the series runtime and number of episodes in the file.",
|
||||
"QualityProfile": "Quality Profile",
|
||||
"QualityProfileInUseSeriesListCollection": "Can't delete a quality profile that is attached to a series, list, or collection",
|
||||
|
||||
7
src/NzbDrone.Core/Qualities/QualityDefinitionLimits.cs
Normal file
7
src/NzbDrone.Core/Qualities/QualityDefinitionLimits.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace NzbDrone.Core.Qualities;
|
||||
|
||||
public static class QualityDefinitionLimits
|
||||
{
|
||||
public const int Min = 0;
|
||||
public const int Max = 1000;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Api.V3.Qualities;
|
||||
using NzbDrone.Core.Datastore.Events;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.Qualities;
|
||||
@@ -20,6 +21,9 @@ namespace Sonarr.Api.V3.Qualities
|
||||
: base(signalRBroadcaster)
|
||||
{
|
||||
_qualityDefinitionService = qualityDefinitionService;
|
||||
|
||||
SharedValidator.RuleFor(c => c)
|
||||
.SetValidator(new QualityDefinitionResourceValidator());
|
||||
}
|
||||
|
||||
[RestPutById]
|
||||
@@ -55,6 +59,12 @@ namespace Sonarr.Api.V3.Qualities
|
||||
.ToResource());
|
||||
}
|
||||
|
||||
[HttpGet("limits")]
|
||||
public ActionResult<QualityDefinitionLimitsResource> GetLimits()
|
||||
{
|
||||
return Ok(new QualityDefinitionLimitsResource());
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public void Handle(CommandExecutedEvent message)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
||||
namespace NzbDrone.Api.V3.Qualities
|
||||
{
|
||||
public class QualityDefinitionLimitsResource
|
||||
{
|
||||
public int Min { get; set; } = QualityDefinitionLimits.Min;
|
||||
public int Max { get; set; } = QualityDefinitionLimits.Max;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
using NzbDrone.Core.Qualities;
|
||||
|
||||
namespace Sonarr.Api.V3.Qualities;
|
||||
|
||||
public class QualityDefinitionResourceValidator : AbstractValidator<QualityDefinitionResource>
|
||||
{
|
||||
public QualityDefinitionResourceValidator()
|
||||
{
|
||||
When(c => c.MinSize is not null, () =>
|
||||
{
|
||||
RuleFor(c => c.MinSize)
|
||||
.GreaterThanOrEqualTo(QualityDefinitionLimits.Min)
|
||||
.WithErrorCode("GreaterThanOrEqualTo")
|
||||
.LessThanOrEqualTo(c => c.PreferredSize ?? QualityDefinitionLimits.Max)
|
||||
.WithErrorCode("LessThanOrEqualTo");
|
||||
});
|
||||
|
||||
When(c => c.MaxSize is not null, () =>
|
||||
{
|
||||
RuleFor(c => c.MaxSize)
|
||||
.GreaterThanOrEqualTo(c => c.PreferredSize ?? QualityDefinitionLimits.Min)
|
||||
.WithErrorCode("GreaterThanOrEqualTo")
|
||||
.LessThanOrEqualTo(QualityDefinitionLimits.Max)
|
||||
.WithErrorCode("LessThanOrEqualTo");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8513,6 +8513,11 @@
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"lastSearchTime": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"runtime": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
|
||||
@@ -70,11 +70,15 @@ namespace Sonarr.Http.REST
|
||||
var skipValidate = skipAttribute?.Skip ?? false;
|
||||
var skipShared = skipAttribute?.SkipShared ?? false;
|
||||
|
||||
if (Request.Method == "POST" || Request.Method == "PUT")
|
||||
if (Request.Method is "POST" or "PUT")
|
||||
{
|
||||
var resourceArgs = context.ActionArguments.Values.Where(x => x.GetType() == typeof(TResource))
|
||||
.Select(x => x as TResource)
|
||||
.ToList();
|
||||
var resourceArgs = context.ActionArguments.Values
|
||||
.SelectMany(x => x switch
|
||||
{
|
||||
TResource single => new[] { single },
|
||||
IEnumerable<TResource> multiple => multiple,
|
||||
_ => Enumerable.Empty<TResource>()
|
||||
});
|
||||
|
||||
foreach (var resource in resourceArgs)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user