1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2026-04-05 19:24:17 -04:00

Compare commits

..

13 Commits

Author SHA1 Message Date
Mark McDowall
974c4a601b Show save error in UI 2024-09-06 20:53:55 -07:00
Mark McDowall
9549038121 Convert QualityDefinitionLimits to static 2024-09-06 20:35:19 -07:00
Robert Dailey
2f193ac58a Add API validation and tests for quality limits 2024-09-06 09:32:11 -05:00
Robert Dailey
e893ca4f1c Support validation of collections in RestController 2024-09-06 09:32:11 -05:00
Robert Dailey
039d7775ed feat: Shift quality definition limits management to the backend
This update moves the minimum, maximum, and preferred quality limits to
the backend, accessible via the new `/qualitydefinition/limits`
endpoint. This change improves support for unofficial Sonarr API clients
and enables a more flexible frontend.
2024-09-06 09:32:11 -05:00
Robert Dailey
87bd5e62f2 Add .idea directory to gitignore
For users of the Jetbrains IDEs, the `.idea` directory isn't strictly
necessary for version control. It's better to ignore it than tie a repo
to specific tooling.
2024-09-06 09:32:11 -05:00
Mark McDowall
a9072ac460 Convert Progress Bars to TypeScript 2024-09-03 20:19:47 -07:00
Mark McDowall
55aaaa5c40 New: Add MDBList link to series details
Closes #7162
2024-09-03 23:19:36 -04:00
Mark McDowall
ee99c3895d Convert series images to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
e1e10e195c Convert NoSeries to TypeScript 2024-09-03 20:19:12 -07:00
Mark McDowall
0b9a212f33 Fixed: Links tooltip closing too quickly 2024-09-03 20:19:12 -07:00
Mark McDowall
0e384ee3aa New: Include seasons and episodes in Trakt import lists
Closes #7137
2024-09-03 20:18:59 -07:00
Sonarr
d903529389 Automated API Docs update
ignore-downstream
2024-09-02 13:27:43 -07:00
27 changed files with 631 additions and 576 deletions

3
.gitignore vendored
View File

@@ -162,3 +162,6 @@ src/.idea/
# API doc generation
.config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

@@ -1,3 +1,7 @@
.saveError {
margin-bottom: 20px;
}
.header {
display: flex;
font-weight: bold;

View File

@@ -5,6 +5,7 @@ interface CssExports {
'header': string;
'megabytesPerMinute': string;
'quality': string;
'saveError': string;
'sizeLimit': string;
'sizeLimitHelpText': string;
'sizeLimitHelpTextContainer': string;

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace NzbDrone.Core.Qualities;
public static class QualityDefinitionLimits
{
public const int Min = 0;
public const int Max = 1000;
}

View File

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

View File

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

View File

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

View File

@@ -8513,6 +8513,11 @@
"format": "date-time",
"nullable": true
},
"lastSearchTime": {
"type": "string",
"format": "date-time",
"nullable": true
},
"runtime": {
"type": "integer",
"format": "int32"

View File

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