Compare commits

..

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

252 changed files with 2551 additions and 5621 deletions

View file

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

View file

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

28
.gitignore vendored
View file

@ -158,12 +158,34 @@ Thumbs.db
/tools/Addins/* /tools/Addins/*
packages.config.md5sum 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 # ignore node_modules symlink
node_modules node_modules
node_modules.nosync node_modules.nosync
# API doc generation # API doc generation
.config/ .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. 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: ## Major Features Include:
* Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc. * Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.

View file

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

15
docs.sh
View file

@ -1,18 +1,13 @@
#!/bin/bash
set -e
FRAMEWORK="net6.0"
PLATFORM=$1 PLATFORM=$1
ARCHITECTURE="${2:-x64}"
if [ "$PLATFORM" = "Windows" ]; then if [ "$PLATFORM" = "Windows" ]; then
RUNTIME="win-$ARCHITECTURE" RUNTIME="win-x64"
elif [ "$PLATFORM" = "Linux" ]; then elif [ "$PLATFORM" = "Linux" ]; then
RUNTIME="linux-$ARCHITECTURE" RUNTIME="linux-x64"
elif [ "$PLATFORM" = "Mac" ]; then elif [ "$PLATFORM" = "Mac" ]; then
RUNTIME="osx-$ARCHITECTURE" RUNTIME="osx-x64"
else 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 exit 1
fi fi
@ -40,7 +35,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
dotnet new tool-manifest dotnet new tool-manifest
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli 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 sleep 45

View file

@ -188,7 +188,7 @@ module.exports = (env) => {
loose: true, loose: true,
debug: false, debug: false,
useBuiltIns: 'entry', useBuiltIns: 'entry',
corejs: '3.41' corejs: 3
} }
] ]
] ]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,8 +3,8 @@ import React, { Component } from 'react';
import Alert from 'Components/Alert'; import Alert from 'Components/Alert';
import PathInput from 'Components/Form/PathInput'; import PathInput from 'Components/Form/PathInput';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
className={styles.mappedDrivesWarning} className={styles.mappedDrivesWarning}
kind={kinds.WARNING} 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> </Alert>
} }

View file

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

View file

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

View file

@ -49,12 +49,12 @@ function getComponent(type) {
case inputTypes.DEVICE: case inputTypes.DEVICE:
return DeviceInputConnector; return DeviceInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.PLAYLIST: case inputTypes.PLAYLIST:
return PlaylistInputConnector; return PlaylistInputConnector;
case inputTypes.KEY_VALUE_LIST:
return KeyValueListInput;
case inputTypes.MONITOR_ALBUMS_SELECT: case inputTypes.MONITOR_ALBUMS_SELECT:
return MonitorAlbumsSelectInput; 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 { &:last-child {
margin-bottom: 0; margin-bottom: 0;
border-bottom: 0;
} }
} }
.keyInputWrapper { .inputWrapper {
flex: 1 0 0; flex: 1 0 0;
} }
.valueInputWrapper {
flex: 1 0 0;
min-width: 40px;
}
.buttonWrapper { .buttonWrapper {
flex: 0 0 22px; flex: 0 0 22px;
} }
@ -26,10 +20,6 @@
.valueInput { .valueInput {
width: 100%; width: 100%;
border: none; border: none;
background-color: transparent; background-color: var(--inputBackgroundColor);
color: var(--textColor); color: var(--textColor);
&::placeholder {
color: var(--helpTextColor);
}
} }

View file

@ -2,11 +2,10 @@
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'buttonWrapper': string; 'buttonWrapper': string;
'inputWrapper': string;
'itemContainer': string; 'itemContainer': string;
'keyInput': string; 'keyInput': string;
'keyInputWrapper': string;
'valueInput': string; 'valueInput': string;
'valueInputWrapper': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default 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; return inputTypes.CHECK;
case 'device': case 'device':
return inputTypes.DEVICE; return inputTypes.DEVICE;
case 'keyValueList':
return inputTypes.KEY_VALUE_LIST;
case 'playlist': case 'playlist':
return inputTypes.PLAYLIST; return inputTypes.PLAYLIST;
case 'password': case 'password':

View file

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

View file

@ -172,7 +172,7 @@ class SignalRConnector extends Component {
const status = resource.status; const status = resource.status;
// Both successful and failed commands need to be // Both successful and failed commands need to be
// completed, otherwise they spin until they time out. // completed, otherwise they spin until they timeout.
if (status === 'completed' || status === 'failed') { if (status === 'completed' || status === 'failed') {
this.props.dispatchFinishCommand(resource); this.props.dispatchFinishCommand(resource);
@ -224,58 +224,10 @@ class SignalRConnector extends Component {
repopulatePage('trackFileUpdated'); 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 = () => { handleHealth = () => {
this.props.dispatchFetchHealth(); 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) => { handleArtist = (body) => {
const action = body.action; const action = body.action;
const section = 'artist'; const section = 'artist';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader'; import ModalHeader from 'Components/Modal/ModalHeader';
import Column from 'Components/Table/Column';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import useSelectState from 'Helpers/Hooks/useSelectState'; import useSelectState from 'Helpers/Hooks/useSelectState';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import SortDirection from 'Helpers/Props/SortDirection';
import { import {
bulkDeleteCustomFormats, bulkDeleteCustomFormats,
bulkEditCustomFormats, bulkEditCustomFormats,
@ -34,7 +34,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow typeof ManageCustomFormatsModalRow
>['onSelectedChange']; >['onSelectedChange'];
const COLUMNS: Column[] = [ const COLUMNS = [
{ {
name: 'name', name: 'name',
label: () => translate('Name'), label: () => translate('Name'),
@ -56,6 +56,8 @@ const COLUMNS: Column[] = [
interface ManageCustomFormatsModalContentProps { interface ManageCustomFormatsModalContentProps {
onModalClose(): void; onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
} }
function ManageCustomFormatsModalContent( function ManageCustomFormatsModalContent(

View file

@ -7,8 +7,8 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
import FormLabel from 'Components/Form/FormLabel'; import FormLabel from 'Components/Form/FormLabel';
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
import Button from 'Components/Link/Button'; import Button from 'Components/Link/Button';
import Link from 'Components/Link/Link';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
import ModalBody from 'Components/Modal/ModalBody'; import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent'; import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter'; import ModalFooter from 'Components/Modal/ModalFooter';
@ -52,13 +52,12 @@ function EditSpecificationModalContent(props) {
fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) && fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
<Alert kind={kinds.INFO}> <Alert kind={kinds.INFO}>
<div> <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>
<div> <div>
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} /> {'Regular expressions can be tested '}
</div> <Link to="http://regexstorm.net/tester">Here</Link>
<div>
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
</div> </div>
</Alert> </Alert>
} }

View file

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

View file

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

View file

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

View file

@ -24,19 +24,19 @@
height: 20px; height: 20px;
} }
.track { .bar {
top: 9px; top: 9px;
margin: 0 5px; margin: 0 5px;
height: 3px; height: 3px;
background-color: var(--sliderAccentColor); background-color: var(--sliderAccentColor);
box-shadow: 0 0 0 #000; box-shadow: 0 0 0 #000;
&:nth-child(3n + 1) { &:nth-child(3n+1) {
background-color: #ddd; background-color: #ddd;
} }
} }
.thumb { .handle {
top: 1px; top: 1px;
z-index: 0 !important; z-index: 0 !important;
width: 18px; width: 18px;

View file

@ -1,6 +1,8 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
interface CssExports { interface CssExports {
'bar': string;
'handle': string;
'kilobitsPerSecond': string; 'kilobitsPerSecond': string;
'quality': string; 'quality': string;
'qualityDefinition': string; 'qualityDefinition': string;
@ -8,9 +10,7 @@ interface CssExports {
'sizeLimit': string; 'sizeLimit': string;
'sizes': string; 'sizes': string;
'slider': string; 'slider': string;
'thumb': string;
'title': string; 'title': string;
'track': string;
} }
export const cssExports: CssExports; export const cssExports: CssExports;
export default 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 // Listeners
@ -195,7 +174,6 @@ class QualityDefinition extends Component {
<div className={styles.sizeLimit}> <div className={styles.sizeLimit}>
<ReactSlider <ReactSlider
className={styles.slider}
min={slider.min} min={slider.min}
max={slider.max} max={slider.max}
step={slider.step} step={slider.step}
@ -204,9 +182,9 @@ class QualityDefinition extends Component {
withTracks={true} withTracks={true}
allowCross={false} allowCross={false}
snapDragDisabled={true} snapDragDisabled={true}
pearling={true} className={styles.slider}
renderThumb={this.thumbRenderer} trackClassName={styles.bar}
renderTrack={this.trackRenderer} thumbClassName={styles.handle}
onChange={this.onSliderChange} onChange={this.onSliderChange}
onAfterChange={this.onAfterSliderChange} onAfterChange={this.onAfterSliderChange}
/> />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise<boolean> {
translations = data.strings; translations = data.strings;
resolve(true); resolve(true);
} catch { } catch (error) {
resolve(false); resolve(false);
} }
}); });
@ -27,12 +27,6 @@ export default function translate(
key: string, key: string,
tokens: Record<string, string | number | boolean> = {} 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; const translation = translations[key] || key;
tokens.appName = 'Lidarr'; tokens.appName = 'Lidarr';

View file

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

View file

@ -14,32 +14,6 @@ window.Lidarr = await response.json();
__webpack_public_path__ = `${window.Lidarr.urlBase}/`; __webpack_public_path__ = `${window.Lidarr.urlBase}/`;
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */ /* 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'); const { bootstrap } = await import('./bootstrap');
await bootstrap(); await bootstrap();

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 = { export type CheckInputChanged = {
name: string; name: string;
value: boolean; value: boolean;

View file

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

View file

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

View file

@ -99,35 +99,6 @@
<RootNamespace Condition="'$(LidarrProject)'=='true'">$(MSBuildProjectName.Replace('Lidarr','NzbDrone'))</RootNamespace> <RootNamespace Condition="'$(LidarrProject)'=='true'">$(MSBuildProjectName.Replace('Lidarr','NzbDrone'))</RootNamespace>
</PropertyGroup> </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 --> <!-- Standard testing packages -->
<ItemGroup Condition="'$(TestProject)'=='true'"> <ItemGroup Condition="'$(TestProject)'=='true'">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />

View file

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

View file

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

View file

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

View file

@ -1,7 +1,5 @@
using FluentValidation;
using Lidarr.Http; using Lidarr.Http;
using NzbDrone.Core.Download; using NzbDrone.Core.Download;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.DownloadClient namespace Lidarr.Api.V1.DownloadClient
{ {
@ -11,10 +9,9 @@ namespace Lidarr.Api.V1.DownloadClient
public static readonly DownloadClientResourceMapper ResourceMapper = new (); public static readonly DownloadClientResourceMapper ResourceMapper = new ();
public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new (); public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new ();
public DownloadClientController(IBroadcastSignalRMessage signalRBroadcaster, IDownloadClientFactory downloadClientFactory) public DownloadClientController(IDownloadClientFactory downloadClientFactory)
: base(signalRBroadcaster, downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper) : 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 System.Linq;
using Lidarr.Http.REST; using Lidarr.Http.REST;
using NzbDrone.Common.Http;
using NzbDrone.Core.HealthCheck; using NzbDrone.Core.HealthCheck;
namespace Lidarr.Api.V1.Health namespace Lidarr.Api.V1.Health
@ -10,7 +11,7 @@ namespace Lidarr.Api.V1.Health
public string Source { get; set; } public string Source { get; set; }
public HealthCheckResult Type { get; set; } public HealthCheckResult Type { get; set; }
public string Message { get; set; } public string Message { get; set; }
public string WikiUrl { get; set; } public HttpUri WikiUrl { get; set; }
} }
public static class HealthResourceMapper public static class HealthResourceMapper
@ -28,7 +29,7 @@ namespace Lidarr.Api.V1.Health
Source = model.Source.Name, Source = model.Source.Name,
Type = model.Type, Type = model.Type,
Message = model.Message, 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.ImportLists;
using NzbDrone.Core.Validation; using NzbDrone.Core.Validation;
using NzbDrone.Core.Validation.Paths; using NzbDrone.Core.Validation.Paths;
using NzbDrone.SignalR;
namespace Lidarr.Api.V1.ImportLists namespace Lidarr.Api.V1.ImportLists
{ {
@ -13,12 +12,11 @@ namespace Lidarr.Api.V1.ImportLists
public static readonly ImportListResourceMapper ResourceMapper = new (); public static readonly ImportListResourceMapper ResourceMapper = new ();
public static readonly ImportListBulkResourceMapper BulkResourceMapper = new (); public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ();
public ImportListController(IBroadcastSignalRMessage signalRBroadcaster, public ImportListController(IImportListFactory importListFactory,
IImportListFactory importListFactory, RootFolderExistsValidator rootFolderExistsValidator,
RootFolderExistsValidator rootFolderExistsValidator, QualityProfileExistsValidator qualityProfileExistsValidator,
QualityProfileExistsValidator qualityProfileExistsValidator, MetadataProfileExistsValidator metadataProfileExistsValidator)
MetadataProfileExistsValidator metadataProfileExistsValidator) : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
: base(signalRBroadcaster, importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
{ {
SharedValidator.RuleFor(c => c.RootFolderPath).Cascade(CascadeMode.Stop) SharedValidator.RuleFor(c => c.RootFolderPath).Cascade(CascadeMode.Stop)
.IsValidPath() .IsValidPath()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -302,7 +302,7 @@ namespace Lidarr.Api.V1.Queue
if (blocklist) if (blocklist)
{ {
_failedDownloadService.MarkAsFailed(trackedDownload, skipRedownload); _failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipRedownload);
} }
if (!removeFromClient && !blocklist && !changeCategory) if (!removeFromClient && !blocklist && !changeCategory)

View file

@ -4,7 +4,6 @@ using Lidarr.Http;
using Lidarr.Http.REST; using Lidarr.Http.REST;
using Lidarr.Http.REST.Attributes; using Lidarr.Http.REST.Attributes;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.RemotePathMappings; using NzbDrone.Core.RemotePathMappings;
using NzbDrone.Core.Validation.Paths; using NzbDrone.Core.Validation.Paths;
@ -22,28 +21,17 @@ namespace Lidarr.Api.V1.RemotePathMappings
_remotePathMappingService = remotePathMappingService; _remotePathMappingService = remotePathMappingService;
SharedValidator.RuleFor(c => c.Host) SharedValidator.RuleFor(c => c.Host)
.NotEmpty(); .NotEmpty();
// We cannot use IsValidPath here, because it's a remote path, possibly other OS. // We cannot use IsValidPath here, because it's a remote path, possibly other OS.
SharedValidator.RuleFor(c => c.RemotePath) SharedValidator.RuleFor(c => c.RemotePath)
.NotEmpty(); .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) SharedValidator.RuleFor(c => c.LocalPath)
.Cascade(CascadeMode.Stop) .Cascade(CascadeMode.Stop)
.IsValidPath() .IsValidPath()
.SetValidator(mappedNetworkDriveValidator) .SetValidator(mappedNetworkDriveValidator)
.SetValidator(pathExistsValidator) .SetValidator(pathExistsValidator);
.SetValidator(new SystemFolderValidator())
.NotEqual("/")
.WithMessage("Cannot be set to '/'");
} }
public override RemotePathMappingResource GetResourceById(int id) public override RemotePathMappingResource GetResourceById(int id)
@ -53,7 +41,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
[RestPostById] [RestPostById]
[Consumes("application/json")] [Consumes("application/json")]
public ActionResult<RemotePathMappingResource> CreateMapping([FromBody] RemotePathMappingResource resource) public ActionResult<RemotePathMappingResource> CreateMapping(RemotePathMappingResource resource)
{ {
var model = resource.ToModel(); var model = resource.ToModel();
@ -74,7 +62,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
} }
[RestPutById] [RestPutById]
public ActionResult<RemotePathMappingResource> UpdateMapping([FromBody] RemotePathMappingResource resource) public ActionResult<RemotePathMappingResource> UpdateMapping(RemotePathMappingResource resource)
{ {
var mapping = resource.ToModel(); var mapping = resource.ToModel();

View file

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

View file

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

View file

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

View file

@ -327,17 +327,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK"
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlbumResource"
}
}
}
}
} }
} }
} }
@ -630,17 +620,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK"
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ArtistResource"
}
}
}
}
} }
} }
} }
@ -7312,17 +7292,7 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK"
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SearchResource"
}
}
}
}
} }
} }
} }
@ -9808,8 +9778,7 @@
"nullable": true "nullable": true
}, },
"wikiUrl": { "wikiUrl": {
"type": "string", "$ref": "#/components/schemas/HttpUri"
"nullable": true
} }
}, },
"additionalProperties": false "additionalProperties": false
@ -10056,9 +10025,48 @@
"backupRetention": { "backupRetention": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
}
},
"additionalProperties": false
},
"HttpUri": {
"type": "object",
"properties": {
"fullUri": {
"type": "string",
"nullable": true,
"readOnly": true
}, },
"trustCgnatIpAddresses": { "scheme": {
"type": "boolean" "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 "additionalProperties": false
@ -12386,26 +12394,6 @@
], ],
"type": "string" "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": { "SecondaryAlbumType": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -12881,7 +12869,6 @@
"downloading", "downloading",
"downloadFailed", "downloadFailed",
"downloadFailedPending", "downloadFailedPending",
"importBlocked",
"importPending", "importPending",
"importing", "importing",
"importFailed", "importFailed",

View file

@ -1,14 +1,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Security.Claims; using System.Security.Claims;
using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Xml;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NLog;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Core.Authentication; using NzbDrone.Core.Authentication;
using NzbDrone.Core.Configuration; using NzbDrone.Core.Configuration;
@ -21,15 +16,11 @@ namespace Lidarr.Http.Authentication
{ {
private readonly IAuthenticationService _authService; private readonly IAuthenticationService _authService;
private readonly IConfigFileProvider _configFileProvider; 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; _authService = authService;
_configFileProvider = configFileProvider; _configFileProvider = configFileProvider;
_appFolderInfo = appFolderInfo;
_logger = logger;
} }
[HttpPost("login")] [HttpPost("login")]
@ -54,23 +45,7 @@ namespace Lidarr.Http.Authentication
IsPersistent = resource.RememberMe == "on" IsPersistent = resource.RememberMe == "on"
}; };
try await HttpContext.SignInAsync(AuthenticationType.Forms.ToString(), new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties);
{
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)) if (returnUrl.IsNullOrWhiteSpace() || !Url.IsLocalUrl(returnUrl))
{ {

View file

@ -77,7 +77,7 @@ namespace Lidarr.Http.Authentication
private void LogSuccess(HttpRequest context, string username) 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) private void LogLogout(HttpRequest context, string username)

View file

@ -5,7 +5,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="9.5.4" /> <PackageReference Include="FluentValidation" Version="9.5.4" />
<PackageReference Include="ImpromptuInterface" Version="7.0.1" /> <PackageReference Include="ImpromptuInterface" Version="7.0.1" />
<PackageReference Include="NLog" Version="5.4.0" /> <PackageReference Include="NLog" Version="5.3.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NzbDrone.Core\Lidarr.Core.csproj" /> <ProjectReference Include="..\NzbDrone.Core\Lidarr.Core.csproj" />

View file

@ -40,16 +40,15 @@ namespace NzbDrone.Automation.Test
var service = ChromeDriverService.CreateDefaultService(); var service = ChromeDriverService.CreateDefaultService();
// Timeout as windows automation tests seem to take alot longer to get going // Timeout as windows automation tests seem to take alot longer to get going
driver = new ChromeDriver(service, options, TimeSpan.FromMinutes(3)); driver = new ChromeDriver(service, options, new TimeSpan(0, 3, 0));
driver.Manage().Window.Size = new System.Drawing.Size(1920, 1080); driver.Manage().Window.Size = new System.Drawing.Size(1920, 1080);
driver.Manage().Window.FullScreen();
_runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger(), null); _runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger(), null);
_runner.KillAll(); _runner.KillAll();
_runner.Start(true); _runner.Start(true);
driver.Navigate().GoToUrl("http://localhost:8686"); driver.Url = "http://localhost:8686";
var page = new PageBase(driver); var page = new PageBase(driver);
page.WaitForNoSpinner(); page.WaitForNoSpinner();
@ -69,7 +68,7 @@ namespace NzbDrone.Automation.Test
{ {
try try
{ {
var image = (driver as ITakesScreenshot).GetScreenshot(); var image = ((ITakesScreenshot)driver).GetScreenshot();
image.SaveAsFile($"./{name}_test_screenshot.png", ScreenshotImageFormat.Png); image.SaveAsFile($"./{name}_test_screenshot.png", ScreenshotImageFormat.Png);
} }
catch (Exception ex) catch (Exception ex)

View file

@ -4,7 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Selenium.Support" Version="3.141.0" /> <PackageReference Include="Selenium.Support" Version="3.141.0" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="134.0.6998.16500" /> <PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="111.0.5563.6400" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" /> <ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" />

View file

@ -1,17 +1,19 @@
using System; using System;
using System.Threading; using System.Threading;
using OpenQA.Selenium; using OpenQA.Selenium;
using OpenQA.Selenium.Remote;
using OpenQA.Selenium.Support.UI; using OpenQA.Selenium.Support.UI;
namespace NzbDrone.Automation.Test.PageModel namespace NzbDrone.Automation.Test.PageModel
{ {
public class PageBase public class PageBase
{ {
private readonly IWebDriver _driver; private readonly RemoteWebDriver _driver;
public PageBase(IWebDriver driver) public PageBase(RemoteWebDriver driver)
{ {
_driver = driver; _driver = driver;
driver.Manage().Window.Maximize();
} }
public IWebElement FindByClass(string className, int timeout = 5) public IWebElement FindByClass(string className, int timeout = 5)

View file

@ -4,7 +4,6 @@ using System.Linq;
using FluentAssertions; using FluentAssertions;
using NLog; using NLog;
using NUnit.Framework; using NUnit.Framework;
using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Instrumentation.Sentry; using NzbDrone.Common.Instrumentation.Sentry;
using NzbDrone.Test.Common; using NzbDrone.Test.Common;
@ -28,7 +27,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
[SetUp] [SetUp]
public void Setup() public void Setup()
{ {
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object); _subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
} }
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message) private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)

View file

@ -42,18 +42,17 @@ namespace NzbDrone.Common
public void CreateZip(string path, IEnumerable<string> files) public void CreateZip(string path, IEnumerable<string> files)
{ {
_logger.Debug("Creating archive {0}", path); using (var zipFile = ZipFile.Create(path))
using var zipFile = ZipFile.Create(path);
zipFile.BeginUpdate();
foreach (var file in files)
{ {
zipFile.Add(file, Path.GetFileName(file)); zipFile.BeginUpdate();
}
zipFile.CommitUpdate(); foreach (var file in files)
{
zipFile.Add(file, Path.GetFileName(file));
}
zipFile.CommitUpdate();
}
} }
private void ExtractZip(string compressedFile, string destination) private void ExtractZip(string compressedFile, string destination)

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.IO.Abstractions; using System.IO.Abstractions;
using System.Linq; using System.Linq;
using System.Threading;
using NLog; using NLog;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
@ -307,26 +306,9 @@ namespace NzbDrone.Common.Disk
{ {
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs); Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
var files = GetFiles(path, recursive).ToList(); var files = GetFiles(path, recursive);
files.ForEach(RemoveReadOnly); files.ToList().ForEach(RemoveReadOnly);
var attempts = 0;
while (attempts < 3 && files.Any())
{
EmptyFolder(path);
if (GetFiles(path, recursive).Any())
{
// Wait for IO operations to complete after emptying the folder since they aren't always
// instantly removed and it can lead to false positives that files are still present.
Thread.Sleep(3000);
}
attempts++;
files = GetFiles(path, recursive).ToList();
}
_fileSystem.Directory.Delete(path, recursive); _fileSystem.Directory.Delete(path, recursive);
} }

View file

@ -21,7 +21,7 @@ namespace NzbDrone.Common.Disk
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly Logger _logger; private readonly Logger _logger;
private static readonly string[] ReflinkFilesystems = { "btrfs", "xfs", "zfs" }; private static readonly string[] _reflinkFilesystems = { "btrfs", "xfs" };
public DiskTransferService(IDiskProvider diskProvider, Logger logger) public DiskTransferService(IDiskProvider diskProvider, Logger logger)
{ {
@ -343,7 +343,7 @@ namespace NzbDrone.Common.Disk
var targetDriveFormat = targetMount?.DriveFormat ?? string.Empty; var targetDriveFormat = targetMount?.DriveFormat ?? string.Empty;
var isCifs = targetDriveFormat == "cifs"; var isCifs = targetDriveFormat == "cifs";
var tryReflink = sourceDriveFormat == targetDriveFormat && ReflinkFilesystems.Contains(sourceDriveFormat); var tryReflink = sourceDriveFormat == targetDriveFormat && _reflinkFilesystems.Contains(sourceDriveFormat);
if (mode.HasFlag(TransferMode.Copy)) if (mode.HasFlag(TransferMode.Copy))
{ {

View file

@ -17,6 +17,37 @@ namespace NzbDrone.Common.Disk
private readonly IDiskProvider _diskProvider; private readonly IDiskProvider _diskProvider;
private readonly IRuntimeInfo _runtimeInfo; private readonly IRuntimeInfo _runtimeInfo;
private readonly HashSet<string> _setToRemove = new HashSet<string>
{
// Windows
"boot",
"bootmgr",
"cache",
"msocache",
"recovery",
"$recycle.bin",
"recycler",
"system volume information",
"temporary internet files",
"windows",
// OS X
".fseventd",
".spotlight",
".trashes",
".vol",
"cachedmessages",
"caches",
"trash",
// QNAP
".@__thumb",
// Synology
"@eadir",
"#recycle"
};
public FileSystemLookupService(IDiskProvider diskProvider, IRuntimeInfo runtimeInfo) public FileSystemLookupService(IDiskProvider diskProvider, IRuntimeInfo runtimeInfo)
{ {
_diskProvider = diskProvider; _diskProvider = diskProvider;
@ -127,7 +158,7 @@ namespace NzbDrone.Common.Disk
}) })
.ToList(); .ToList();
directories.RemoveAll(d => SpecialFolders.IsSpecialFolder(d.Name)); directories.RemoveAll(d => _setToRemove.Contains(d.Name.ToLowerInvariant()));
return directories; return directories;
} }

View file

@ -1,47 +0,0 @@
using System.Collections.Generic;
namespace NzbDrone.Common.Disk;
public static class SpecialFolders
{
private static readonly HashSet<string> _specialFolders = new HashSet<string>
{
// Windows
"boot",
"bootmgr",
"cache",
"msocache",
"recovery",
"$recycle.bin",
"recycler",
"system volume information",
"temporary internet files",
"windows",
// OS X
".fseventd",
".spotlight",
".trashes",
".vol",
"cachedmessages",
"caches",
"trash",
// QNAP
".@__thumb",
// Synology
"@eadir",
"#recycle"
};
public static bool IsSpecialFolder(string folder)
{
if (folder == null)
{
return false;
}
return _specialFolders.Contains(folder.ToLowerInvariant());
}
}

View file

@ -141,7 +141,7 @@ namespace NzbDrone.Common.Http.Dispatchers
} }
catch (OperationCanceledException ex) when (cts.IsCancellationRequested) catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
{ {
throw new WebException("Http request timed out", ex, WebExceptionStatus.Timeout, null); throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null);
} }
} }

View file

@ -1,21 +0,0 @@
using System.Text;
using NLog;
using NLog.Layouts.ClefJsonLayout;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common.Instrumentation;
public class CleansingClefLogLayout : CompactJsonLayout
{
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{
base.RenderFormattedMessage(logEvent, target);
if (RuntimeInfo.IsProduction)
{
var result = CleanseLogMessage.Cleanse(target.ToString());
target.Clear();
target.Append(result);
}
}
}

View file

@ -1,26 +0,0 @@
using System.Text;
using NLog;
using NLog.Layouts;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common.Instrumentation;
public class CleansingConsoleLogLayout : SimpleLayout
{
public CleansingConsoleLogLayout(string format)
: base(format)
{
}
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{
base.RenderFormattedMessage(logEvent, target);
if (RuntimeInfo.IsProduction)
{
var result = CleanseLogMessage.Cleanse(target.ToString());
target.Clear();
target.Append(result);
}
}
}

View file

@ -4,7 +4,7 @@ using NLog.Targets;
namespace NzbDrone.Common.Instrumentation namespace NzbDrone.Common.Instrumentation
{ {
public class CleansingFileTarget : FileTarget public class NzbDroneFileTarget : FileTarget
{ {
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target) protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
{ {

View file

@ -3,6 +3,7 @@ using System.Diagnostics;
using System.IO; using System.IO;
using NLog; using NLog;
using NLog.Config; using NLog.Config;
using NLog.Layouts.ClefJsonLayout;
using NLog.Targets; using NLog.Targets;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
@ -12,11 +13,9 @@ namespace NzbDrone.Common.Instrumentation
{ {
public static class NzbDroneLogger public static class NzbDroneLogger
{ {
private const string FileLogLayout = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; private const string FILE_LOG_LAYOUT = @"${date:format=yyyy-MM-dd HH\:mm\:ss.f}|${level}|${logger}|${message}${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
private const string ConsoleFormat = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}"; public const string ConsoleLogLayout = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
public static CompactJsonLayout ClefLogLayout = new CompactJsonLayout();
private static readonly CleansingConsoleLogLayout CleansingConsoleLayout = new (ConsoleFormat);
private static readonly CleansingClefLogLayout ClefLogLayout = new ();
private static bool _isConfigured; private static bool _isConfigured;
@ -45,7 +44,7 @@ namespace NzbDrone.Common.Instrumentation
RegisterDebugger(); RegisterDebugger();
} }
RegisterSentry(updateApp, appFolderInfo); RegisterSentry(updateApp);
if (updateApp) if (updateApp)
{ {
@ -66,7 +65,7 @@ namespace NzbDrone.Common.Instrumentation
LogManager.ReconfigExistingLoggers(); LogManager.ReconfigExistingLoggers();
} }
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo) private static void RegisterSentry(bool updateClient)
{ {
string dsn; string dsn;
@ -81,7 +80,7 @@ namespace NzbDrone.Common.Instrumentation
: "https://0522924d625c497f86fc2a1b22aaf21d@sentry.servarr.com/16"; : "https://0522924d625c497f86fc2a1b22aaf21d@sentry.servarr.com/16";
} }
var target = new SentryTarget(dsn, appFolderInfo) var target = new SentryTarget(dsn)
{ {
Name = "sentryTarget", Name = "sentryTarget",
Layout = "${message}" Layout = "${message}"
@ -119,7 +118,11 @@ namespace NzbDrone.Common.Instrumentation
? formatEnumValue ? formatEnumValue
: ConsoleLogFormat.Standard; : ConsoleLogFormat.Standard;
ConfigureConsoleLayout(coloredConsoleTarget, logFormat); coloredConsoleTarget.Layout = logFormat switch
{
ConsoleLogFormat.Clef => ClefLogLayout,
_ => ConsoleLogLayout
};
var loggingRule = new LoggingRule("*", level, coloredConsoleTarget); var loggingRule = new LoggingRule("*", level, coloredConsoleTarget);
@ -136,7 +139,7 @@ namespace NzbDrone.Common.Instrumentation
private static void RegisterAppFile(IAppFolderInfo appFolderInfo, string name, string fileName, int maxArchiveFiles, LogLevel minLogLevel) private static void RegisterAppFile(IAppFolderInfo appFolderInfo, string name, string fileName, int maxArchiveFiles, LogLevel minLogLevel)
{ {
var fileTarget = new CleansingFileTarget(); var fileTarget = new NzbDroneFileTarget();
fileTarget.Name = name; fileTarget.Name = name;
fileTarget.FileName = Path.Combine(appFolderInfo.GetLogFolder(), fileName); fileTarget.FileName = Path.Combine(appFolderInfo.GetLogFolder(), fileName);
@ -149,7 +152,7 @@ namespace NzbDrone.Common.Instrumentation
fileTarget.MaxArchiveFiles = maxArchiveFiles; fileTarget.MaxArchiveFiles = maxArchiveFiles;
fileTarget.EnableFileDelete = true; fileTarget.EnableFileDelete = true;
fileTarget.ArchiveNumbering = ArchiveNumberingMode.Rolling; fileTarget.ArchiveNumbering = ArchiveNumberingMode.Rolling;
fileTarget.Layout = FileLogLayout; fileTarget.Layout = FILE_LOG_LAYOUT;
var loggingRule = new LoggingRule("*", minLogLevel, fileTarget); var loggingRule = new LoggingRule("*", minLogLevel, fileTarget);
@ -168,7 +171,7 @@ namespace NzbDrone.Common.Instrumentation
fileTarget.ConcurrentWrites = false; fileTarget.ConcurrentWrites = false;
fileTarget.ConcurrentWriteAttemptDelay = 50; fileTarget.ConcurrentWriteAttemptDelay = 50;
fileTarget.ConcurrentWriteAttempts = 100; fileTarget.ConcurrentWriteAttempts = 100;
fileTarget.Layout = FileLogLayout; fileTarget.Layout = FILE_LOG_LAYOUT;
var loggingRule = new LoggingRule("*", LogLevel.Trace, fileTarget); var loggingRule = new LoggingRule("*", LogLevel.Trace, fileTarget);
@ -213,15 +216,6 @@ namespace NzbDrone.Common.Instrumentation
{ {
return GetLogger(obj.GetType()); return GetLogger(obj.GetType());
} }
public static void ConfigureConsoleLayout(ColoredConsoleTarget target, ConsoleLogFormat format)
{
target.Layout = format switch
{
ConsoleLogFormat.Clef => NzbDroneLogger.ClefLogLayout,
_ => NzbDroneLogger.CleansingConsoleLayout
};
}
} }
public enum ConsoleLogFormat public enum ConsoleLogFormat

View file

@ -9,7 +9,6 @@ using NLog;
using NLog.Common; using NLog.Common;
using NLog.Targets; using NLog.Targets;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions;
using Sentry; using Sentry;
namespace NzbDrone.Common.Instrumentation.Sentry namespace NzbDrone.Common.Instrumentation.Sentry
@ -100,7 +99,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
public bool FilterEvents { get; set; } public bool FilterEvents { get; set; }
public bool SentryEnabled { get; set; } public bool SentryEnabled { get; set; }
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo) public SentryTarget(string dsn)
{ {
_sdk = SentrySdk.Init(o => _sdk = SentrySdk.Init(o =>
{ {
@ -108,33 +107,9 @@ namespace NzbDrone.Common.Instrumentation.Sentry
o.AttachStacktrace = true; o.AttachStacktrace = true;
o.MaxBreadcrumbs = 200; o.MaxBreadcrumbs = 200;
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}"; o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x)); o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x)); o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
o.Environment = BuildInfo.Branch; o.Environment = BuildInfo.Branch;
// Crash free run statistics (sends a ping for healthy and for crashes sessions)
o.AutoSessionTracking = false;
// Caches files in the event device is offline
// Sentry creates a 'sentry' sub directory, no need to concat here
o.CacheDirectoryPath = appFolderInfo.GetAppDataPath();
// default environment is production
if (!RuntimeInfo.IsProduction)
{
if (RuntimeInfo.IsDevelopment)
{
o.Environment = "development";
}
else if (RuntimeInfo.IsTesting)
{
o.Environment = "testing";
}
else
{
o.Environment = "other";
}
}
}); });
InitializeScope(); InitializeScope();
@ -152,7 +127,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
{ {
SentrySdk.ConfigureScope(scope => SentrySdk.ConfigureScope(scope =>
{ {
scope.User = new SentryUser scope.User = new User
{ {
Id = HashUtil.AnonymousToken() Id = HashUtil.AnonymousToken()
}; };
@ -194,7 +169,9 @@ namespace NzbDrone.Common.Instrumentation.Sentry
private void OnError(Exception ex) private void OnError(Exception ex)
{ {
if (ex is WebException webException) var webException = ex as WebException;
if (webException != null)
{ {
var response = webException.Response as HttpWebResponse; var response = webException.Response as HttpWebResponse;
var statusCode = response?.StatusCode; var statusCode = response?.StatusCode;
@ -313,21 +290,13 @@ namespace NzbDrone.Common.Instrumentation.Sentry
} }
} }
var level = LoggingLevelMap[logEvent.Level];
var sentryEvent = new SentryEvent(logEvent.Exception) var sentryEvent = new SentryEvent(logEvent.Exception)
{ {
Level = level, Level = LoggingLevelMap[logEvent.Level],
Logger = logEvent.LoggerName, Logger = logEvent.LoggerName,
Message = logEvent.FormattedMessage Message = logEvent.FormattedMessage
}; };
if (level is SentryLevel.Fatal && logEvent.Exception is not null)
{
// Usages of 'fatal' here indicates the process will crash. In Sentry this is represented with
// the 'unhandled' exception flag
logEvent.Exception.SetSentryMechanism("Logger.Fatal", "Logger.Fatal was called", false);
}
sentryEvent.SetExtras(extras); sentryEvent.SetExtras(extras);
sentryEvent.SetFingerprint(fingerPrint); sentryEvent.SetFingerprint(fingerPrint);

View file

@ -6,17 +6,17 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="DryIoc.dll" Version="5.4.3" /> <PackageReference Include="DryIoc.dll" Version="5.4.3" />
<PackageReference Include="IPAddressRange" Version="6.2.0" /> <PackageReference Include="IPAddressRange" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NLog" Version="5.4.0" /> <PackageReference Include="NLog" Version="5.3.3" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" /> <PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.3" /> <PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.0" />
<PackageReference Include="Sentry" Version="4.0.2" /> <PackageReference Include="Sentry" Version="3.25.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" /> <PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" /> <PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
<PackageReference Include="System.Text.Json" Version="6.0.10" /> <PackageReference Include="System.Text.Json" Version="6.0.10" />
<PackageReference Include="System.ValueTuple" Version="4.6.1" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" /> <PackageReference Include="System.Data.SQLite.Core.Servarr" Version="1.0.115.5-18" />
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" /> <PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
<PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" /> <PackageReference Include="System.Configuration.ConfigurationManager" Version="6.0.1" />

View file

@ -1,4 +1,3 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
@ -7,7 +6,7 @@ namespace NzbDrone.Common
{ {
public class PathEqualityComparer : IEqualityComparer<string> public class PathEqualityComparer : IEqualityComparer<string>
{ {
public static readonly PathEqualityComparer Instance = new (); public static readonly PathEqualityComparer Instance = new PathEqualityComparer();
private PathEqualityComparer() private PathEqualityComparer()
{ {
@ -20,19 +19,12 @@ namespace NzbDrone.Common
public int GetHashCode(string obj) public int GetHashCode(string obj)
{ {
try if (OsInfo.IsWindows)
{ {
if (OsInfo.IsWindows) return obj.CleanFilePath().Normalize().ToLower().GetHashCode();
{ }
return obj.CleanFilePath().Normalize().ToLower().GetHashCode();
}
return obj.CleanFilePath().Normalize().GetHashCode(); return obj.CleanFilePath().Normalize().GetHashCode();
}
catch (ArgumentException ex)
{
throw new ArgumentException($"Invalid path: {obj}", ex);
}
} }
} }
} }

View file

@ -6,7 +6,6 @@ using System.ComponentModel;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using NLog; using NLog;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Model; using NzbDrone.Common.Model;
@ -118,9 +117,7 @@ namespace NzbDrone.Common.Processes
UseShellExecute = false, UseShellExecute = false,
RedirectStandardError = true, RedirectStandardError = true,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardInput = true, RedirectStandardInput = true
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
}; };
if (environmentVariables != null) if (environmentVariables != null)
@ -316,7 +313,7 @@ namespace NzbDrone.Common.Processes
processInfo = new ProcessInfo(); processInfo = new ProcessInfo();
processInfo.Id = process.Id; processInfo.Id = process.Id;
processInfo.Name = process.ProcessName; processInfo.Name = process.ProcessName;
processInfo.StartPath = process.MainModule?.FileName; processInfo.StartPath = process.MainModule.FileName;
if (process.Id != GetCurrentProcessId() && process.HasExited) if (process.Id != GetCurrentProcessId() && process.HasExited)
{ {

View file

@ -34,8 +34,7 @@ namespace NzbDrone.Common.Reflection
|| type == typeof(string) || type == typeof(string)
|| type == typeof(DateTime) || type == typeof(DateTime)
|| type == typeof(Version) || type == typeof(Version)
|| type == typeof(decimal) || type == typeof(decimal);
|| (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>));
} }
public static bool IsReadable(this PropertyInfo propertyInfo) public static bool IsReadable(this PropertyInfo propertyInfo)

View file

@ -1,43 +0,0 @@
using System;
using System.Data.SQLite;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore.Converters;
using NzbDrone.Core.Test.Framework;
namespace NzbDrone.Core.Test.Datastore.Converters;
[TestFixture]
public class TimeSpanConverterFixture : CoreTest<TimeSpanConverter>
{
private SQLiteParameter _param;
[SetUp]
public void Setup()
{
_param = new SQLiteParameter();
}
[Test]
public void should_return_string_when_saving_timespan_to_db()
{
var span = TimeSpan.FromMilliseconds(10);
Subject.SetValue(_param, span);
_param.Value.Should().Be(span.ToString());
}
[Test]
public void should_return_timespan_when_getting_string_from_db()
{
var span = TimeSpan.FromMilliseconds(10);
Subject.Parse(span.ToString()).Should().Be(span);
}
[Test]
public void should_return_zero_timespan_for_db_null_value_when_getting_from_db()
{
Subject.Parse(null).Should().Be(TimeSpan.Zero);
}
}

View file

@ -1,38 +0,0 @@
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Datastore;
namespace NzbDrone.Core.Test.Datastore;
[TestFixture]
public class DatabaseVersionParserFixture
{
[TestCase("3.44.2", 3, 44, 2)]
public void should_parse_sqlite_database_version(string serverVersion, int majorVersion, int minorVersion, int buildVersion)
{
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
version.Should().NotBeNull();
version.Major.Should().Be(majorVersion);
version.Minor.Should().Be(minorVersion);
version.Build.Should().Be(buildVersion);
}
[TestCase("14.8 (Debian 14.8-1.pgdg110+1)", 14, 8, null)]
[TestCase("16.3 (Debian 16.3-1.pgdg110+1)", 16, 3, null)]
[TestCase("16.3 - Percona Distribution", 16, 3, null)]
[TestCase("17.0 - Percona Server", 17, 0, null)]
public void should_parse_postgres_database_version(string serverVersion, int majorVersion, int minorVersion, int? buildVersion)
{
var version = DatabaseVersionParser.ParseServerVersion(serverVersion);
version.Should().NotBeNull();
version.Major.Should().Be(majorVersion);
version.Minor.Should().Be(minorVersion);
if (buildVersion.HasValue)
{
version.Build.Should().Be(buildVersion.Value);
}
}
}

View file

@ -103,7 +103,6 @@ namespace NzbDrone.Core.Test.DiskSpace
[TestCase("/var/lib/docker")] [TestCase("/var/lib/docker")]
[TestCase("/some/place/docker/aufs")] [TestCase("/some/place/docker/aufs")]
[TestCase("/etc/network")] [TestCase("/etc/network")]
[TestCase("/Volumes/.timemachine/ABC123456-A1BC-12A3B45678C9/2025-05-13-181401.backup")]
public void should_not_check_diskspace_for_irrelevant_mounts(string path) public void should_not_check_diskspace_for_irrelevant_mounts(string path)
{ {
var mount = new Mock<IMount>(); var mount = new Mock<IMount>();

View file

@ -183,8 +183,6 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
{ {
GivenArtistMatch(); GivenArtistMatch();
var tracks = Builder<Track>.CreateListOfSize(3).BuildList();
_trackedDownload.RemoteAlbum.Albums = new List<Album> _trackedDownload.RemoteAlbum.Albums = new List<Album>
{ {
CreateAlbum(1, 3) CreateAlbum(1, 3)
@ -194,9 +192,9 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>())) .Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult> .Returns(new List<ImportResult>
{ {
new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Tracks = new List<Track> { tracks[0] } })), new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })),
new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Tracks = new List<Track> { tracks[1] } })), new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })),
new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Tracks = new List<Track> { tracks[2] } })), new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })),
new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() }), "Test Failure") new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() }), "Test Failure")
}); });
@ -292,9 +290,6 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
[Test] [Test]
public void should_mark_as_imported_if_all_tracks_were_imported() public void should_mark_as_imported_if_all_tracks_were_imported()
{ {
var track1 = new Track { Id = 1 };
var track2 = new Track { Id = 2 };
_trackedDownload.RemoteAlbum.Albums = new List<Album> _trackedDownload.RemoteAlbum.Albums = new List<Album>
{ {
CreateAlbum(1, 2) CreateAlbum(1, 2)
@ -306,11 +301,11 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
{ {
new ImportResult( new ImportResult(
new ImportDecision<LocalTrack>( new ImportDecision<LocalTrack>(
new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Tracks = new List<Track> { track1 } })), new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })),
new ImportResult( new ImportResult(
new ImportDecision<LocalTrack>( new ImportDecision<LocalTrack>(
new LocalTrack { Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic(), Tracks = new List<Track> { track2 } })) new LocalTrack { Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic() }))
}); });
Subject.Import(_trackedDownload); Subject.Import(_trackedDownload);
@ -372,13 +367,11 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
{ {
GivenABadlyNamedDownload(); GivenABadlyNamedDownload();
var track1 = new Track { Id = 1 };
Mocker.GetMock<IDownloadedTracksImportService>() Mocker.GetMock<IDownloadedTracksImportService>()
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>())) .Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>()))
.Returns(new List<ImportResult> .Returns(new List<ImportResult>
{ {
new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic(), Tracks = new List<Track> { track1 } })) new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() }))
}); });
Mocker.GetMock<IArtistService>() Mocker.GetMock<IArtistService>()

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