Compare commits

..

No commits in common. "develop" and "v2.8.0.4431" have entirely different histories.

308 changed files with 3194 additions and 6886 deletions

View file

@ -6,7 +6,7 @@
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "20",
"version": "16",
"nvmVersion": "latest"
}
},

View file

@ -60,7 +60,6 @@ body:
- Master
- Develop
- Nightly
- Plugins (experimental)
- Other (This issue will be closed)
validations:
required: true

28
.gitignore vendored
View file

@ -158,12 +158,34 @@ Thumbs.db
/tools/Addins/*
packages.config.md5sum
# Common IntelliJ Platform excludes
# User specific
**/.idea/**/workspace.xml
**/.idea/**/tasks.xml
**/.idea/shelf/*
**/.idea/dictionaries
**/.idea/.idea.Radarr.Posix
**/.idea/.idea.Radarr.Windows
# Sensitive or high-churn files
**/.idea/**/dataSources/
**/.idea/**/dataSources.ids
**/.idea/**/dataSources.xml
**/.idea/**/dataSources.local.xml
**/.idea/**/sqlDataSources.xml
**/.idea/**/dynamic.xml
# Rider
# Rider auto-generates .iml files, and contentModel.xml
**/.idea/**/*.iml
**/.idea/**/contentModel.xml
**/.idea/**/modules.xml
# ignore node_modules symlink
node_modules
node_modules.nosync
# API doc generation
.config/
# Ignore Jetbrains IntelliJ Workspace Directories
.idea/

View file

@ -9,9 +9,6 @@
Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
> [!WARNING]
> NOTICE - The Lidarr Metadata Server is currently down impacting adding artists, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for detaila.
## Major Features Include:
* Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.

View file

@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '2.13.3'
majorVersion: '2.8.0'
minorVersion: $[counter('minorVersion', 1076)]
lidarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
@ -19,7 +19,7 @@ variables:
nodeVersion: '20.X'
innoVersion: '6.2.0'
windowsImage: 'windows-2022'
linuxImage: 'ubuntu-22.04'
linuxImage: 'ubuntu-20.04'
macImage: 'macOS-13'
trigger:
@ -1120,19 +1120,19 @@ stages:
vmImage: ${{ variables.windowsImage }}
steps:
- checkout: self # Need history for Sonar analysis
- task: SonarCloudPrepare@3
- task: SonarCloudPrepare@2
env:
SONAR_SCANNER_OPTS: ''
inputs:
SonarCloud: 'SonarCloud'
organization: 'lidarr'
scannerMode: 'cli'
scannerMode: 'CLI'
configMode: 'manual'
cliProjectKey: 'lidarr_Lidarr.UI'
cliProjectName: 'LidarrUI'
cliProjectVersion: '$(lidarrVersion)'
cliSources: './frontend'
- task: SonarCloudAnalyze@3
- task: SonarCloudAnalyze@2
- job: Api_Docs
displayName: API Docs
@ -1208,12 +1208,12 @@ stages:
submodules: true
- powershell: Set-Service SCardSvr -StartupType Manual
displayName: Enable Windows Test Service
- task: SonarCloudPrepare@3
- task: SonarCloudPrepare@2
condition: eq(variables['System.PullRequest.IsFork'], 'False')
inputs:
SonarCloud: 'SonarCloud'
organization: 'lidarr'
scannerMode: 'dotnet'
scannerMode: 'MSBuild'
projectKey: 'lidarr_Lidarr'
projectName: 'Lidarr'
projectVersion: '$(lidarrVersion)'
@ -1226,10 +1226,10 @@ stages:
./build.sh --backend -f net6.0 -r win-x64
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
displayName: Coverage Unit Tests
- task: SonarCloudAnalyze@3
- task: SonarCloudAnalyze@2
condition: eq(variables['System.PullRequest.IsFork'], 'False')
displayName: Publish SonarCloud Results
- task: reportgenerator@5.3.11
- task: reportgenerator@5
displayName: Generate Coverage Report
inputs:
reports: '$(Build.SourcesDirectory)/CoverageResults/**/coverage.opencover.xml'

15
docs.sh
View file

@ -1,18 +1,13 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE"
RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE"
RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE"
RUNTIME="osx-x64"
else
echo "Platform must be provided as first argument: Windows, Linux or Mac"
echo "Platform must be provided as first arguement: Windows, Linux or Mac"
exit 1
fi
@ -40,7 +35,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
dotnet new tool-manifest
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 &
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/$application" v1 &
sleep 45

View file

@ -26,7 +26,6 @@ module.exports = (env) => {
const config = {
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'eval-source-map',
target: 'web',
stats: {
children: false
@ -135,12 +134,6 @@ 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')
}
]
}
@ -188,7 +181,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.41'
corejs: 3
}
]
]

View file

@ -172,8 +172,7 @@ function HistoryDetails(props) {
if (eventType === 'downloadFailed') {
const {
message,
indexer
message
} = data;
return (
@ -193,14 +192,6 @@ function HistoryDetails(props) {
null
}
{
indexer ? (
<DescriptionListItem
title={translate('Indexer')}
data={indexer}
/>
) : null}
{
message ?
<DescriptionListItem

View file

@ -57,40 +57,30 @@ function QueueStatusCell(props) {
if (status === 'paused') {
iconName = icons.PAUSED;
title = translate('Paused');
title = 'Paused';
}
if (status === 'queued') {
iconName = icons.QUEUED;
title = translate('Queued');
title = 'Queued';
}
if (status === 'completed') {
iconName = icons.DOWNLOADED;
title = translate('Downloaded');
if (trackedDownloadState === 'importBlocked') {
title += ` - ${translate('UnableToImportAutomatically')}`;
iconKind = kinds.WARNING;
}
if (trackedDownloadState === 'importFailed') {
title += ` - ${translate('ImportFailed', { sourceTitle })}`;
iconKind = kinds.WARNING;
}
title = 'Downloaded';
if (trackedDownloadState === 'importPending') {
title += ` - ${translate('WaitingToImport')}`;
title += ' - Waiting to Import';
iconKind = kinds.PURPLE;
}
if (trackedDownloadState === 'importing') {
title += ` - ${translate('Importing')}`;
title += ' - Importing';
iconKind = kinds.PURPLE;
}
if (trackedDownloadState === 'failedPending') {
title += ` - ${translate('WaitingToProcess')}`;
title += ' - Waiting to Process';
iconKind = kinds.DANGER;
}
}
@ -101,38 +91,36 @@ function QueueStatusCell(props) {
if (status === 'delay') {
iconName = icons.PENDING;
title = translate('Pending');
title = 'Pending';
}
if (status === 'downloadClientUnavailable') {
iconName = icons.PENDING;
iconKind = kinds.WARNING;
title = translate('PendingDownloadClientUnavailable');
title = 'Pending - Download client is unavailable';
}
if (status === 'failed') {
iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER;
title = translate('DownloadFailed');
title = 'Download failed';
}
if (status === 'warning') {
iconName = icons.DOWNLOADING;
iconKind = kinds.WARNING;
const warningMessage =
errorMessage || translate('CheckDownloadClientForDetails');
title = translate('DownloadWarning', { warningMessage });
title = `Download warning: ${errorMessage || 'check download client for more details'}`;
}
if (hasError) {
if (status === 'completed') {
iconName = icons.DOWNLOAD;
iconKind = kinds.DANGER;
title = translate('ImportFailed', { sourceTitle });
title = `Import failed: ${sourceTitle}`;
} else {
iconName = icons.DOWNLOADING;
iconKind = kinds.DANGER;
title = translate('DownloadFailed');
title = 'Download failed';
}
}

View file

@ -8,17 +8,17 @@ function ArtistMonitorNewItemsOptionsPopoverContent() {
<DescriptionList>
<DescriptionListItem
title={translate('AllAlbums')}
data={translate('MonitorAllAlbums')}
data="Monitor all new albums"
/>
<DescriptionListItem
title={translate('NewAlbums')}
data={translate('MonitorNewAlbumsData')}
data="Monitor new albums released after the newest existing album"
/>
<DescriptionListItem
title={translate('None')}
data={translate('MonitorNoAlbumsData')}
data="Don't monitor any new albums"
/>
</DescriptionList>
);

View file

@ -205,7 +205,6 @@ class AlbumDetails extends Component {
isFetching,
isPopulated,
albumsError,
tracksError,
trackFilesError,
hasTrackFiles,
shortDateFormat,
@ -553,9 +552,8 @@ class AlbumDetails extends Component {
<div className={styles.contentContainer}>
{
!isPopulated && !albumsError && !tracksError && !trackFilesError ?
<LoadingIndicator /> :
null
!isPopulated && !albumsError && !trackFilesError &&
<LoadingIndicator />
}
{
@ -566,14 +564,6 @@ class AlbumDetails extends Component {
null
}
{
!isFetching && tracksError ?
<Alert kind={kinds.DANGER}>
{translate('TracksLoadError')}
</Alert> :
null
}
{
!isFetching && trackFilesError ?
<Alert kind={kinds.DANGER}>
@ -602,14 +592,6 @@ class AlbumDetails extends Component {
</div>
}
{
isPopulated && !media.length ?
<Alert kind={kinds.WARNING}>
{translate('NoMediumInformation')}
</Alert> :
null
}
</div>
<OrganizePreviewModalConnector
@ -704,7 +686,6 @@ AlbumDetails.propTypes = {
AlbumDetails.defaultProps = {
secondaryTypes: [],
statistics: {},
isSaving: false
};

View file

@ -15,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import Popover from 'Components/Tooltip/Popover';
import { icons, inputTypes, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './EditArtistModalContent.css';
@ -93,7 +93,7 @@ class EditArtistModalContent extends Component {
<ModalBody>
<Form {...otherProps}>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('Monitored')}
</FormLabel>
@ -107,10 +107,9 @@ class EditArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('MonitorNewItems')}
<Popover
anchor={
<Icon
@ -133,7 +132,7 @@ class EditArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('QualityProfile')}
</FormLabel>
@ -147,10 +146,10 @@ class EditArtistModalContent extends Component {
</FormGroup>
{
showMetadataProfile ?
<FormGroup size={sizes.MEDIUM}>
showMetadataProfile &&
<FormGroup>
<FormLabel>
{translate('MetadataProfile')}
Metadata Profile
<Popover
anchor={
@ -174,11 +173,10 @@ class EditArtistModalContent extends Component {
{...metadataProfileId}
onChange={onInputChange}
/>
</FormGroup> :
null
</FormGroup>
}
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('Path')}
</FormLabel>
@ -191,7 +189,7 @@ class EditArtistModalContent extends Component {
/>
</FormGroup>
<FormGroup size={sizes.MEDIUM}>
<FormGroup>
<FormLabel>
{translate('Tags')}
</FormLabel>
@ -211,7 +209,7 @@ class EditArtistModalContent extends Component {
kind={kinds.DANGER}
onPress={onDeleteArtistPress}
>
{translate('Delete')}
Delete
</Button>
<Button

View file

@ -164,7 +164,7 @@ class CalendarLinkModalContent extends Component {
type={inputTypes.TAG}
name="tags"
value={tags}
helpText={translate('ICalTagsArtistHelpText')}
helpText={translate('TagsHelpText')}
onChange={this.onInputChange}
/>
</FormGroup>

View file

@ -3,8 +3,8 @@ import React, { Component } from 'react';
import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning}
kind={kinds.WARNING}
>
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/lidarr/faq#why-cant-lidarr-see-my-files-on-a-remote-server' })} />
Mapped network drives are not available when running as a Windows Service, see the <Link className={styles.faqLink} to="https://wiki.servarr.com/lidarr/faq">FAQ</Link> for more information.
</Alert>
}

View file

@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [
{
id: 7,
get name() {
return translate('ImportCompleteFailed');
return translate('ImportFailed');
},
},
{

View file

@ -20,8 +20,6 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
import TextInput from './TextInput';
import styles from './EnhancedSelectInput.css';
const MINIMUM_DISTANCE_FROM_EDGE = 10;
function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
}
@ -139,9 +137,18 @@ class EnhancedSelectInput extends Component {
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data;
};
@ -450,10 +457,6 @@ class EnhancedSelectInput extends Component {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
}
}}
>

View file

@ -49,12 +49,12 @@ function getComponent(type) {
case inputTypes.DEVICE:
return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.PLAYLIST:
return PlaylistInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput;

View file

@ -0,0 +1,156 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
class KeyValueListInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFocused: false
};
}
//
// Listeners
onItemChange = (index, itemValue) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
if (index == null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({
name,
value: newValue
});
};
onRemoveItem = (index) => {
const {
name,
value,
onChange
} = this.props;
const newValue = [...value];
newValue.splice(index, 1);
onChange({
name,
value: newValue
});
};
onFocus = () => {
this.setState({
isFocused: true
});
};
onBlur = () => {
this.setState({
isFocused: false
});
const {
name,
value,
onChange
} = this.props;
const newValue = value.reduce((acc, v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({
name,
value: newValue
});
}
};
//
// Render
render() {
const {
className,
value,
keyPlaceholder,
valuePlaceholder,
hasError,
hasWarning
} = this.props;
const { isFocused } = this.state;
return (
<div className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{
[...value, { key: '', value: '' }].map((v, index) => {
return (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={this.onItemChange}
onRemove={this.onRemoveItem}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
);
})
}
</div>
);
}
}
KeyValueListInput.propTypes = {
className: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.arrayOf(PropTypes.object).isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
keyPlaceholder: PropTypes.string,
valuePlaceholder: PropTypes.string,
onChange: PropTypes.func.isRequired
};
KeyValueListInput.defaultProps = {
className: styles.inputContainer,
value: []
};
export default KeyValueListInput;

View file

@ -1,104 +0,0 @@
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { InputOnChange } from 'typings/inputs';
import KeyValueListInputItem from './KeyValueListInputItem';
import styles from './KeyValueListInput.css';
interface KeyValue {
key: string;
value: string;
}
export interface KeyValueListInputProps {
className?: string;
name: string;
value: KeyValue[];
hasError?: boolean;
hasWarning?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
onChange: InputOnChange<KeyValue[]>;
}
function KeyValueListInput({
className = styles.inputContainer,
name,
value = [],
hasError = false,
hasWarning = false,
keyPlaceholder,
valuePlaceholder,
onChange,
}: KeyValueListInputProps): JSX.Element {
const [isFocused, setIsFocused] = useState(false);
const handleItemChange = useCallback(
(index: number | null, itemValue: KeyValue) => {
const newValue = [...value];
if (index === null) {
newValue.push(itemValue);
} else {
newValue.splice(index, 1, itemValue);
}
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const handleRemoveItem = useCallback(
(index: number) => {
const newValue = [...value];
newValue.splice(index, 1);
onChange({ name, value: newValue });
},
[value, name, onChange]
);
const onFocus = useCallback(() => setIsFocused(true), []);
const onBlur = useCallback(() => {
setIsFocused(false);
const newValue = value.reduce((acc: KeyValue[], v) => {
if (v.key || v.value) {
acc.push(v);
}
return acc;
}, []);
if (newValue.length !== value.length) {
onChange({ name, value: newValue });
}
}, [value, name, onChange]);
return (
<div
className={classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning
)}
>
{[...value, { key: '', value: '' }].map((v, index) => (
<KeyValueListInputItem
key={index}
index={index}
keyValue={v.key}
value={v.value}
keyPlaceholder={keyPlaceholder}
valuePlaceholder={valuePlaceholder}
isNew={index === value.length}
onChange={handleItemChange}
onRemove={handleRemoveItem}
onFocus={onFocus}
onBlur={onBlur}
/>
))}
</div>
);
}
export default KeyValueListInput;

View file

@ -5,19 +5,13 @@
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
}
.keyInputWrapper {
.inputWrapper {
flex: 1 0 0;
}
.valueInputWrapper {
flex: 1 0 0;
min-width: 40px;
}
.buttonWrapper {
flex: 0 0 22px;
}
@ -26,10 +20,6 @@
.valueInput {
width: 100%;
border: none;
background-color: transparent;
background-color: var(--inputBackgroundColor);
color: var(--textColor);
&::placeholder {
color: var(--helpTextColor);
}
}

View file

@ -2,11 +2,10 @@
// Please do not change this file!
interface CssExports {
'buttonWrapper': string;
'inputWrapper': string;
'itemContainer': string;
'keyInput': string;
'keyInputWrapper': string;
'valueInput': string;
'valueInputWrapper': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -0,0 +1,124 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
class KeyValueListInputItem extends Component {
//
// Listeners
onKeyChange = ({ value: keyValue }) => {
const {
index,
value,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onValueChange = ({ value }) => {
// TODO: Validate here or validate at a lower level component
const {
index,
keyValue,
onChange
} = this.props;
onChange(index, { key: keyValue, value });
};
onRemovePress = () => {
const {
index,
onRemove
} = this.props;
onRemove(index);
};
onFocus = () => {
this.props.onFocus();
};
onBlur = () => {
this.props.onBlur();
};
//
// Render
render() {
const {
keyValue,
value,
keyPlaceholder,
valuePlaceholder,
isNew
} = this.props;
return (
<div className={styles.itemContainer}>
<div className={styles.inputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={this.onKeyChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.inputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={this.onValueChange}
onFocus={this.onFocus}
onBlur={this.onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{
isNew ?
null :
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={this.onRemovePress}
/>
}
</div>
</div>
);
}
}
KeyValueListInputItem.propTypes = {
index: PropTypes.number,
keyValue: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
keyPlaceholder: PropTypes.string.isRequired,
valuePlaceholder: PropTypes.string.isRequired,
isNew: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
onRemove: PropTypes.func.isRequired,
onFocus: PropTypes.func.isRequired,
onBlur: PropTypes.func.isRequired
};
KeyValueListInputItem.defaultProps = {
keyPlaceholder: 'Key',
valuePlaceholder: 'Value'
};
export default KeyValueListInputItem;

View file

@ -1,89 +0,0 @@
import React, { useCallback } from 'react';
import IconButton from 'Components/Link/IconButton';
import { icons } from 'Helpers/Props';
import TextInput from './TextInput';
import styles from './KeyValueListInputItem.css';
interface KeyValueListInputItemProps {
index: number;
keyValue: string;
value: string;
keyPlaceholder?: string;
valuePlaceholder?: string;
isNew: boolean;
onChange: (index: number, itemValue: { key: string; value: string }) => void;
onRemove: (index: number) => void;
onFocus: () => void;
onBlur: () => void;
}
function KeyValueListInputItem({
index,
keyValue,
value,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
isNew,
onChange,
onRemove,
onFocus,
onBlur,
}: KeyValueListInputItemProps): JSX.Element {
const handleKeyChange = useCallback(
({ value: keyValue }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, value, onChange]
);
const handleValueChange = useCallback(
({ value }: { value: string }) => {
onChange(index, { key: keyValue, value });
},
[index, keyValue, onChange]
);
const handleRemovePress = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
return (
<div className={styles.itemContainer}>
<div className={styles.keyInputWrapper}>
<TextInput
className={styles.keyInput}
name="key"
value={keyValue}
placeholder={keyPlaceholder}
onChange={handleKeyChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.valueInputWrapper}>
<TextInput
className={styles.valueInput}
name="value"
value={value}
placeholder={valuePlaceholder}
onChange={handleValueChange}
onFocus={onFocus}
onBlur={onBlur}
/>
</div>
<div className={styles.buttonWrapper}>
{isNew ? null : (
<IconButton
name={icons.REMOVE}
tabIndex={-1}
onPress={handleRemovePress}
/>
)}
</div>
</div>
);
}
export default KeyValueListInputItem;

View file

@ -14,8 +14,6 @@ function getType({ type, selectOptionsProviderAction }) {
return inputTypes.CHECK;
case 'device':
return inputTypes.DEVICE;
case 'keyValueList':
return inputTypes.KEY_VALUE_LIST;
case 'playlist':
return inputTypes.PLAYLIST;
case 'password':

View file

@ -83,6 +83,13 @@
}
@media only screen and (max-width: $breakpointMedium) {
.modal.small,
.modal.medium {
width: 90%;
}
}
@media only screen and (max-width: $breakpointSmall) {
.modalContainer {
position: fixed;
}

View file

@ -224,58 +224,10 @@ class SignalRConnector extends Component {
repopulatePage('trackFileUpdated');
};
handleDownloadclient = ({ action, resource }) => {
const section = 'settings.downloadClients';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleHealth = () => {
this.props.dispatchFetchHealth();
};
handleImportlist = ({ action, resource }) => {
const section = 'settings.importLists';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleIndexer = ({ action, resource }) => {
const section = 'settings.indexers';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleMetadata = ({ action, resource }) => {
const section = 'settings.metadata';
if (action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
}
};
handleNotification = ({ action, resource }) => {
const section = 'settings.notifications';
if (action === 'created' || action === 'updated') {
this.props.dispatchUpdateItem({ section, ...resource });
} else if (action === 'deleted') {
this.props.dispatchRemoveItem({ section, id: resource.id });
}
};
handleArtist = (body) => {
const action = body.action;
const section = 'artist';

View file

@ -4,7 +4,7 @@
line-height: 1.52857143;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.cell {
white-space: nowrap;
}

View file

@ -7,7 +7,7 @@
white-space: nowrap;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.cell {
white-space: nowrap;
}

View file

@ -10,7 +10,7 @@
border-collapse: collapse;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.tableContainer {
min-width: 100%;
width: fit-content;

View file

@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.headerCell {
white-space: nowrap;
}

View file

@ -60,7 +60,7 @@
height: 25px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.pager {
flex-wrap: wrap;
}

View file

@ -9,7 +9,7 @@
margin-left: 10px;
}
@media only screen and (max-width: $breakpointMedium) {
@media only screen and (max-width: $breakpointSmall) {
.headerCell {
white-space: nowrap;
}

View file

@ -0,0 +1,9 @@
<?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

@ -0,0 +1,19 @@
{
"name": "Lidarr",
"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

@ -1,11 +0,0 @@
<?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

@ -1,19 +0,0 @@
{
"name": "__INSTANCE_NAME__",
"icons": [
{
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "__URL_BASE__/",
"theme_color": "#3a3f51",
"background_color": "#3a3f51",
"display": "standalone"
}

View file

@ -2,8 +2,8 @@ export const AUTO_COMPLETE = 'autoComplete';
export const CAPTCHA = 'captcha';
export const CHECK = 'check';
export const DEVICE = 'device';
export const KEY_VALUE_LIST = 'keyValueList';
export const PLAYLIST = 'playlist';
export const KEY_VALUE_LIST = 'keyValueList';
export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect';
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
export const FLOAT = 'float';
@ -34,8 +34,8 @@ export const all = [
CAPTCHA,
CHECK,
DEVICE,
KEY_VALUE_LIST,
PLAYLIST,
KEY_VALUE_LIST,
MONITOR_ALBUMS_SELECT,
MONITOR_NEW_ITEMS_SELECT,
FLOAT,

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
@ -131,8 +130,7 @@ class AddNewItem extends Component {
<div className={styles.helpText}>
{translate('FailedLoadingSearchResults')}
</div>
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
<div>{getErrorMessage(error)}</div>
</div> : null
}

View file

@ -3,7 +3,6 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditCustomFormatModal from './EditCustomFormatModal';
import EditCustomFormatModalContentConnector from './EditCustomFormatModalContentConnector';
function mapStateToProps() {
return {};
@ -37,7 +36,6 @@ class EditCustomFormatModalConnector extends Component {
}
EditCustomFormatModalConnector.propTypes = {
...EditCustomFormatModalContentConnector.propTypes,
onModalClose: PropTypes.func.isRequired,
clearPendingChanges: PropTypes.func.isRequired
};

View file

@ -10,11 +10,11 @@ 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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,
@ -34,7 +34,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@ -47,15 +47,12 @@ const COLUMNS: Column[] = [
isSortable: true,
isVisible: true,
},
{
name: 'actions',
label: '',
isVisible: true,
},
];
interface ManageCustomFormatsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageCustomFormatsModalContent(

View file

@ -4,9 +4,3 @@
word-break: break-all;
}
.actions {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 40px;
}

View file

@ -1,7 +1,6 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'actions': string;
'includeCustomFormatWhenRenaming': string;
'name': string;
}

View file

@ -1,18 +1,10 @@
import React, { useCallback, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import IconButton from 'Components/Link/IconButton';
import ConfirmModal from 'Components/Modal/ConfirmModal';
import React, { useCallback } from 'react';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import Column from 'Components/Table/Column';
import TableRow from 'Components/Table/TableRow';
import { icons } from 'Helpers/Props';
import { deleteCustomFormat } from 'Store/Actions/settingsActions';
import { SelectStateInputProps } from 'typings/props';
import translate from 'Utilities/String/translate';
import EditCustomFormatModalConnector from '../EditCustomFormatModalConnector';
import styles from './ManageCustomFormatsModalRow.css';
interface ManageCustomFormatsModalRowProps {
@ -24,15 +16,6 @@ interface ManageCustomFormatsModalRowProps {
onSelectedChange(result: SelectStateInputProps): void;
}
function isDeletingSelector() {
return createSelector(
(state: AppState) => state.settings.customFormats.isDeleting,
(isDeleting) => {
return isDeleting;
}
);
}
function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
const {
id,
@ -42,16 +25,7 @@ function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
onSelectedChange,
} = props;
const dispatch = useDispatch();
const isDeleting = useSelector(isDeletingSelector());
const [isEditCustomFormatModalOpen, setIsEditCustomFormatModalOpen] =
useState(false);
const [isDeleteCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen] =
useState(false);
const handlelectedChange = useCallback(
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
@ -60,33 +34,12 @@ function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
[onSelectedChange]
);
const handleEditCustomFormatModalOpen = useCallback(() => {
setIsEditCustomFormatModalOpen(true);
}, [setIsEditCustomFormatModalOpen]);
const handleEditCustomFormatModalClose = useCallback(() => {
setIsEditCustomFormatModalOpen(false);
}, [setIsEditCustomFormatModalOpen]);
const handleDeleteCustomFormatPress = useCallback(() => {
setIsEditCustomFormatModalOpen(false);
setIsDeleteCustomFormatModalOpen(true);
}, [setIsEditCustomFormatModalOpen, setIsDeleteCustomFormatModalOpen]);
const handleDeleteCustomFormatModalClose = useCallback(() => {
setIsDeleteCustomFormatModalOpen(false);
}, [setIsDeleteCustomFormatModalOpen]);
const handleConfirmDeleteCustomFormat = useCallback(() => {
dispatch(deleteCustomFormat({ id }));
}, [id, dispatch]);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={handlelectedChange}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
@ -94,31 +47,6 @@ function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
<TableRowCell className={styles.includeCustomFormatWhenRenaming}>
{includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')}
</TableRowCell>
<TableRowCell className={styles.actions}>
<IconButton
name={icons.EDIT}
onPress={handleEditCustomFormatModalOpen}
/>
</TableRowCell>
<EditCustomFormatModalConnector
id={id}
isOpen={isEditCustomFormatModalOpen}
onModalClose={handleEditCustomFormatModalClose}
onDeleteCustomFormatPress={handleDeleteCustomFormatPress}
/>
<ConfirmModal
isOpen={isDeleteCustomFormatModalOpen}
kind="danger"
title={translate('DeleteCustomFormat')}
message={translate('DeleteCustomFormatMessageText', { name })}
confirmLabel={translate('Delete')}
isSpinning={isDeleting}
onConfirm={handleConfirmDeleteCustomFormat}
onCancel={handleDeleteCustomFormatModalClose}
/>
</TableRow>
);
}

View file

@ -12,7 +12,7 @@ function ManageCustomFormatsToolbarButton() {
return (
<>
<PageToolbarButton
label={translate('ManageFormats')}
label={translate('ManageCustomFormats')}
iconName={icons.MANAGE}
onPress={openManageModal}
/>

View file

@ -7,8 +7,8 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
@ -52,13 +52,12 @@ function EditSpecificationModalContent(props) {
fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
<Alert kind={kinds.INFO}>
<div>
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
<div dangerouslySetInnerHTML={{ __html: 'This condition matches using Regular Expressions. Note that the characters <code>\\^$.|?*+()[{</code> have special meanings and need escaping with a <code>\\</code>' }} />
{'More details'} <Link to="https://www.regular-expressions.info/tutorial.html">{'Here'}</Link>
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
{'Regular expressions can be tested '}
<Link to="http://regexstorm.net/tester">Here</Link>
</div>
</Alert>
}

View file

@ -10,11 +10,11 @@ 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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteDownloadClients,
bulkEditDownloadClients,
@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageDownloadClientsModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@ -82,6 +82,8 @@ const COLUMNS: Column[] = [
interface ManageDownloadClientsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageDownloadClientsModalContent(

View file

@ -292,7 +292,7 @@ function EditImportListModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('ImportListTagsHelpText')}
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>

View file

@ -10,11 +10,11 @@ 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 Column from 'Components/Table/Column';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteIndexers,
bulkEditIndexers,
@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageIndexersModalRow
>['onSelectedChange'];
const COLUMNS: Column[] = [
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
@ -82,6 +82,8 @@ const COLUMNS: Column[] = [
interface ManageIndexersModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {

View file

@ -94,9 +94,9 @@ class RootFolder extends Component {
<ConfirmModal
isOpen={this.state.isDeleteRootFolderModalOpen}
kind={kinds.DANGER}
title={translate('RemoveRootFolder')}
message={translate('RemoveRootFolderArtistsMessageText', { name })}
confirmLabel={translate('Remove')}
title={translate('DeleteRootFolder')}
message={translate('DeleteRootFolderMessageText', { name })}
confirmLabel={translate('Delete')}
onConfirm={this.onConfirmDeleteRootFolder}
onCancel={this.onDeleteRootFolderModalClose}
/>

View file

@ -105,7 +105,7 @@ function EditNotificationModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('NotificationsTagsArtistHelpText')}
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>

View file

@ -87,9 +87,9 @@ function EditDelayProfileModalContent(props) {
{
!isFetching && !!error ?
<Alert kind={kinds.DANGER}>
{translate('AddDelayProfileError')}
</Alert> :
<div>
{translate('UnableToAddANewQualityProfilePleaseTryAgain')}
</div> :
null
}
@ -186,7 +186,7 @@ function EditDelayProfileModalContent(props) {
{
id === 1 ?
<Alert>
{translate('DefaultDelayProfileArtist')}
{translate('DefaultDelayProfileHelpText')}
</Alert> :
<FormGroup>
@ -196,7 +196,7 @@ function EditDelayProfileModalContent(props) {
type={inputTypes.TAG}
name="tags"
{...tags}
helpText={translate('DelayProfileArtistTagsHelpText')}
helpText={translate('TagsHelpText')}
onChange={onInputChange}
/>
</FormGroup>

View file

@ -119,7 +119,7 @@ function EditReleaseProfileModalContent(props) {
<FormInputGroup
type={inputTypes.TAG}
name="tags"
helpText={translate('ReleaseProfileTagArtistHelpText')}
helpText={translate('TagsHelpText')}
{...tags}
onChange={onInputChange}
/>

View file

@ -24,7 +24,7 @@
height: 20px;
}
.track {
.bar {
top: 9px;
margin: 0 5px;
height: 3px;
@ -36,7 +36,7 @@
}
}
.thumb {
.handle {
top: 1px;
z-index: 0 !important;
width: 18px;

View file

@ -1,6 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'bar': string;
'handle': string;
'kilobitsPerSecond': string;
'quality': string;
'qualityDefinition': string;
@ -8,9 +10,7 @@ interface CssExports {
'sizeLimit': string;
'sizes': string;
'slider': string;
'thumb': string;
'title': string;
'track': string;
}
export const cssExports: CssExports;
export default cssExports;

View file

@ -55,27 +55,6 @@ class QualityDefinition extends Component {
};
}
//
// Control
trackRenderer(props, state) {
return (
<div
{...props}
className={styles.track}
/>
);
}
thumbRenderer(props, state) {
return (
<div
{...props}
className={styles.thumb}
/>
);
}
//
// Listeners
@ -195,7 +174,6 @@ class QualityDefinition extends Component {
<div className={styles.sizeLimit}>
<ReactSlider
className={styles.slider}
min={slider.min}
max={slider.max}
step={slider.step}
@ -204,9 +182,9 @@ class QualityDefinition extends Component {
withTracks={true}
allowCross={false}
snapDragDisabled={true}
pearling={true}
renderThumb={this.thumbRenderer}
renderTrack={this.trackRenderer}
className={styles.slider}
trackClassName={styles.bar}
thumbClassName={styles.handle}
onChange={this.onSliderChange}
onAfterChange={this.onAfterSliderChange}
/>

View file

@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
</div>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
</div>
</Alert>
}

View file

@ -151,7 +151,7 @@ export const defaultState = {
{
name: 'genres',
label: () => translate('Genres'),
isSortable: true,
isSortable: false,
isVisible: false
},
{

View file

@ -150,7 +150,7 @@ export const defaultState = {
},
{
key: 'importFailed',
label: () => translate('ImportCompleteFailed'),
label: () => translate('ImportFailed'),
filters: [
{
key: 'eventType',

View file

@ -1,5 +1,4 @@
import { createSelector } from 'reselect';
import AlbumAppState from 'App/State/AlbumAppState';
import AppState from 'App/State/AppState';
import Artist from 'Artist/Artist';
import { createArtistSelectorForHook } from './createArtistSelector';
@ -8,7 +7,7 @@ function createArtistAlbumsSelector(artistId: number) {
return createSelector(
(state: AppState) => state.albums,
createArtistSelectorForHook(artistId),
(albums: AlbumAppState, artist = {} as Artist) => {
(albums, artist = {} as Artist) => {
const { isFetching, isPopulated, error, items } = albums;
const filteredAlbums = items.filter(

View file

@ -1,14 +1,13 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Artist from 'Artist/Artist';
import MetadataProfile from 'typings/MetadataProfile';
import { createArtistSelectorForHook } from './createArtistSelector';
function createArtistMetadataProfileSelector(artistId: number) {
return createSelector(
(state: AppState) => state.settings.metadataProfiles.items,
createArtistSelectorForHook(artistId),
(metadataProfiles: MetadataProfile[], artist = {} as Artist) => {
(metadataProfiles, artist = {} as Artist) => {
return metadataProfiles.find((profile) => {
return profile.id === artist.metadataProfileId;
});

View file

@ -1,14 +1,13 @@
import { createSelector } from 'reselect';
import AppState from 'App/State/AppState';
import Artist from 'Artist/Artist';
import QualityProfile from 'typings/QualityProfile';
import { createArtistSelectorForHook } from './createArtistSelector';
function createArtistQualityProfileSelector(artistId: number) {
return createSelector(
(state: AppState) => state.settings.qualityProfiles.items,
createArtistSelectorForHook(artistId),
(qualityProfiles: QualityProfile[], artist = {} as Artist) => {
(qualityProfiles, artist = {} as Artist) => {
return qualityProfiles.find(
(profile) => profile.id === artist.qualityProfileId
);

View file

@ -1,8 +1,8 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Alert from 'Components/Alert';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import PageContent from 'Components/Page/PageContent';
import PageContentBody from 'Components/Page/PageContentBody';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
@ -77,16 +77,15 @@ class LogFiles extends Component {
<PageContentBody>
<Alert>
<div>
{translate('LogFilesLocation', {
location
})}
Log files are located in: {location}
</div>
{currentLogView === 'Log Files' ? (
{
currentLogView === 'Log Files' &&
<div>
<InlineMarkdown data={translate('TheLogLevelDefault')} />
The log level defaults to 'Info' and can be changed in <Link to="/settings/general">General Settings</Link>
</div>
) : null}
}
</Alert>
{

View file

@ -270,7 +270,7 @@ function Updates() {
{generalSettingsError ? (
<Alert kind={kinds.DANGER}>
{translate('FailedToFetchSettings')}
{translate('FailedToUpdateSettings')}
</Alert>
) : null}

View file

@ -6,33 +6,15 @@ import isTomorrow from 'Utilities/Date/isTomorrow';
import isYesterday from 'Utilities/Date/isYesterday';
import translate from 'Utilities/String/translate';
interface GetRelativeDateOptions {
timeFormat?: string;
includeSeconds?: boolean;
timeForToday?: boolean;
}
function getRelativeDate(
date: string | undefined,
shortDateFormat: string,
showRelativeDates: boolean,
{
timeFormat,
includeSeconds = false,
timeForToday = false,
}: GetRelativeDateOptions = {}
) {
function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) {
if (!date) {
return '';
return null;
}
const isTodayDate = isToday(date);
if (isTodayDate && timeForToday && timeFormat) {
return formatTime(date, timeFormat, {
includeMinuteZero: true,
includeSeconds,
});
return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
}
if (!showRelativeDates) {

View file

@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise<boolean> {
translations = data.strings;
resolve(true);
} catch {
} catch (error) {
resolve(false);
}
});
@ -27,12 +27,6 @@ export default function translate(
key: string,
tokens: Record<string, string | number | boolean> = {}
) {
const { isProduction = true } = window.Lidarr;
if (!isProduction && !(key in translations)) {
console.warn(`Missing translation for key: ${key}`);
}
const translation = translations[key] || key;
tokens.appName = 'Lidarr';

View file

@ -1,6 +1,6 @@
import { createBrowserHistory } from 'history';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { render } from 'react-dom';
import createAppStore from 'Store/createAppStore';
import App from './App/App';
@ -9,8 +9,9 @@ import 'Diag/ConsoleApi';
export async function bootstrap() {
const history = createBrowserHistory();
const store = createAppStore(history);
const container = document.getElementById('root');
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
root.render(<App store={store} history={history} />);
render(
<App store={store} history={history} />,
document.getElementById('root')
);
}

View file

@ -33,7 +33,7 @@
sizes="16x16"
href="/Content/Images/Icons/favicon-16x16.png"
/>
<link rel="manifest" href="/Content/manifest.json" crossorigin="use-credentials" />
<link rel="manifest" href="/Content/Images/Icons/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/browserconfig.xml"
content="/Content/Images/Icons/browserconfig.xml"
/>
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css">

View file

@ -14,32 +14,6 @@ window.Lidarr = await response.json();
__webpack_public_path__ = `${window.Lidarr.urlBase}/`;
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
const error = console.error;
// Monkey patch console.error to filter out some warnings from React
// TODO: Remove this after the great TypeScript migration
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function logError(...parameters: any[]) {
const filter = parameters.find((parameter) => {
return (
typeof parameter === 'string' &&
(parameter.includes(
'Support for defaultProps will be removed from function components in a future major release'
) ||
parameter.includes(
'findDOMNode is deprecated and will be removed in the next major release'
))
);
});
if (!filter) {
error(...parameters);
}
}
console.error = logError;
const { bootstrap } = await import('./bootstrap');
await bootstrap();

View file

@ -11,11 +11,8 @@
<!-- 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="Lidarr" />
@ -36,11 +33,7 @@
sizes="16x16"
href="/Content/Images/Icons/favicon-16x16.png"
/>
<link
rel="manifest"
href="/Content/manifest.json"
crossorigin="use-credentials"
/>
<link rel="manifest" href="/Content/Images/Icons/manifest.json" crossorigin="use-credentials" />
<link
rel="mask-icon"
href="/Content/Images/Icons/safari-pinned-tab.svg"
@ -52,7 +45,10 @@
href="/favicon.ico"
data-no-hash
/>
<meta name="msapplication-config" content="/Content/browserconfig.xml" />
<meta
name="msapplication-config"
content="/Content/Images/Icons/browserconfig.xml"
/>
<link rel="stylesheet" type="text/css" href="/Content/styles.css" />
<link rel="stylesheet" type="text/css" href="/Content/Fonts/fonts.css" />
@ -63,7 +59,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;
}
@ -213,7 +209,9 @@
</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"
@ -232,8 +230,8 @@
pattern=".{1,}"
required
title="User name is required"
autofocus="true"
autocapitalize="false"
autoFocus="true"
autoCapitalize="false"
/>
</div>
@ -284,16 +282,16 @@
</body>
<script type="text/javascript">
var yearSpan = document.getElementById('year');
yearSpan.innerHTML = '2017-' + new Date().getFullYear();
var yearSpan = document.getElementById("year");
yearSpan.innerHTML = "2017-" + 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 = {
@ -313,7 +311,7 @@
primaryHoverBorderColor: '#1D563D',
failedColor: '#f05050',
forgotPasswordColor: '#909fa7',
forgotPasswordAltColor: '#748690',
forgotPasswordAltColor: '#748690'
};
var dark = {
@ -333,16 +331,21 @@
primaryHoverBorderColor: '#1D563D',
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,10 +1,3 @@
export type InputChanged<T = unknown> = {
name: string;
value: T;
};
export type InputOnChange<T> = (change: InputChanged<T>) => void;
export type CheckInputChanged = {
name: string;
value: boolean;

View file

@ -7,6 +7,5 @@ interface Window {
theme: string;
urlBase: string;
version: string;
isProduction: boolean;
};
}

View file

@ -25,18 +25,18 @@
"defaults"
],
"dependencies": {
"@fortawesome/fontawesome-free": "6.7.1",
"@fortawesome/fontawesome-svg-core": "6.7.1",
"@fortawesome/free-regular-svg-icons": "6.7.1",
"@fortawesome/free-solid-svg-icons": "6.7.1",
"@fortawesome/fontawesome-free": "6.6.0",
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-regular-svg-icons": "6.6.0",
"@fortawesome/free-solid-svg-icons": "6.6.0",
"@fortawesome/react-fontawesome": "0.2.2",
"@juggle/resize-observer": "3.4.0",
"@microsoft/signalr": "6.0.25",
"@sentry/browser": "7.119.1",
"@sentry/integrations": "7.119.1",
"@types/node": "20.16.11",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"classnames": "2.5.1",
"clipboard": "2.0.11",
"connected-react-router": "6.9.3",
@ -53,7 +53,7 @@
"normalize.css": "8.0.1",
"prop-types": "15.8.1",
"qs": "6.13.0",
"react": "18.3.1",
"react": "17.0.2",
"react-addons-shallow-compare": "15.6.3",
"react-async-script": "1.2.0",
"react-autosuggest": "10.1.0",
@ -63,7 +63,7 @@
"react-dnd-multi-backend": "6.0.2",
"react-dnd-touch-backend": "14.1.1",
"react-document-title": "2.0.3",
"react-dom": "18.3.1",
"react-dom": "17.0.2",
"react-focus-lock": "2.9.4",
"react-google-recaptcha": "2.1.0",
"react-lazyload": "3.2.0",
@ -86,16 +86,16 @@
"redux-thunk": "2.4.2",
"reselect": "4.1.8",
"stacktrace-js": "2.0.2",
"typescript": "5.7.2"
"typescript": "5.1.6"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/eslint-parser": "7.25.9",
"@babel/plugin-proposal-export-default-from": "7.25.9",
"@babel/core": "7.25.8",
"@babel/eslint-parser": "7.25.8",
"@babel/plugin-proposal-export-default-from": "7.25.8",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@babel/preset-env": "7.25.8",
"@babel/preset-react": "7.25.7",
"@babel/preset-typescript": "7.25.7",
"@types/lodash": "4.14.195",
"@types/react-lazyload": "3.2.3",
"@types/react-router-dom": "5.3.3",
@ -103,13 +103,13 @@
"@types/react-window": "1.8.8",
"@types/redux-actions": "2.6.5",
"@types/webpack-livereload-plugin": "2.3.6",
"@typescript-eslint/eslint-plugin": "8.18.1",
"@typescript-eslint/parser": "8.18.1",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"autoprefixer": "10.4.20",
"babel-loader": "9.2.1",
"babel-plugin-inline-classnames": "2.0.1",
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
"core-js": "3.41.0",
"core-js": "3.38.1",
"css-loader": "6.7.3",
"css-modules-typescript-loader": "4.0.1",
"eslint": "8.57.1",

View file

@ -99,35 +99,6 @@
<RootNamespace Condition="'$(LidarrProject)'=='true'">$(MSBuildProjectName.Replace('Lidarr','NzbDrone'))</RootNamespace>
</PropertyGroup>
<ItemGroup Condition="'$(TestProject)'!='true'">
<!-- Annotates .NET assemblies with repository information including SHA -->
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>
<!-- Sentry specific configuration: Only in Release mode -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
<!-- OrgSlug, ProjectSlug and AuthToken are required.
They can be set below, via argument to 'msbuild -p:' or environment variable -->
<SentryOrg></SentryOrg>
<SentryProject></SentryProject>
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
without the need to deploy the application with PDBs -->
<SentryUploadSymbols>true</SentryUploadSymbols>
<!-- Source Link settings -->
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
<EmbedAllSources>true</EmbedAllSources>
</PropertyGroup>
<!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />

View file

@ -20,8 +20,7 @@ namespace Lidarr.Api.V1.Albums
}
[HttpGet]
[Produces("application/json")]
public IEnumerable<AlbumResource> Search(string term)
public object Search(string term)
{
var searchResults = _searchProxy.SearchForNewAlbum(term, null);
return MapToResource(searchResults).ToList();

View file

@ -23,8 +23,7 @@ namespace Lidarr.Api.V1.Artist
}
[HttpGet]
[Produces("application/json")]
public IEnumerable<ArtistResource> Search([FromQuery] string term)
public object Search([FromQuery] string term)
{
var searchResults = _searchProxy.SearchForNewArtist(term);
return MapToResource(searchResults).ToList();

View file

@ -33,6 +33,7 @@ namespace Lidarr.Api.V1.Config
SharedValidator.RuleFor(c => c.BindAddress)
.ValidIpAddress()
.NotListenAllIp4Address()
.When(c => c.BindAddress != "*" && c.BindAddress != "localhost");
SharedValidator.RuleFor(c => c.Port).ValidPort();

View file

@ -45,7 +45,6 @@ namespace Lidarr.Api.V1.Config
public string BackupFolder { get; set; }
public int BackupInterval { get; set; }
public int BackupRetention { get; set; }
public bool TrustCgnatIpAddresses { get; set; }
}
public static class HostConfigResourceMapper

View file

@ -1,7 +1,5 @@
using FluentValidation;
using Lidarr.Http;
using NzbDrone.Core.Download;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.DownloadClient
{
@ -11,10 +9,9 @@ namespace Lidarr.Api.V1.DownloadClient
public static readonly DownloadClientResourceMapper ResourceMapper = new ();
public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new ();
public DownloadClientController(IBroadcastSignalRMessage signalRBroadcaster, IDownloadClientFactory downloadClientFactory)
: base(signalRBroadcaster, downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
public DownloadClientController(IDownloadClientFactory downloadClientFactory)
: base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
{
SharedValidator.RuleFor(c => c.Priority).InclusiveBetween(1, 50);
}
}
}

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using Lidarr.Http.REST;
using NzbDrone.Common.Http;
using NzbDrone.Core.HealthCheck;
namespace Lidarr.Api.V1.Health
@ -10,7 +11,7 @@ namespace Lidarr.Api.V1.Health
public string Source { get; set; }
public HealthCheckResult Type { get; set; }
public string Message { get; set; }
public string WikiUrl { get; set; }
public HttpUri WikiUrl { get; set; }
}
public static class HealthResourceMapper
@ -28,7 +29,7 @@ namespace Lidarr.Api.V1.Health
Source = model.Source.Name,
Type = model.Type,
Message = model.Message,
WikiUrl = model.WikiUrl.FullUri
WikiUrl = model.WikiUrl
};
}

View file

@ -3,7 +3,6 @@ using Lidarr.Http;
using NzbDrone.Core.ImportLists;
using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.ImportLists
{
@ -13,12 +12,11 @@ namespace Lidarr.Api.V1.ImportLists
public static readonly ImportListResourceMapper ResourceMapper = new ();
public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ();
public ImportListController(IBroadcastSignalRMessage signalRBroadcaster,
IImportListFactory importListFactory,
public ImportListController(IImportListFactory importListFactory,
RootFolderExistsValidator rootFolderExistsValidator,
QualityProfileExistsValidator qualityProfileExistsValidator,
MetadataProfileExistsValidator metadataProfileExistsValidator)
: base(signalRBroadcaster, importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
: base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
{
SharedValidator.RuleFor(c => c.RootFolderPath).Cascade(CascadeMode.Stop)
.IsValidPath()

View file

@ -1,8 +1,6 @@
using FluentValidation;
using Lidarr.Http;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Validation;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.Indexers
{
@ -12,12 +10,9 @@ namespace Lidarr.Api.V1.Indexers
public static readonly IndexerResourceMapper ResourceMapper = new ();
public static readonly IndexerBulkResourceMapper BulkResourceMapper = new ();
public IndexerController(IBroadcastSignalRMessage signalRBroadcaster,
IndexerFactory indexerFactory,
DownloadClientExistsValidator downloadClientExistsValidator)
: base(signalRBroadcaster, indexerFactory, "indexer", ResourceMapper, BulkResourceMapper)
public IndexerController(IndexerFactory indexerFactory, DownloadClientExistsValidator downloadClientExistsValidator)
: base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper)
{
SharedValidator.RuleFor(c => c.Priority).InclusiveBetween(1, 50);
SharedValidator.RuleFor(c => c.DownloadClientId).SetValidator(downloadClientExistsValidator);
}
}

View file

@ -12,8 +12,8 @@
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.6.2" />
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="FluentValidation" Version="9.5.4" />
<PackageReference Include="Ical.Net" Version="4.3.1" />
<PackageReference Include="NLog" Version="5.4.0" />
<PackageReference Include="Ical.Net" Version="4.2.0" />
<PackageReference Include="NLog" Version="5.3.3" />
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
</ItemGroup>
</Project>

View file

@ -2,7 +2,6 @@ using Lidarr.Http;
using Lidarr.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Instrumentation;
namespace Lidarr.Api.V1.Logs
@ -11,23 +10,16 @@ namespace Lidarr.Api.V1.Logs
public class LogController : Controller
{
private readonly ILogService _logService;
private readonly IConfigFileProvider _configFileProvider;
public LogController(ILogService logService, IConfigFileProvider configFileProvider)
public LogController(ILogService logService)
{
_logService = logService;
_configFileProvider = configFileProvider;
}
[HttpGet]
[Produces("application/json")]
public PagingResource<LogResource> GetLogs([FromQuery] PagingRequestResource paging, string level)
{
if (!_configFileProvider.LogDbEnabled)
{
return new PagingResource<LogResource>();
}
var pagingResource = new PagingResource<LogResource>(paging);
var pageSpec = pagingResource.MapToPagingSpec<LogResource, Log>();

View file

@ -2,7 +2,6 @@ using System;
using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Extras.Metadata;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.Metadata
{
@ -12,8 +11,8 @@ namespace Lidarr.Api.V1.Metadata
public static readonly MetadataResourceMapper ResourceMapper = new ();
public static readonly MetadataBulkResourceMapper BulkResourceMapper = new ();
public MetadataController(IBroadcastSignalRMessage signalRBroadcaster, IMetadataFactory metadataFactory)
: base(signalRBroadcaster, metadataFactory, "metadata", ResourceMapper, BulkResourceMapper)
public MetadataController(IMetadataFactory metadataFactory)
: base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper)
{
}

View file

@ -2,7 +2,6 @@ using System;
using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Notifications;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.Notifications
{
@ -12,8 +11,8 @@ namespace Lidarr.Api.V1.Notifications
public static readonly NotificationResourceMapper ResourceMapper = new ();
public static readonly NotificationBulkResourceMapper BulkResourceMapper = new ();
public NotificationController(IBroadcastSignalRMessage signalRBroadcaster, NotificationFactory notificationFactory)
: base(signalRBroadcaster, notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
public NotificationController(NotificationFactory notificationFactory)
: base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
{
}

View file

@ -7,19 +7,12 @@ using Lidarr.Http.REST.Attributes;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.Serializer;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.ThingiProvider;
using NzbDrone.Core.ThingiProvider.Events;
using NzbDrone.Core.Validation;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1
{
public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestControllerWithSignalR<TProviderResource, TProviderDefinition>,
IHandle<ProviderAddedEvent<TProvider>>,
IHandle<ProviderUpdatedEvent<TProvider>>,
IHandle<ProviderDeletedEvent<TProvider>>
public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
where TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider
where TProviderResource : ProviderResource<TProviderResource>, new()
@ -29,13 +22,11 @@ namespace Lidarr.Api.V1
private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper;
private readonly ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> _bulkResourceMapper;
protected ProviderControllerBase(IBroadcastSignalRMessage signalRBroadcaster,
IProviderFactory<TProvider,
protected ProviderControllerBase(IProviderFactory<TProvider,
TProviderDefinition> providerFactory,
string resource,
ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper,
ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> bulkResourceMapper)
: base(signalRBroadcaster)
{
_providerFactory = providerFactory;
_resourceMapper = resourceMapper;
@ -270,24 +261,6 @@ namespace Lidarr.Api.V1
return Content(data.ToJson(), "application/json");
}
[NonAction]
public virtual void Handle(ProviderAddedEvent<TProvider> message)
{
BroadcastResourceChange(ModelAction.Created, message.Definition.Id);
}
[NonAction]
public virtual void Handle(ProviderUpdatedEvent<TProvider> message)
{
BroadcastResourceChange(ModelAction.Updated, message.Definition.Id);
}
[NonAction]
public virtual void Handle(ProviderDeletedEvent<TProvider> message)
{
BroadcastResourceChange(ModelAction.Deleted, message.ProviderId);
}
protected virtual void Validate(TProviderDefinition definition, bool includeWarnings)
{
var validationResult = definition.Settings.Validate();

View file

@ -130,15 +130,15 @@ namespace Lidarr.Api.V1.Queue
[HttpGet]
[Produces("application/json")]
public PagingResource<QueueResource> GetQueue([FromQuery] PagingRequestResource paging, bool includeUnknownArtistItems = false, bool includeArtist = false, bool includeAlbum = false, [FromQuery] int[] artistIds = null, DownloadProtocol? protocol = null, [FromQuery] int[] quality = null)
public PagingResource<QueueResource> GetQueue([FromQuery] PagingRequestResource paging, bool includeUnknownArtistItems = false, bool includeArtist = false, bool includeAlbum = false, [FromQuery] int[] artistIds = null, DownloadProtocol? protocol = null, int? quality = null)
{
var pagingResource = new PagingResource<QueueResource>(paging);
var pagingSpec = pagingResource.MapToPagingSpec<QueueResource, NzbDrone.Core.Queue.Queue>("timeleft", SortDirection.Ascending);
return pagingSpec.ApplyToPage((spec) => GetQueue(spec, artistIds?.ToHashSet(), protocol, quality?.ToHashSet(), includeUnknownArtistItems), (q) => MapToResource(q, includeArtist, includeAlbum));
return pagingSpec.ApplyToPage((spec) => GetQueue(spec, artistIds?.ToHashSet(), protocol, quality, includeUnknownArtistItems), (q) => MapToResource(q, includeArtist, includeAlbum));
}
private PagingSpec<NzbDrone.Core.Queue.Queue> GetQueue(PagingSpec<NzbDrone.Core.Queue.Queue> pagingSpec, HashSet<int> artistIds, DownloadProtocol? protocol, HashSet<int> quality, bool includeUnknownArtistItems)
private PagingSpec<NzbDrone.Core.Queue.Queue> GetQueue(PagingSpec<NzbDrone.Core.Queue.Queue> pagingSpec, HashSet<int> artistIds, DownloadProtocol? protocol, int? quality, bool includeUnknownArtistItems)
{
var ascending = pagingSpec.SortDirection == SortDirection.Ascending;
var orderByFunc = GetOrderByFunc(pagingSpec);
@ -148,8 +148,6 @@ namespace Lidarr.Api.V1.Queue
var pending = _pendingReleaseService.GetPendingQueue();
var hasArtistIdFilter = artistIds.Any();
var hasQualityFilter = quality.Any();
var fullQueue = filteredQueue.Concat(pending).Where(q =>
{
var include = true;
@ -164,9 +162,9 @@ namespace Lidarr.Api.V1.Queue
include &= q.Protocol == protocol.Value;
}
if (include && hasQualityFilter)
if (include && quality.HasValue)
{
include &= quality.Contains(q.Quality.Quality.Id);
include &= q.Quality.Quality.Id == quality.Value;
}
return include;
@ -302,7 +300,7 @@ namespace Lidarr.Api.V1.Queue
if (blocklist)
{
_failedDownloadService.MarkAsFailed(trackedDownload, skipRedownload);
_failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipRedownload);
}
if (!removeFromClient && !blocklist && !changeCategory)

View file

@ -4,7 +4,6 @@ using Lidarr.Http;
using Lidarr.Http.REST;
using Lidarr.Http.REST.Attributes;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.Validation.Paths;
@ -28,22 +27,11 @@ namespace Lidarr.Api.V1.RemotePathMappings
SharedValidator.RuleFor(c => c.RemotePath)
.NotEmpty();
SharedValidator.RuleFor(c => c.RemotePath)
.Must(remotePath => remotePath.IsNotNullOrWhiteSpace() && !remotePath.StartsWith(" "))
.WithMessage("Remote Path '{PropertyValue}' must not start with a space");
SharedValidator.RuleFor(c => c.RemotePath)
.Must(remotePath => remotePath.IsNotNullOrWhiteSpace() && !remotePath.EndsWith(" "))
.WithMessage("Remote Path '{PropertyValue}' must not end with a space");
SharedValidator.RuleFor(c => c.LocalPath)
.Cascade(CascadeMode.Stop)
.IsValidPath()
.SetValidator(mappedNetworkDriveValidator)
.SetValidator(pathExistsValidator)
.SetValidator(new SystemFolderValidator())
.NotEqual("/")
.WithMessage("Cannot be set to '/'");
.SetValidator(pathExistsValidator);
}
public override RemotePathMappingResource GetResourceById(int id)
@ -53,7 +41,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
[RestPostById]
[Consumes("application/json")]
public ActionResult<RemotePathMappingResource> CreateMapping([FromBody] RemotePathMappingResource resource)
public ActionResult<RemotePathMappingResource> CreateMapping(RemotePathMappingResource resource)
{
var model = resource.ToModel();
@ -74,7 +62,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
}
[RestPutById]
public ActionResult<RemotePathMappingResource> UpdateMapping([FromBody] RemotePathMappingResource resource)
public ActionResult<RemotePathMappingResource> UpdateMapping(RemotePathMappingResource resource)
{
var mapping = resource.ToModel();

View file

@ -24,8 +24,7 @@ namespace Lidarr.Api.V1.Search
}
[HttpGet]
[Produces("application/json")]
public IEnumerable<SearchResource> Search([FromQuery] string term)
public object Search([FromQuery] string term)
{
var searchResults = _searchProxy.SearchForNewEntity(term);
return MapToResource(searchResults).ToList();

View file

@ -50,7 +50,7 @@ namespace Lidarr.Api.V1.System.Backup
}
[RestDeleteById]
public object DeleteBackup(int id)
public void DeleteBackup(int id)
{
var backup = GetBackup(id);
@ -67,8 +67,6 @@ namespace Lidarr.Api.V1.System.Backup
}
_diskProvider.DeleteFile(path);
return new { };
}
[HttpPost("restore/{id:int}")]
@ -92,7 +90,7 @@ namespace Lidarr.Api.V1.System.Backup
}
[HttpPost("restore/upload")]
[RequestFormLimits(MultipartBodyLengthLimit = 5000000000)]
[RequestFormLimits(MultipartBodyLengthLimit = 1000000000)]
public object UploadAndRestore()
{
var files = Request.Form.Files;

View file

@ -1,5 +1,4 @@
using System.Collections.Generic;
using FluentValidation;
using Lidarr.Http;
using Lidarr.Http.REST;
using Lidarr.Http.REST.Attributes;
@ -24,8 +23,6 @@ namespace Lidarr.Api.V1.Tags
: base(signalRBroadcaster)
{
_tagService = tagService;
SharedValidator.RuleFor(c => c.Label).NotEmpty();
}
public override TagResource GetResourceById(int id)

View file

@ -3,7 +3,6 @@ using System.Linq;
using Lidarr.Http;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Update;
using NzbDrone.Core.Update.History;
@ -14,13 +13,11 @@ namespace Lidarr.Api.V1.Update
{
private readonly IRecentUpdateProvider _recentUpdateProvider;
private readonly IUpdateHistoryService _updateHistoryService;
private readonly IConfigFileProvider _configFileProvider;
public UpdateController(IRecentUpdateProvider recentUpdateProvider, IUpdateHistoryService updateHistoryService, IConfigFileProvider configFileProvider)
public UpdateController(IRecentUpdateProvider recentUpdateProvider, IUpdateHistoryService updateHistoryService)
{
_recentUpdateProvider = recentUpdateProvider;
_updateHistoryService = updateHistoryService;
_configFileProvider = configFileProvider;
}
[HttpGet]
@ -48,13 +45,7 @@ namespace Lidarr.Api.V1.Update
installed.Installed = true;
}
if (!_configFileProvider.LogDbEnabled)
{
return resources;
}
var updateHistory = _updateHistoryService.InstalledSince(resources.Last().ReleaseDate);
var installDates = updateHistory
var installDates = _updateHistoryService.InstalledSince(resources.Last().ReleaseDate)
.DistinctBy(v => v.Version)
.ToDictionary(v => v.Version);

View file

@ -327,17 +327,7 @@
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlbumResource"
}
}
}
}
"description": "OK"
}
}
}
@ -630,17 +620,7 @@
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ArtistResource"
}
}
}
}
"description": "OK"
}
}
}
@ -6349,13 +6329,10 @@
"name": "quality",
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
}
}
],
"responses": {
"200": {
@ -7312,17 +7289,7 @@
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SearchResource"
}
}
}
}
"description": "OK"
}
}
}
@ -9808,8 +9775,7 @@
"nullable": true
},
"wikiUrl": {
"type": "string",
"nullable": true
"$ref": "#/components/schemas/HttpUri"
}
},
"additionalProperties": false
@ -10056,9 +10022,48 @@
"backupRetention": {
"type": "integer",
"format": "int32"
}
},
"trustCgnatIpAddresses": {
"type": "boolean"
"additionalProperties": false
},
"HttpUri": {
"type": "object",
"properties": {
"fullUri": {
"type": "string",
"nullable": true,
"readOnly": true
},
"scheme": {
"type": "string",
"nullable": true,
"readOnly": true
},
"host": {
"type": "string",
"nullable": true,
"readOnly": true
},
"port": {
"type": "integer",
"format": "int32",
"nullable": true,
"readOnly": true
},
"path": {
"type": "string",
"nullable": true,
"readOnly": true
},
"query": {
"type": "string",
"nullable": true,
"readOnly": true
},
"fragment": {
"type": "string",
"nullable": true,
"readOnly": true
}
},
"additionalProperties": false
@ -12386,26 +12391,6 @@
],
"type": "string"
},
"SearchResource": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"foreignId": {
"type": "string",
"nullable": true
},
"artist": {
"$ref": "#/components/schemas/ArtistResource"
},
"album": {
"$ref": "#/components/schemas/AlbumResource"
}
},
"additionalProperties": false
},
"SecondaryAlbumType": {
"type": "object",
"properties": {
@ -12881,7 +12866,6 @@
"downloading",
"downloadFailed",
"downloadFailedPending",
"importBlocked",
"importPending",
"importing",
"importFailed",

View file

@ -1,14 +1,9 @@
using System.Collections.Generic;
using System.IO;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Xml;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration;
@ -21,15 +16,11 @@ namespace Lidarr.Http.Authentication
{
private readonly IAuthenticationService _authService;
private readonly IConfigFileProvider _configFileProvider;
private readonly IAppFolderInfo _appFolderInfo;
private readonly Logger _logger;
public AuthenticationController(IAuthenticationService authService, IConfigFileProvider configFileProvider, IAppFolderInfo appFolderInfo, Logger logger)
public AuthenticationController(IAuthenticationService authService, IConfigFileProvider configFileProvider)
{
_authService = authService;
_configFileProvider = configFileProvider;
_appFolderInfo = appFolderInfo;
_logger = logger;
}
[HttpPost("login")]
@ -54,23 +45,7 @@ namespace Lidarr.Http.Authentication
IsPersistent = resource.RememberMe == "on"
};
try
{
await HttpContext.SignInAsync(AuthenticationType.Forms.ToString(), new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties);
}
catch (CryptographicException e)
{
if (e.InnerException is XmlException)
{
_logger.Error(e, "Failed to authenticate user due to corrupt XML. Please remove all XML files from {0} and restart Lidarr", Path.Combine(_appFolderInfo.AppDataFolder, "asp"));
}
else
{
_logger.Error(e, "Failed to authenticate user. {0}", e.Message);
}
return Unauthorized();
}
if (returnUrl.IsNullOrWhiteSpace() || !Url.IsLocalUrl(returnUrl))
{

View file

@ -77,7 +77,7 @@ namespace Lidarr.Http.Authentication
private void LogSuccess(HttpRequest context, string username)
{
_authLogger.Debug("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username);
_authLogger.Info("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username);
}
private void LogLogout(HttpRequest context, string username)

View file

@ -27,15 +27,12 @@ namespace NzbDrone.Http.Authentication
if (_authenticationRequired == AuthenticationRequiredType.DisabledForLocalAddresses)
{
if (context.Resource is HttpContext httpContext &&
IPAddress.TryParse(httpContext.GetRemoteIP(), out var ipAddress))
{
if (ipAddress.IsLocalAddress() ||
(_configService.TrustCgnatIpAddresses && ipAddress.IsCgnatIpAddress()))
IPAddress.TryParse(httpContext.GetRemoteIP(), out var ipAddress) &&
ipAddress.IsLocalAddress())
{
context.Succeed(requirement);
}
}
}
return Task.CompletedTask;
}

View file

@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
@ -6,22 +6,29 @@ using NzbDrone.Core.Configuration;
namespace Lidarr.Http.Frontend.Mappers
{
public class BrowserConfig : UrlBaseReplacementResourceMapperBase
public class BrowserConfig : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
public BrowserConfig(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, configFileProvider, logger)
: base(diskProvider, logger)
{
FilePath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "Content", "browserconfig.xml");
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
}
public override string Map(string resourceUrl)
{
return FilePath;
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = path.Trim(Path.DirectorySeparatorChar);
return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "xml");
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/Content/browserconfig");
return resourceUrl.StartsWith("/Content/Images/Icons/browserconfig");
}
}
}

View file

@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NzbDrone.Common.Crypto;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
namespace Lidarr.Http.Frontend.Mappers
@ -30,11 +28,6 @@ namespace Lidarr.Http.Frontend.Mappers
return resourceUrl;
}
if (!RuntimeInfo.IsProduction)
{
return resourceUrl + "?t=" + DateTime.UtcNow.Ticks;
}
var mapper = _diskMappers.Single(m => m.CanHandle(resourceUrl));
var pathToFile = mapper.Map(resourceUrl);
var hash = _hashProvider.ComputeMd5(pathToFile).ToBase64();

View file

@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.EnvironmentInfo;
@ -6,43 +6,29 @@ using NzbDrone.Core.Configuration;
namespace Lidarr.Http.Frontend.Mappers
{
public class ManifestMapper : UrlBaseReplacementResourceMapperBase
public class ManifestMapper : StaticResourceMapperBase
{
private readonly IAppFolderInfo _appFolderInfo;
private readonly IConfigFileProvider _configFileProvider;
private string _generatedContent;
public ManifestMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, IConfigFileProvider configFileProvider, Logger logger)
: base(diskProvider, configFileProvider, logger)
: base(diskProvider, logger)
{
_appFolderInfo = appFolderInfo;
_configFileProvider = configFileProvider;
FilePath = Path.Combine(appFolderInfo.StartUpFolder, configFileProvider.UiFolder, "Content", "manifest.json");
}
public override string Map(string resourceUrl)
{
return FilePath;
var path = resourceUrl.Replace('/', Path.DirectorySeparatorChar);
path = path.Trim(Path.DirectorySeparatorChar);
return Path.ChangeExtension(Path.Combine(_appFolderInfo.StartUpFolder, _configFileProvider.UiFolder, path), "json");
}
public override bool CanHandle(string resourceUrl)
{
return resourceUrl.StartsWith("/Content/manifest");
}
protected override string GetFileText()
{
if (RuntimeInfo.IsProduction && _generatedContent != null)
{
return _generatedContent;
}
var text = base.GetFileText();
text = text.Replace("__INSTANCE_NAME__", _configFileProvider.InstanceName);
_generatedContent = text;
return _generatedContent;
return resourceUrl.StartsWith("/Content/Images/Icons/manifest");
}
}
}

View file

@ -30,8 +30,8 @@ namespace Lidarr.Http.Frontend.Mappers
{
resourceUrl = resourceUrl.ToLowerInvariant();
if (resourceUrl.StartsWith("/content/manifest") ||
resourceUrl.StartsWith("/content/browserconfig"))
if (resourceUrl.StartsWith("/content/images/icons/manifest") ||
resourceUrl.StartsWith("/content/images/icons/browserconfig"))
{
return false;
}

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