mirror of
https://github.com/lidarr/lidarr.git
synced 2025-08-19 21:13:28 -07:00
Compare commits
171 commits
v2.8.1.448
...
develop
Author | SHA1 | Date | |
---|---|---|---|
|
d2330a3232 |
||
|
4cb306780f | ||
|
393db165f3 | ||
|
eb861f06d3 | ||
|
6f1b370772 |
||
|
074f06442a |
||
|
fef111d396 | ||
|
76b7713870 | ||
|
d50ed84541 | ||
|
002e8f5b69 | ||
|
c7b8aa8a04 | ||
|
91f06801ca |
||
|
dc61618711 | ||
|
fd00a5627c | ||
|
66ea1b1dfb | ||
|
72fa05cf41 | ||
|
c51b5c6fba | ||
|
efebab9ba2 | ||
|
47c32c9963 | ||
|
9f229bb684 | ||
|
f9b2e57696 | ||
|
4b48edab0a | ||
|
e087574de7 | ||
|
8877cf99f1 | ||
|
a56e5b3f9a | ||
|
5bb1949ea2 | ||
|
979042948d | ||
|
ebe59b18d9 | ||
|
086a451dff | ||
|
1bcb82eed0 | ||
|
ae9b4cec75 | ||
|
ed777de015 | ||
|
96f956a5d6 | ||
|
68a8f40746 | ||
|
c518cf63e7 |
||
|
da55b8578a | ||
|
234c29ef49 |
||
|
de169e8a1f | ||
|
4b300a448a | ||
|
785bcfda0b |
||
|
94ea751ad2 | ||
|
0c172b58f1 |
||
|
ea2ee70208 | ||
|
8b63928a25 |
||
|
7217e891f7 | ||
|
345bbcd992 |
||
|
bd9d7ba085 | ||
|
3937bebfea | ||
|
767b0930a5 | ||
|
c3f0fc640c | ||
|
9dbcc79436 | ||
|
3dd04cecbf | ||
|
d8850af019 | ||
|
fbfd24e226 |
||
|
d9562c701e | ||
|
d21ad2ad68 | ||
|
556f0ea54b | ||
|
e4a36ca388 | ||
|
1045684935 | ||
|
9ba71ae6b1 | ||
|
89b9352fef | ||
|
c83332e58c | ||
|
4677a1115a |
||
|
6150a57596 | ||
|
13f6b1a086 |
||
|
8027ab5d2e |
||
|
5bdc119b98 | ||
|
1b9b57ae9b | ||
|
c28a97cafd | ||
|
099d19a04d | ||
|
d381463b60 | ||
|
a86bd8e862 | ||
|
4bea38ab9c | ||
|
950c51bc59 | ||
|
18f13fe7f8 | ||
|
f8d4b3a59b | ||
|
5cf9624e55 |
||
|
81895f8033 | ||
|
a1c2bfa527 | ||
|
33049910de |
||
|
6dd87fd348 |
||
|
9314eb34ab | ||
|
84b91ba6c1 | ||
|
6c6f92fbed | ||
|
1e42ae94aa | ||
|
29f5810865 | ||
|
342c82aa1f | ||
|
5a3f879442 | ||
|
6e57c14e57 | ||
|
9fc549b43b | ||
|
a2201001c5 | ||
|
8c99280f07 | ||
|
07db508580 | ||
|
031f32a52c | ||
|
2997c16346 | ||
|
a1a53dbb5e | ||
|
e8bb78e5bb | ||
|
6292f223ac | ||
|
f4dc294ab3 | ||
|
23611cb116 |
||
|
f177345d01 | ||
|
ec050a7b3c | ||
|
860bd04c59 | ||
|
261f30d268 | ||
|
36998abba0 | ||
|
ad12617694 | ||
|
be115da157 | ||
|
664b972494 |
||
|
2b2fd5a175 | ||
|
d8222c066c |
||
|
bc6417229e | ||
|
e0e17a2ea7 | ||
|
5bf2ae9e6f |
||
|
8e01ba5f21 |
||
|
45e8ecffa0 | ||
|
3c4b438d27 | ||
|
8fd79d7291 | ||
|
477a799b8a | ||
|
51a38bc648 | ||
|
917f705695 | ||
|
5a1092b511 | ||
|
ef2c6366c4 | ||
|
1ffb82e364 |
||
|
e2f8753a6a | ||
|
739019498f | ||
|
396b2ae7c1 | ||
|
0216616738 | ||
|
82e0b628cc | ||
|
014f8a58b1 | ||
|
5cbb2848c7 |
||
|
554cf8ec55 | ||
|
4ff6c71456 | ||
|
7cfcf01ae3 | ||
|
17c5c66e54 |
||
|
40dab8deb9 | ||
|
39f0e4d989 | ||
|
35a46eca7b |
||
|
79b29f39f9 | ||
|
0e19c03e9a | ||
|
e6388cab94 | ||
|
47e504fbc9 | ||
|
1a40839202 | ||
|
25a80aa29d | ||
|
7255126af5 | ||
|
166f87ae68 | ||
|
babdf10273 |
||
|
19c2994ff3 | ||
|
e420ee0645 | ||
|
78469a96c9 | ||
|
bc6df548fc | ||
|
797e4c773e | ||
|
119141723a | ||
|
fd1719e58c | ||
|
41612708ff | ||
|
535caf1324 | ||
|
eb3c7d6990 | ||
|
4c603e24f6 | ||
|
ec93c33aa9 | ||
|
afb3fd5bd5 | ||
|
198a13755f | ||
|
44a5654918 | ||
|
8aa0754843 | ||
|
c42e96b55d | ||
|
f92935e3d2 | ||
|
13bb8f5089 | ||
|
ad084cdf91 | ||
|
4bcdc49777 | ||
|
502cb20898 | ||
|
0fd6c263b1 | ||
|
11af8a5e05 |
||
|
88196340a8 |
252 changed files with 5609 additions and 2539 deletions
|
@ -6,7 +6,7 @@
|
|||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "16",
|
||||
"version": "20",
|
||||
"nvmVersion": "latest"
|
||||
}
|
||||
},
|
||||
|
|
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
@ -60,6 +60,7 @@ body:
|
|||
- Master
|
||||
- Develop
|
||||
- Nightly
|
||||
- Plugins (experimental)
|
||||
- Other (This issue will be closed)
|
||||
validations:
|
||||
required: true
|
||||
|
|
28
.gitignore
vendored
28
.gitignore
vendored
|
@ -158,34 +158,12 @@ Thumbs.db
|
|||
/tools/Addins/*
|
||||
packages.config.md5sum
|
||||
|
||||
|
||||
# Common IntelliJ Platform excludes
|
||||
|
||||
# User specific
|
||||
**/.idea/**/workspace.xml
|
||||
**/.idea/**/tasks.xml
|
||||
**/.idea/shelf/*
|
||||
**/.idea/dictionaries
|
||||
**/.idea/.idea.Radarr.Posix
|
||||
**/.idea/.idea.Radarr.Windows
|
||||
|
||||
# Sensitive or high-churn files
|
||||
**/.idea/**/dataSources/
|
||||
**/.idea/**/dataSources.ids
|
||||
**/.idea/**/dataSources.xml
|
||||
**/.idea/**/dataSources.local.xml
|
||||
**/.idea/**/sqlDataSources.xml
|
||||
**/.idea/**/dynamic.xml
|
||||
|
||||
# Rider
|
||||
# Rider auto-generates .iml files, and contentModel.xml
|
||||
**/.idea/**/*.iml
|
||||
**/.idea/**/contentModel.xml
|
||||
**/.idea/**/modules.xml
|
||||
|
||||
# ignore node_modules symlink
|
||||
node_modules
|
||||
node_modules.nosync
|
||||
|
||||
# API doc generation
|
||||
.config/
|
||||
|
||||
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||
.idea/
|
||||
|
|
|
@ -9,6 +9,9 @@
|
|||
|
||||
Lidarr is a music collection manager for Usenet and BitTorrent users. It can monitor multiple RSS feeds for new tracks from your favorite artists and will grab, sort and rename them. It can also be configured to automatically upgrade the quality of files already downloaded when a better quality format becomes available.
|
||||
|
||||
> [!WARNING]
|
||||
> NOTICE - The Lidarr Metadata Server is currently down impacting adding artists, etc. Please follow [GHI 5498](https://github.com/Lidarr/Lidarr/issues/5498) or see Discord for detaila.
|
||||
|
||||
## Major Features Include:
|
||||
|
||||
* Support for major platforms: Windows, Linux, macOS, Raspberry Pi, etc.
|
||||
|
|
|
@ -9,7 +9,7 @@ variables:
|
|||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '2.8.1'
|
||||
majorVersion: '2.13.3'
|
||||
minorVersion: $[counter('minorVersion', 1076)]
|
||||
lidarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(lidarrVersion)'
|
||||
|
@ -19,7 +19,7 @@ variables:
|
|||
nodeVersion: '20.X'
|
||||
innoVersion: '6.2.0'
|
||||
windowsImage: 'windows-2022'
|
||||
linuxImage: 'ubuntu-20.04'
|
||||
linuxImage: 'ubuntu-22.04'
|
||||
macImage: 'macOS-13'
|
||||
|
||||
trigger:
|
||||
|
@ -1120,19 +1120,19 @@ stages:
|
|||
vmImage: ${{ variables.windowsImage }}
|
||||
steps:
|
||||
- checkout: self # Need history for Sonar analysis
|
||||
- task: SonarCloudPrepare@2
|
||||
- task: SonarCloudPrepare@3
|
||||
env:
|
||||
SONAR_SCANNER_OPTS: ''
|
||||
inputs:
|
||||
SonarCloud: 'SonarCloud'
|
||||
organization: 'lidarr'
|
||||
scannerMode: 'CLI'
|
||||
scannerMode: 'cli'
|
||||
configMode: 'manual'
|
||||
cliProjectKey: 'lidarr_Lidarr.UI'
|
||||
cliProjectName: 'LidarrUI'
|
||||
cliProjectVersion: '$(lidarrVersion)'
|
||||
cliSources: './frontend'
|
||||
- task: SonarCloudAnalyze@2
|
||||
- task: SonarCloudAnalyze@3
|
||||
|
||||
- job: Api_Docs
|
||||
displayName: API Docs
|
||||
|
@ -1208,12 +1208,12 @@ stages:
|
|||
submodules: true
|
||||
- powershell: Set-Service SCardSvr -StartupType Manual
|
||||
displayName: Enable Windows Test Service
|
||||
- task: SonarCloudPrepare@2
|
||||
- task: SonarCloudPrepare@3
|
||||
condition: eq(variables['System.PullRequest.IsFork'], 'False')
|
||||
inputs:
|
||||
SonarCloud: 'SonarCloud'
|
||||
organization: 'lidarr'
|
||||
scannerMode: 'MSBuild'
|
||||
scannerMode: 'dotnet'
|
||||
projectKey: 'lidarr_Lidarr'
|
||||
projectName: 'Lidarr'
|
||||
projectVersion: '$(lidarrVersion)'
|
||||
|
@ -1226,7 +1226,7 @@ stages:
|
|||
./build.sh --backend -f net6.0 -r win-x64
|
||||
TEST_DIR=_tests/net6.0/win-x64/publish/ ./test.sh Windows Unit Coverage
|
||||
displayName: Coverage Unit Tests
|
||||
- task: SonarCloudAnalyze@2
|
||||
- task: SonarCloudAnalyze@3
|
||||
condition: eq(variables['System.PullRequest.IsFork'], 'False')
|
||||
displayName: Publish SonarCloud Results
|
||||
- task: reportgenerator@5.3.11
|
||||
|
|
15
docs.sh
15
docs.sh
|
@ -1,13 +1,18 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
FRAMEWORK="net6.0"
|
||||
PLATFORM=$1
|
||||
ARCHITECTURE="${2:-x64}"
|
||||
|
||||
if [ "$PLATFORM" = "Windows" ]; then
|
||||
RUNTIME="win-x64"
|
||||
RUNTIME="win-$ARCHITECTURE"
|
||||
elif [ "$PLATFORM" = "Linux" ]; then
|
||||
RUNTIME="linux-x64"
|
||||
RUNTIME="linux-$ARCHITECTURE"
|
||||
elif [ "$PLATFORM" = "Mac" ]; then
|
||||
RUNTIME="osx-x64"
|
||||
RUNTIME="osx-$ARCHITECTURE"
|
||||
else
|
||||
echo "Platform must be provided as first arguement: Windows, Linux or Mac"
|
||||
echo "Platform must be provided as first argument: Windows, Linux or Mac"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
@ -35,7 +40,7 @@ dotnet msbuild -restore $slnFile -p:Configuration=Debug -p:Platform=$platform -p
|
|||
dotnet new tool-manifest
|
||||
dotnet tool install --version 6.6.2 Swashbuckle.AspNetCore.Cli
|
||||
|
||||
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/net6.0/$RUNTIME/$application" v1 &
|
||||
dotnet tool run swagger tofile --output ./src/Lidarr.Api.V1/openapi.json "$outputFolder/$FRAMEWORK/$RUNTIME/$application" v1 &
|
||||
|
||||
sleep 45
|
||||
|
||||
|
|
|
@ -188,7 +188,7 @@ module.exports = (env) => {
|
|||
loose: true,
|
||||
debug: false,
|
||||
useBuiltIns: 'entry',
|
||||
corejs: 3
|
||||
corejs: '3.41'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
@ -172,7 +172,8 @@ function HistoryDetails(props) {
|
|||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const {
|
||||
message
|
||||
message,
|
||||
indexer
|
||||
} = data;
|
||||
|
||||
return (
|
||||
|
@ -192,6 +193,14 @@ function HistoryDetails(props) {
|
|||
null
|
||||
}
|
||||
|
||||
{
|
||||
indexer ? (
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{
|
||||
message ?
|
||||
<DescriptionListItem
|
||||
|
|
|
@ -57,30 +57,40 @@ function QueueStatusCell(props) {
|
|||
|
||||
if (status === 'paused') {
|
||||
iconName = icons.PAUSED;
|
||||
title = 'Paused';
|
||||
title = translate('Paused');
|
||||
}
|
||||
|
||||
if (status === 'queued') {
|
||||
iconName = icons.QUEUED;
|
||||
title = 'Queued';
|
||||
title = translate('Queued');
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
iconName = icons.DOWNLOADED;
|
||||
title = 'Downloaded';
|
||||
title = translate('Downloaded');
|
||||
|
||||
if (trackedDownloadState === 'importBlocked') {
|
||||
title += ` - ${translate('UnableToImportAutomatically')}`;
|
||||
iconKind = kinds.WARNING;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'importFailed') {
|
||||
title += ` - ${translate('ImportFailed', { sourceTitle })}`;
|
||||
iconKind = kinds.WARNING;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'importPending') {
|
||||
title += ' - Waiting to Import';
|
||||
title += ` - ${translate('WaitingToImport')}`;
|
||||
iconKind = kinds.PURPLE;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'importing') {
|
||||
title += ' - Importing';
|
||||
title += ` - ${translate('Importing')}`;
|
||||
iconKind = kinds.PURPLE;
|
||||
}
|
||||
|
||||
if (trackedDownloadState === 'failedPending') {
|
||||
title += ' - Waiting to Process';
|
||||
title += ` - ${translate('WaitingToProcess')}`;
|
||||
iconKind = kinds.DANGER;
|
||||
}
|
||||
}
|
||||
|
@ -91,36 +101,38 @@ function QueueStatusCell(props) {
|
|||
|
||||
if (status === 'delay') {
|
||||
iconName = icons.PENDING;
|
||||
title = 'Pending';
|
||||
title = translate('Pending');
|
||||
}
|
||||
|
||||
if (status === 'downloadClientUnavailable') {
|
||||
iconName = icons.PENDING;
|
||||
iconKind = kinds.WARNING;
|
||||
title = 'Pending - Download client is unavailable';
|
||||
title = translate('PendingDownloadClientUnavailable');
|
||||
}
|
||||
|
||||
if (status === 'failed') {
|
||||
iconName = icons.DOWNLOADING;
|
||||
iconKind = kinds.DANGER;
|
||||
title = 'Download failed';
|
||||
title = translate('DownloadFailed');
|
||||
}
|
||||
|
||||
if (status === 'warning') {
|
||||
iconName = icons.DOWNLOADING;
|
||||
iconKind = kinds.WARNING;
|
||||
title = `Download warning: ${errorMessage || 'check download client for more details'}`;
|
||||
const warningMessage =
|
||||
errorMessage || translate('CheckDownloadClientForDetails');
|
||||
title = translate('DownloadWarning', { warningMessage });
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
if (status === 'completed') {
|
||||
iconName = icons.DOWNLOAD;
|
||||
iconKind = kinds.DANGER;
|
||||
title = `Import failed: ${sourceTitle}`;
|
||||
title = translate('ImportFailed', { sourceTitle });
|
||||
} else {
|
||||
iconName = icons.DOWNLOADING;
|
||||
iconKind = kinds.DANGER;
|
||||
title = 'Download failed';
|
||||
title = translate('DownloadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,17 +8,17 @@ function ArtistMonitorNewItemsOptionsPopoverContent() {
|
|||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
title={translate('AllAlbums')}
|
||||
data="Monitor all new albums"
|
||||
data={translate('MonitorAllAlbums')}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('NewAlbums')}
|
||||
data="Monitor new albums released after the newest existing album"
|
||||
data={translate('MonitorNewAlbumsData')}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('None')}
|
||||
data="Don't monitor any new albums"
|
||||
data={translate('MonitorNoAlbumsData')}
|
||||
/>
|
||||
</DescriptionList>
|
||||
);
|
||||
|
|
|
@ -205,6 +205,7 @@ class AlbumDetails extends Component {
|
|||
isFetching,
|
||||
isPopulated,
|
||||
albumsError,
|
||||
tracksError,
|
||||
trackFilesError,
|
||||
hasTrackFiles,
|
||||
shortDateFormat,
|
||||
|
@ -552,8 +553,9 @@ class AlbumDetails extends Component {
|
|||
|
||||
<div className={styles.contentContainer}>
|
||||
{
|
||||
!isPopulated && !albumsError && !trackFilesError &&
|
||||
<LoadingIndicator />
|
||||
!isPopulated && !albumsError && !tracksError && !trackFilesError ?
|
||||
<LoadingIndicator /> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
|
@ -564,6 +566,14 @@ class AlbumDetails extends Component {
|
|||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && tracksError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('TracksLoadError')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && trackFilesError ?
|
||||
<Alert kind={kinds.DANGER}>
|
||||
|
@ -592,6 +602,14 @@ class AlbumDetails extends Component {
|
|||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !media.length ?
|
||||
<Alert kind={kinds.WARNING}>
|
||||
{translate('NoMediumInformation')}
|
||||
</Alert> :
|
||||
null
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<OrganizePreviewModalConnector
|
||||
|
@ -686,6 +704,7 @@ AlbumDetails.propTypes = {
|
|||
|
||||
AlbumDetails.defaultProps = {
|
||||
secondaryTypes: [],
|
||||
statistics: {},
|
||||
isSaving: false
|
||||
};
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ import ModalContent from 'Components/Modal/ModalContent';
|
|||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, inputTypes, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { icons, inputTypes, kinds, sizes, tooltipPositions } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EditArtistModalContent.css';
|
||||
|
||||
|
@ -93,7 +93,7 @@ class EditArtistModalContent extends Component {
|
|||
|
||||
<ModalBody>
|
||||
<Form {...otherProps}>
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
{translate('Monitored')}
|
||||
</FormLabel>
|
||||
|
@ -107,9 +107,10 @@ class EditArtistModalContent extends Component {
|
|||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
{translate('MonitorNewItems')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
|
@ -132,7 +133,7 @@ class EditArtistModalContent extends Component {
|
|||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
{translate('QualityProfile')}
|
||||
</FormLabel>
|
||||
|
@ -146,10 +147,10 @@ class EditArtistModalContent extends Component {
|
|||
</FormGroup>
|
||||
|
||||
{
|
||||
showMetadataProfile &&
|
||||
<FormGroup>
|
||||
showMetadataProfile ?
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
Metadata Profile
|
||||
{translate('MetadataProfile')}
|
||||
|
||||
<Popover
|
||||
anchor={
|
||||
|
@ -173,10 +174,11 @@ class EditArtistModalContent extends Component {
|
|||
{...metadataProfileId}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormGroup> :
|
||||
null
|
||||
}
|
||||
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
{translate('Path')}
|
||||
</FormLabel>
|
||||
|
@ -189,7 +191,7 @@ class EditArtistModalContent extends Component {
|
|||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormGroup size={sizes.MEDIUM}>
|
||||
<FormLabel>
|
||||
{translate('Tags')}
|
||||
</FormLabel>
|
||||
|
@ -209,7 +211,7 @@ class EditArtistModalContent extends Component {
|
|||
kind={kinds.DANGER}
|
||||
onPress={onDeleteArtistPress}
|
||||
>
|
||||
Delete
|
||||
{translate('Delete')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
|
|
@ -3,8 +3,8 @@ import React, { Component } from 'react';
|
|||
import Alert from 'Components/Alert';
|
||||
import PathInput from 'Components/Form/PathInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
|
@ -117,7 +117,7 @@ class FileBrowserModalContent extends Component {
|
|||
className={styles.mappedDrivesWarning}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
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.
|
||||
<InlineMarkdown data={translate('MappedNetworkDrivesWindowsService', { url: 'https://wiki.servarr.com/lidarr/faq#why-cant-lidarr-see-my-files-on-a-remote-server' })} />
|
||||
</Alert>
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ const EVENT_TYPE_OPTIONS = [
|
|||
{
|
||||
id: 7,
|
||||
get name() {
|
||||
return translate('ImportFailed');
|
||||
return translate('ImportCompleteFailed');
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -20,6 +20,8 @@ import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
|||
import TextInput from './TextInput';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
const MINIMUM_DISTANCE_FROM_EDGE = 10;
|
||||
|
||||
function isArrowKey(keyCode) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
|
@ -137,18 +139,9 @@ class EnhancedSelectInput extends Component {
|
|||
// Listeners
|
||||
|
||||
onComputeMaxHeight = (data) => {
|
||||
const {
|
||||
top,
|
||||
bottom
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^botton/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
|
||||
|
||||
return data;
|
||||
};
|
||||
|
@ -457,6 +450,10 @@ class EnhancedSelectInput extends Component {
|
|||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.onComputeMaxHeight
|
||||
},
|
||||
preventOverflow: {
|
||||
enabled: true,
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
@ -49,12 +49,12 @@ function getComponent(type) {
|
|||
case inputTypes.DEVICE:
|
||||
return DeviceInputConnector;
|
||||
|
||||
case inputTypes.PLAYLIST:
|
||||
return PlaylistInputConnector;
|
||||
|
||||
case inputTypes.KEY_VALUE_LIST:
|
||||
return KeyValueListInput;
|
||||
|
||||
case inputTypes.PLAYLIST:
|
||||
return PlaylistInputConnector;
|
||||
|
||||
case inputTypes.MONITOR_ALBUMS_SELECT:
|
||||
return MonitorAlbumsSelectInput;
|
||||
|
||||
|
|
|
@ -1,156 +0,0 @@
|
|||
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;
|
104
frontend/src/Components/Form/KeyValueListInput.tsx
Normal file
104
frontend/src/Components/Form/KeyValueListInput.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
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;
|
|
@ -5,13 +5,19 @@
|
|||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
.keyInputWrapper {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.valueInputWrapper {
|
||||
flex: 1 0 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
@ -20,6 +26,10 @@
|
|||
.valueInput {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background-color: var(--inputBackgroundColor);
|
||||
background-color: transparent;
|
||||
color: var(--textColor);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--helpTextColor);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,11 @@
|
|||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'buttonWrapper': string;
|
||||
'inputWrapper': string;
|
||||
'itemContainer': string;
|
||||
'keyInput': string;
|
||||
'keyInputWrapper': string;
|
||||
'valueInput': string;
|
||||
'valueInputWrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
|
@ -1,124 +0,0 @@
|
|||
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;
|
89
frontend/src/Components/Form/KeyValueListInputItem.tsx
Normal file
89
frontend/src/Components/Form/KeyValueListInputItem.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
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;
|
|
@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
|
|||
return inputTypes.CHECK;
|
||||
case 'device':
|
||||
return inputTypes.DEVICE;
|
||||
case 'keyValueList':
|
||||
return inputTypes.KEY_VALUE_LIST;
|
||||
case 'playlist':
|
||||
return inputTypes.PLAYLIST;
|
||||
case 'password':
|
||||
|
|
|
@ -83,13 +83,6 @@
|
|||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.modal.small,
|
||||
.modal.medium {
|
||||
width: 90%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.modalContainer {
|
||||
position: fixed;
|
||||
}
|
||||
|
|
|
@ -172,7 +172,7 @@ class SignalRConnector extends Component {
|
|||
const status = resource.status;
|
||||
|
||||
// Both successful and failed commands need to be
|
||||
// completed, otherwise they spin until they timeout.
|
||||
// completed, otherwise they spin until they time out.
|
||||
|
||||
if (status === 'completed' || status === 'failed') {
|
||||
this.props.dispatchFinishCommand(resource);
|
||||
|
@ -224,10 +224,58 @@ class SignalRConnector extends Component {
|
|||
repopulatePage('trackFileUpdated');
|
||||
};
|
||||
|
||||
handleDownloadclient = ({ action, resource }) => {
|
||||
const section = 'settings.downloadClients';
|
||||
|
||||
if (action === 'created' || action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...resource });
|
||||
} else if (action === 'deleted') {
|
||||
this.props.dispatchRemoveItem({ section, id: resource.id });
|
||||
}
|
||||
};
|
||||
|
||||
handleHealth = () => {
|
||||
this.props.dispatchFetchHealth();
|
||||
};
|
||||
|
||||
handleImportlist = ({ action, resource }) => {
|
||||
const section = 'settings.importLists';
|
||||
|
||||
if (action === 'created' || action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...resource });
|
||||
} else if (action === 'deleted') {
|
||||
this.props.dispatchRemoveItem({ section, id: resource.id });
|
||||
}
|
||||
};
|
||||
|
||||
handleIndexer = ({ action, resource }) => {
|
||||
const section = 'settings.indexers';
|
||||
|
||||
if (action === 'created' || action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...resource });
|
||||
} else if (action === 'deleted') {
|
||||
this.props.dispatchRemoveItem({ section, id: resource.id });
|
||||
}
|
||||
};
|
||||
|
||||
handleMetadata = ({ action, resource }) => {
|
||||
const section = 'settings.metadata';
|
||||
|
||||
if (action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...resource });
|
||||
}
|
||||
};
|
||||
|
||||
handleNotification = ({ action, resource }) => {
|
||||
const section = 'settings.notifications';
|
||||
|
||||
if (action === 'created' || action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...resource });
|
||||
} else if (action === 'deleted') {
|
||||
this.props.dispatchRemoveItem({ section, id: resource.id });
|
||||
}
|
||||
};
|
||||
|
||||
handleArtist = (body) => {
|
||||
const action = body.action;
|
||||
const section = 'artist';
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
line-height: 1.52857143;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.tableContainer {
|
||||
min-width: 100%;
|
||||
width: fit-content;
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.headerCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@
|
|||
height: 25px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.pager {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
@media only screen and (max-width: $breakpointMedium) {
|
||||
.headerCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
@ -2,8 +2,8 @@ export const AUTO_COMPLETE = 'autoComplete';
|
|||
export const CAPTCHA = 'captcha';
|
||||
export const CHECK = 'check';
|
||||
export const DEVICE = 'device';
|
||||
export const PLAYLIST = 'playlist';
|
||||
export const KEY_VALUE_LIST = 'keyValueList';
|
||||
export const PLAYLIST = 'playlist';
|
||||
export const MONITOR_ALBUMS_SELECT = 'monitorAlbumsSelect';
|
||||
export const MONITOR_NEW_ITEMS_SELECT = 'monitorNewItemsSelect';
|
||||
export const FLOAT = 'float';
|
||||
|
@ -34,8 +34,8 @@ export const all = [
|
|||
CAPTCHA,
|
||||
CHECK,
|
||||
DEVICE,
|
||||
PLAYLIST,
|
||||
KEY_VALUE_LIST,
|
||||
PLAYLIST,
|
||||
MONITOR_ALBUMS_SELECT,
|
||||
MONITOR_NEW_ITEMS_SELECT,
|
||||
FLOAT,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import TextInput from 'Components/Form/TextInput';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
|
@ -130,7 +131,8 @@ class AddNewItem extends Component {
|
|||
<div className={styles.helpText}>
|
||||
{translate('FailedLoadingSearchResults')}
|
||||
</div>
|
||||
<div>{getErrorMessage(error)}</div>
|
||||
|
||||
<Alert kind={kinds.DANGER}>{getErrorMessage(error)}</Alert>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
|
|
|
@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
|
|||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import {
|
||||
bulkDeleteCustomFormats,
|
||||
bulkEditCustomFormats,
|
||||
|
@ -34,7 +34,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
|
|||
typeof ManageCustomFormatsModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
|
@ -56,8 +56,6 @@ const COLUMNS = [
|
|||
|
||||
interface ManageCustomFormatsModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageCustomFormatsModalContent(
|
||||
|
|
|
@ -7,8 +7,8 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
|||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Link from 'Components/Link/Link';
|
||||
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
|
@ -52,12 +52,13 @@ function EditSpecificationModalContent(props) {
|
|||
fields && fields.some((x) => x.label === translate('CustomFormatsSpecificationRegularExpression')) &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
<div>
|
||||
<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>
|
||||
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
|
||||
</div>
|
||||
<div>
|
||||
{'Regular expressions can be tested '}
|
||||
<Link to="http://regexstorm.net/tester">Here</Link>
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
|
|
@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
|
|||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import {
|
||||
bulkDeleteDownloadClients,
|
||||
bulkEditDownloadClients,
|
||||
|
@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
|
|||
typeof ManageDownloadClientsModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
|
@ -82,8 +82,6 @@ const COLUMNS = [
|
|||
|
||||
interface ManageDownloadClientsModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageDownloadClientsModalContent(
|
||||
|
|
|
@ -10,11 +10,11 @@ import ModalBody from 'Components/Modal/ModalBody';
|
|||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import {
|
||||
bulkDeleteIndexers,
|
||||
bulkEditIndexers,
|
||||
|
@ -35,7 +35,7 @@ type OnSelectedChangeCallback = React.ComponentProps<
|
|||
typeof ManageIndexersModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
|
@ -82,8 +82,6 @@ const COLUMNS = [
|
|||
|
||||
interface ManageIndexersModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
|
||||
|
|
|
@ -94,9 +94,9 @@ class RootFolder extends Component {
|
|||
<ConfirmModal
|
||||
isOpen={this.state.isDeleteRootFolderModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteRootFolder')}
|
||||
message={translate('DeleteRootFolderMessageText', { name })}
|
||||
confirmLabel={translate('Delete')}
|
||||
title={translate('RemoveRootFolder')}
|
||||
message={translate('RemoveRootFolderArtistsMessageText', { name })}
|
||||
confirmLabel={translate('Remove')}
|
||||
onConfirm={this.onConfirmDeleteRootFolder}
|
||||
onCancel={this.onDeleteRootFolderModalClose}
|
||||
/>
|
||||
|
|
|
@ -24,19 +24,19 @@
|
|||
height: 20px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
.track {
|
||||
top: 9px;
|
||||
margin: 0 5px;
|
||||
height: 3px;
|
||||
background-color: var(--sliderAccentColor);
|
||||
box-shadow: 0 0 0 #000;
|
||||
|
||||
&:nth-child(3n+1) {
|
||||
&:nth-child(3n + 1) {
|
||||
background-color: #ddd;
|
||||
}
|
||||
}
|
||||
|
||||
.handle {
|
||||
.thumb {
|
||||
top: 1px;
|
||||
z-index: 0 !important;
|
||||
width: 18px;
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'bar': string;
|
||||
'handle': string;
|
||||
'kilobitsPerSecond': string;
|
||||
'quality': string;
|
||||
'qualityDefinition': string;
|
||||
|
@ -10,7 +8,9 @@ interface CssExports {
|
|||
'sizeLimit': string;
|
||||
'sizes': string;
|
||||
'slider': string;
|
||||
'thumb': string;
|
||||
'title': string;
|
||||
'track': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
|
|
|
@ -55,6 +55,27 @@ class QualityDefinition extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
trackRenderer(props, state) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={styles.track}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
thumbRenderer(props, state) {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={styles.thumb}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
|
@ -174,6 +195,7 @@ class QualityDefinition extends Component {
|
|||
|
||||
<div className={styles.sizeLimit}>
|
||||
<ReactSlider
|
||||
className={styles.slider}
|
||||
min={slider.min}
|
||||
max={slider.max}
|
||||
step={slider.step}
|
||||
|
@ -182,9 +204,9 @@ class QualityDefinition extends Component {
|
|||
withTracks={true}
|
||||
allowCross={false}
|
||||
snapDragDisabled={true}
|
||||
className={styles.slider}
|
||||
trackClassName={styles.bar}
|
||||
thumbClassName={styles.handle}
|
||||
pearling={true}
|
||||
renderThumb={this.thumbRenderer}
|
||||
renderTrack={this.trackRenderer}
|
||||
onChange={this.onSliderChange}
|
||||
onAfterChange={this.onAfterSliderChange}
|
||||
/>
|
||||
|
|
|
@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
|
|||
<InlineMarkdown data={translate('ConditionUsingRegularExpressions')} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink')} />
|
||||
<InlineMarkdown data={translate('RegularExpressionsTutorialLink', { url: 'https://www.regular-expressions.info/tutorial.html' })} />
|
||||
</div>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested')} />
|
||||
<InlineMarkdown data={translate('RegularExpressionsCanBeTested', { url: 'http://regexstorm.net/tester' })} />
|
||||
</div>
|
||||
</Alert>
|
||||
}
|
||||
|
|
|
@ -151,7 +151,7 @@ export const defaultState = {
|
|||
{
|
||||
name: 'genres',
|
||||
label: () => translate('Genres'),
|
||||
isSortable: false,
|
||||
isSortable: true,
|
||||
isVisible: false
|
||||
},
|
||||
{
|
||||
|
|
|
@ -150,7 +150,7 @@ export const defaultState = {
|
|||
},
|
||||
{
|
||||
key: 'importFailed',
|
||||
label: () => translate('ImportFailed'),
|
||||
label: () => translate('ImportCompleteFailed'),
|
||||
filters: [
|
||||
{
|
||||
key: 'eventType',
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import AlbumAppState from 'App/State/AlbumAppState';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Artist from 'Artist/Artist';
|
||||
import { createArtistSelectorForHook } from './createArtistSelector';
|
||||
|
@ -7,7 +8,7 @@ function createArtistAlbumsSelector(artistId: number) {
|
|||
return createSelector(
|
||||
(state: AppState) => state.albums,
|
||||
createArtistSelectorForHook(artistId),
|
||||
(albums, artist = {} as Artist) => {
|
||||
(albums: AlbumAppState, artist = {} as Artist) => {
|
||||
const { isFetching, isPopulated, error, items } = albums;
|
||||
|
||||
const filteredAlbums = items.filter(
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Artist from 'Artist/Artist';
|
||||
import MetadataProfile from 'typings/MetadataProfile';
|
||||
import { createArtistSelectorForHook } from './createArtistSelector';
|
||||
|
||||
function createArtistMetadataProfileSelector(artistId: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.metadataProfiles.items,
|
||||
createArtistSelectorForHook(artistId),
|
||||
(metadataProfiles, artist = {} as Artist) => {
|
||||
(metadataProfiles: MetadataProfile[], artist = {} as Artist) => {
|
||||
return metadataProfiles.find((profile) => {
|
||||
return profile.id === artist.metadataProfileId;
|
||||
});
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Artist from 'Artist/Artist';
|
||||
import QualityProfile from 'typings/QualityProfile';
|
||||
import { createArtistSelectorForHook } from './createArtistSelector';
|
||||
|
||||
function createArtistQualityProfileSelector(artistId: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.qualityProfiles.items,
|
||||
createArtistSelectorForHook(artistId),
|
||||
(qualityProfiles, artist = {} as Artist) => {
|
||||
(qualityProfiles: QualityProfile[], artist = {} as Artist) => {
|
||||
return qualityProfiles.find(
|
||||
(profile) => profile.id === artist.qualityProfileId
|
||||
);
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
|
@ -77,15 +77,16 @@ class LogFiles extends Component {
|
|||
<PageContentBody>
|
||||
<Alert>
|
||||
<div>
|
||||
Log files are located in: {location}
|
||||
{translate('LogFilesLocation', {
|
||||
location
|
||||
})}
|
||||
</div>
|
||||
|
||||
{
|
||||
currentLogView === 'Log Files' &&
|
||||
<div>
|
||||
The log level defaults to 'Info' and can be changed in <Link to="/settings/general">General Settings</Link>
|
||||
</div>
|
||||
}
|
||||
{currentLogView === 'Log Files' ? (
|
||||
<div>
|
||||
<InlineMarkdown data={translate('TheLogLevelDefault')} />
|
||||
</div>
|
||||
) : null}
|
||||
</Alert>
|
||||
|
||||
{
|
||||
|
|
|
@ -270,7 +270,7 @@ function Updates() {
|
|||
|
||||
{generalSettingsError ? (
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('FailedToUpdateSettings')}
|
||||
{translate('FailedToFetchSettings')}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -6,15 +6,33 @@ import isTomorrow from 'Utilities/Date/isTomorrow';
|
|||
import isYesterday from 'Utilities/Date/isYesterday';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function getRelativeDate(date, shortDateFormat, showRelativeDates, { timeFormat, includeSeconds = false, timeForToday = false } = {}) {
|
||||
interface GetRelativeDateOptions {
|
||||
timeFormat?: string;
|
||||
includeSeconds?: boolean;
|
||||
timeForToday?: boolean;
|
||||
}
|
||||
|
||||
function getRelativeDate(
|
||||
date: string | undefined,
|
||||
shortDateFormat: string,
|
||||
showRelativeDates: boolean,
|
||||
{
|
||||
timeFormat,
|
||||
includeSeconds = false,
|
||||
timeForToday = false,
|
||||
}: GetRelativeDateOptions = {}
|
||||
) {
|
||||
if (!date) {
|
||||
return null;
|
||||
return '';
|
||||
}
|
||||
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
if (isTodayDate && timeForToday && timeFormat) {
|
||||
return formatTime(date, timeFormat, { includeMinuteZero: true, includeSeconds });
|
||||
return formatTime(date, timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
includeSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
if (!showRelativeDates) {
|
|
@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise<boolean> {
|
|||
translations = data.strings;
|
||||
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
@ -27,6 +27,12 @@ export default function translate(
|
|||
key: string,
|
||||
tokens: Record<string, string | number | boolean> = {}
|
||||
) {
|
||||
const { isProduction = true } = window.Lidarr;
|
||||
|
||||
if (!isProduction && !(key in translations)) {
|
||||
console.warn(`Missing translation for key: ${key}`);
|
||||
}
|
||||
|
||||
const translation = translations[key] || key;
|
||||
|
||||
tokens.appName = 'Lidarr';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { createBrowserHistory } from 'history';
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import createAppStore from 'Store/createAppStore';
|
||||
import App from './App/App';
|
||||
|
||||
|
@ -9,9 +9,8 @@ import 'Diag/ConsoleApi';
|
|||
export async function bootstrap() {
|
||||
const history = createBrowserHistory();
|
||||
const store = createAppStore(history);
|
||||
const container = document.getElementById('root');
|
||||
|
||||
render(
|
||||
<App store={store} history={history} />,
|
||||
document.getElementById('root')
|
||||
);
|
||||
const root = createRoot(container!); // createRoot(container!) if you use TypeScript
|
||||
root.render(<App store={store} history={history} />);
|
||||
}
|
||||
|
|
|
@ -14,6 +14,32 @@ window.Lidarr = await response.json();
|
|||
__webpack_public_path__ = `${window.Lidarr.urlBase}/`;
|
||||
/* eslint-enable no-undef, @typescript-eslint/ban-ts-comment */
|
||||
|
||||
const error = console.error;
|
||||
|
||||
// Monkey patch console.error to filter out some warnings from React
|
||||
// TODO: Remove this after the great TypeScript migration
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function logError(...parameters: any[]) {
|
||||
const filter = parameters.find((parameter) => {
|
||||
return (
|
||||
typeof parameter === 'string' &&
|
||||
(parameter.includes(
|
||||
'Support for defaultProps will be removed from function components in a future major release'
|
||||
) ||
|
||||
parameter.includes(
|
||||
'findDOMNode is deprecated and will be removed in the next major release'
|
||||
))
|
||||
);
|
||||
});
|
||||
|
||||
if (!filter) {
|
||||
error(...parameters);
|
||||
}
|
||||
}
|
||||
|
||||
console.error = logError;
|
||||
|
||||
const { bootstrap } = await import('./bootstrap');
|
||||
|
||||
await bootstrap();
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
export type InputChanged<T = unknown> = {
|
||||
name: string;
|
||||
value: T;
|
||||
};
|
||||
|
||||
export type InputOnChange<T> = (change: InputChanged<T>) => void;
|
||||
|
||||
export type CheckInputChanged = {
|
||||
name: string;
|
||||
value: boolean;
|
||||
|
|
1
frontend/typings/Globals.d.ts
vendored
1
frontend/typings/Globals.d.ts
vendored
|
@ -7,5 +7,6 @@ interface Window {
|
|||
theme: string;
|
||||
urlBase: string;
|
||||
version: string;
|
||||
isProduction: boolean;
|
||||
};
|
||||
}
|
||||
|
|
36
package.json
36
package.json
|
@ -25,18 +25,18 @@
|
|||
"defaults"
|
||||
],
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "6.6.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.6.0",
|
||||
"@fortawesome/fontawesome-free": "6.7.1",
|
||||
"@fortawesome/fontawesome-svg-core": "6.7.1",
|
||||
"@fortawesome/free-regular-svg-icons": "6.7.1",
|
||||
"@fortawesome/free-solid-svg-icons": "6.7.1",
|
||||
"@fortawesome/react-fontawesome": "0.2.2",
|
||||
"@juggle/resize-observer": "3.4.0",
|
||||
"@microsoft/signalr": "6.0.25",
|
||||
"@sentry/browser": "7.119.1",
|
||||
"@sentry/integrations": "7.119.1",
|
||||
"@types/node": "20.16.11",
|
||||
"@types/react": "18.2.79",
|
||||
"@types/react-dom": "18.2.25",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"classnames": "2.5.1",
|
||||
"clipboard": "2.0.11",
|
||||
"connected-react-router": "6.9.3",
|
||||
|
@ -53,7 +53,7 @@
|
|||
"normalize.css": "8.0.1",
|
||||
"prop-types": "15.8.1",
|
||||
"qs": "6.13.0",
|
||||
"react": "17.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-addons-shallow-compare": "15.6.3",
|
||||
"react-async-script": "1.2.0",
|
||||
"react-autosuggest": "10.1.0",
|
||||
|
@ -63,7 +63,7 @@
|
|||
"react-dnd-multi-backend": "6.0.2",
|
||||
"react-dnd-touch-backend": "14.1.1",
|
||||
"react-document-title": "2.0.3",
|
||||
"react-dom": "17.0.2",
|
||||
"react-dom": "18.3.1",
|
||||
"react-focus-lock": "2.9.4",
|
||||
"react-google-recaptcha": "2.1.0",
|
||||
"react-lazyload": "3.2.0",
|
||||
|
@ -86,16 +86,16 @@
|
|||
"redux-thunk": "2.4.2",
|
||||
"reselect": "4.1.8",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"typescript": "5.1.6"
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.25.8",
|
||||
"@babel/eslint-parser": "7.25.8",
|
||||
"@babel/plugin-proposal-export-default-from": "7.25.8",
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/eslint-parser": "7.25.9",
|
||||
"@babel/plugin-proposal-export-default-from": "7.25.9",
|
||||
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||
"@babel/preset-env": "7.25.8",
|
||||
"@babel/preset-react": "7.25.7",
|
||||
"@babel/preset-typescript": "7.25.7",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@babel/preset-typescript": "7.26.0",
|
||||
"@types/lodash": "4.14.195",
|
||||
"@types/react-lazyload": "3.2.3",
|
||||
"@types/react-router-dom": "5.3.3",
|
||||
|
@ -103,13 +103,13 @@
|
|||
"@types/react-window": "1.8.8",
|
||||
"@types/redux-actions": "2.6.5",
|
||||
"@types/webpack-livereload-plugin": "2.3.6",
|
||||
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||
"@typescript-eslint/parser": "6.21.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.1",
|
||||
"@typescript-eslint/parser": "8.18.1",
|
||||
"autoprefixer": "10.4.20",
|
||||
"babel-loader": "9.2.1",
|
||||
"babel-plugin-inline-classnames": "2.0.1",
|
||||
"babel-plugin-transform-react-remove-prop-types": "0.4.24",
|
||||
"core-js": "3.38.1",
|
||||
"core-js": "3.41.0",
|
||||
"css-loader": "6.7.3",
|
||||
"css-modules-typescript-loader": "4.0.1",
|
||||
"eslint": "8.57.1",
|
||||
|
|
|
@ -99,6 +99,35 @@
|
|||
<RootNamespace Condition="'$(LidarrProject)'=='true'">$(MSBuildProjectName.Replace('Lidarr','NzbDrone'))</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TestProject)'!='true'">
|
||||
<!-- Annotates .NET assemblies with repository information including SHA -->
|
||||
<!-- Sentry uses this to link directly to GitHub at the exact version/file/line -->
|
||||
<!-- This is built-in on .NET 8 and can be removed once the project is updated -->
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Sentry specific configuration: Only in Release mode -->
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
<!-- https://docs.sentry.io/platforms/dotnet/configuration/msbuild/ -->
|
||||
<!-- OrgSlug, ProjectSlug and AuthToken are required.
|
||||
They can be set below, via argument to 'msbuild -p:' or environment variable -->
|
||||
<SentryOrg></SentryOrg>
|
||||
<SentryProject></SentryProject>
|
||||
<SentryUrl></SentryUrl> <!-- If empty, assumed to be sentry.io -->
|
||||
<SentryAuthToken></SentryAuthToken> <!-- Use env var instead: SENTRY_AUTH_TOKEN -->
|
||||
|
||||
<!-- Upload PDBs to Sentry, enabling stack traces with line numbers and file paths
|
||||
without the need to deploy the application with PDBs -->
|
||||
<SentryUploadSymbols>true</SentryUploadSymbols>
|
||||
|
||||
<!-- Source Link settings -->
|
||||
<!-- https://github.com/dotnet/sourcelink/blob/main/docs/README.md#publishrepositoryurl -->
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<!-- Embeds all source code in the respective PDB. This can make it a bit bigger but since it'll be uploaded
|
||||
to Sentry and not distributed to run on the server, it helps debug crashes while making releases smaller -->
|
||||
<EmbedAllSources>true</EmbedAllSources>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Standard testing packages -->
|
||||
<ItemGroup Condition="'$(TestProject)'=='true'">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
|
|
|
@ -20,7 +20,8 @@ namespace Lidarr.Api.V1.Albums
|
|||
}
|
||||
|
||||
[HttpGet]
|
||||
public object Search(string term)
|
||||
[Produces("application/json")]
|
||||
public IEnumerable<AlbumResource> Search(string term)
|
||||
{
|
||||
var searchResults = _searchProxy.SearchForNewAlbum(term, null);
|
||||
return MapToResource(searchResults).ToList();
|
||||
|
|
|
@ -23,7 +23,8 @@ namespace Lidarr.Api.V1.Artist
|
|||
}
|
||||
|
||||
[HttpGet]
|
||||
public object Search([FromQuery] string term)
|
||||
[Produces("application/json")]
|
||||
public IEnumerable<ArtistResource> Search([FromQuery] string term)
|
||||
{
|
||||
var searchResults = _searchProxy.SearchForNewArtist(term);
|
||||
return MapToResource(searchResults).ToList();
|
||||
|
|
|
@ -33,7 +33,6 @@ namespace Lidarr.Api.V1.Config
|
|||
|
||||
SharedValidator.RuleFor(c => c.BindAddress)
|
||||
.ValidIpAddress()
|
||||
.NotListenAllIp4Address()
|
||||
.When(c => c.BindAddress != "*" && c.BindAddress != "localhost");
|
||||
|
||||
SharedValidator.RuleFor(c => c.Port).ValidPort();
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
using FluentValidation;
|
||||
using Lidarr.Http;
|
||||
using NzbDrone.Core.Download;
|
||||
using NzbDrone.SignalR;
|
||||
|
||||
namespace Lidarr.Api.V1.DownloadClient
|
||||
{
|
||||
|
@ -9,9 +11,10 @@ namespace Lidarr.Api.V1.DownloadClient
|
|||
public static readonly DownloadClientResourceMapper ResourceMapper = new ();
|
||||
public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public DownloadClientController(IDownloadClientFactory downloadClientFactory)
|
||||
: base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
|
||||
public DownloadClientController(IBroadcastSignalRMessage signalRBroadcaster, IDownloadClientFactory downloadClientFactory)
|
||||
: base(signalRBroadcaster, downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
SharedValidator.RuleFor(c => c.Priority).InclusiveBetween(1, 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Lidarr.Http.REST;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Core.HealthCheck;
|
||||
|
||||
namespace Lidarr.Api.V1.Health
|
||||
|
@ -11,7 +10,7 @@ namespace Lidarr.Api.V1.Health
|
|||
public string Source { get; set; }
|
||||
public HealthCheckResult Type { get; set; }
|
||||
public string Message { get; set; }
|
||||
public HttpUri WikiUrl { get; set; }
|
||||
public string WikiUrl { get; set; }
|
||||
}
|
||||
|
||||
public static class HealthResourceMapper
|
||||
|
@ -29,7 +28,7 @@ namespace Lidarr.Api.V1.Health
|
|||
Source = model.Source.Name,
|
||||
Type = model.Type,
|
||||
Message = model.Message,
|
||||
WikiUrl = model.WikiUrl
|
||||
WikiUrl = model.WikiUrl.FullUri
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ using Lidarr.Http;
|
|||
using NzbDrone.Core.ImportLists;
|
||||
using NzbDrone.Core.Validation;
|
||||
using NzbDrone.Core.Validation.Paths;
|
||||
using NzbDrone.SignalR;
|
||||
|
||||
namespace Lidarr.Api.V1.ImportLists
|
||||
{
|
||||
|
@ -12,11 +13,12 @@ namespace Lidarr.Api.V1.ImportLists
|
|||
public static readonly ImportListResourceMapper ResourceMapper = new ();
|
||||
public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public ImportListController(IImportListFactory importListFactory,
|
||||
RootFolderExistsValidator rootFolderExistsValidator,
|
||||
QualityProfileExistsValidator qualityProfileExistsValidator,
|
||||
MetadataProfileExistsValidator metadataProfileExistsValidator)
|
||||
: base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
|
||||
public ImportListController(IBroadcastSignalRMessage signalRBroadcaster,
|
||||
IImportListFactory importListFactory,
|
||||
RootFolderExistsValidator rootFolderExistsValidator,
|
||||
QualityProfileExistsValidator qualityProfileExistsValidator,
|
||||
MetadataProfileExistsValidator metadataProfileExistsValidator)
|
||||
: base(signalRBroadcaster, importListFactory, "importlist", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
SharedValidator.RuleFor(c => c.RootFolderPath).Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
using FluentValidation;
|
||||
using Lidarr.Http;
|
||||
using NzbDrone.Core.Indexers;
|
||||
using NzbDrone.Core.Validation;
|
||||
using NzbDrone.SignalR;
|
||||
|
||||
namespace Lidarr.Api.V1.Indexers
|
||||
{
|
||||
|
@ -10,9 +12,12 @@ namespace Lidarr.Api.V1.Indexers
|
|||
public static readonly IndexerResourceMapper ResourceMapper = new ();
|
||||
public static readonly IndexerBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public IndexerController(IndexerFactory indexerFactory, DownloadClientExistsValidator downloadClientExistsValidator)
|
||||
: base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper)
|
||||
public IndexerController(IBroadcastSignalRMessage signalRBroadcaster,
|
||||
IndexerFactory indexerFactory,
|
||||
DownloadClientExistsValidator downloadClientExistsValidator)
|
||||
: base(signalRBroadcaster, indexerFactory, "indexer", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
SharedValidator.RuleFor(c => c.Priority).InclusiveBetween(1, 50);
|
||||
SharedValidator.RuleFor(c => c.DownloadClientId).SetValidator(downloadClientExistsValidator);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,8 @@
|
|||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.6.2" />
|
||||
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
<PackageReference Include="Ical.Net" Version="4.2.0" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="Ical.Net" Version="4.3.1" />
|
||||
<PackageReference Include="NLog" Version="5.4.0" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using Lidarr.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.Extras.Metadata;
|
||||
using NzbDrone.SignalR;
|
||||
|
||||
namespace Lidarr.Api.V1.Metadata
|
||||
{
|
||||
|
@ -11,8 +12,8 @@ namespace Lidarr.Api.V1.Metadata
|
|||
public static readonly MetadataResourceMapper ResourceMapper = new ();
|
||||
public static readonly MetadataBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public MetadataController(IMetadataFactory metadataFactory)
|
||||
: base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper)
|
||||
public MetadataController(IBroadcastSignalRMessage signalRBroadcaster, IMetadataFactory metadataFactory)
|
||||
: base(signalRBroadcaster, metadataFactory, "metadata", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using Lidarr.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Core.Notifications;
|
||||
using NzbDrone.SignalR;
|
||||
|
||||
namespace Lidarr.Api.V1.Notifications
|
||||
{
|
||||
|
@ -11,8 +12,8 @@ namespace Lidarr.Api.V1.Notifications
|
|||
public static readonly NotificationResourceMapper ResourceMapper = new ();
|
||||
public static readonly NotificationBulkResourceMapper BulkResourceMapper = new ();
|
||||
|
||||
public NotificationController(NotificationFactory notificationFactory)
|
||||
: base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
|
||||
public NotificationController(IBroadcastSignalRMessage signalRBroadcaster, NotificationFactory notificationFactory)
|
||||
: base(signalRBroadcaster, notificationFactory, "notification", ResourceMapper, BulkResourceMapper)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -7,12 +7,19 @@ using Lidarr.Http.REST.Attributes;
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Datastore.Events;
|
||||
using NzbDrone.Core.Messaging.Events;
|
||||
using NzbDrone.Core.ThingiProvider;
|
||||
using NzbDrone.Core.ThingiProvider.Events;
|
||||
using NzbDrone.Core.Validation;
|
||||
using NzbDrone.SignalR;
|
||||
|
||||
namespace Lidarr.Api.V1
|
||||
{
|
||||
public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestController<TProviderResource>
|
||||
public abstract class ProviderControllerBase<TProviderResource, TBulkProviderResource, TProvider, TProviderDefinition> : RestControllerWithSignalR<TProviderResource, TProviderDefinition>,
|
||||
IHandle<ProviderAddedEvent<TProvider>>,
|
||||
IHandle<ProviderUpdatedEvent<TProvider>>,
|
||||
IHandle<ProviderDeletedEvent<TProvider>>
|
||||
where TProviderDefinition : ProviderDefinition, new()
|
||||
where TProvider : IProvider
|
||||
where TProviderResource : ProviderResource<TProviderResource>, new()
|
||||
|
@ -22,11 +29,13 @@ namespace Lidarr.Api.V1
|
|||
private readonly ProviderResourceMapper<TProviderResource, TProviderDefinition> _resourceMapper;
|
||||
private readonly ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> _bulkResourceMapper;
|
||||
|
||||
protected ProviderControllerBase(IProviderFactory<TProvider,
|
||||
protected ProviderControllerBase(IBroadcastSignalRMessage signalRBroadcaster,
|
||||
IProviderFactory<TProvider,
|
||||
TProviderDefinition> providerFactory,
|
||||
string resource,
|
||||
ProviderResourceMapper<TProviderResource, TProviderDefinition> resourceMapper,
|
||||
ProviderBulkResourceMapper<TBulkProviderResource, TProviderDefinition> bulkResourceMapper)
|
||||
: base(signalRBroadcaster)
|
||||
{
|
||||
_providerFactory = providerFactory;
|
||||
_resourceMapper = resourceMapper;
|
||||
|
@ -261,6 +270,24 @@ namespace Lidarr.Api.V1
|
|||
return Content(data.ToJson(), "application/json");
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public virtual void Handle(ProviderAddedEvent<TProvider> message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Created, message.Definition.Id);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public virtual void Handle(ProviderUpdatedEvent<TProvider> message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Updated, message.Definition.Id);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
public virtual void Handle(ProviderDeletedEvent<TProvider> message)
|
||||
{
|
||||
BroadcastResourceChange(ModelAction.Deleted, message.ProviderId);
|
||||
}
|
||||
|
||||
protected virtual void Validate(TProviderDefinition definition, bool includeWarnings)
|
||||
{
|
||||
var validationResult = definition.Settings.Validate();
|
||||
|
|
|
@ -302,7 +302,7 @@ namespace Lidarr.Api.V1.Queue
|
|||
|
||||
if (blocklist)
|
||||
{
|
||||
_failedDownloadService.MarkAsFailed(trackedDownload.DownloadItem.DownloadId, skipRedownload);
|
||||
_failedDownloadService.MarkAsFailed(trackedDownload, skipRedownload);
|
||||
}
|
||||
|
||||
if (!removeFromClient && !blocklist && !changeCategory)
|
||||
|
|
|
@ -4,6 +4,7 @@ using Lidarr.Http;
|
|||
using Lidarr.Http.REST;
|
||||
using Lidarr.Http.REST.Attributes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.RemotePathMappings;
|
||||
using NzbDrone.Core.Validation.Paths;
|
||||
|
||||
|
@ -21,17 +22,28 @@ namespace Lidarr.Api.V1.RemotePathMappings
|
|||
_remotePathMappingService = remotePathMappingService;
|
||||
|
||||
SharedValidator.RuleFor(c => c.Host)
|
||||
.NotEmpty();
|
||||
.NotEmpty();
|
||||
|
||||
// We cannot use IsValidPath here, because it's a remote path, possibly other OS.
|
||||
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)
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
.SetValidator(mappedNetworkDriveValidator)
|
||||
.SetValidator(pathExistsValidator);
|
||||
.Cascade(CascadeMode.Stop)
|
||||
.IsValidPath()
|
||||
.SetValidator(mappedNetworkDriveValidator)
|
||||
.SetValidator(pathExistsValidator)
|
||||
.SetValidator(new SystemFolderValidator())
|
||||
.NotEqual("/")
|
||||
.WithMessage("Cannot be set to '/'");
|
||||
}
|
||||
|
||||
public override RemotePathMappingResource GetResourceById(int id)
|
||||
|
@ -41,7 +53,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
|
|||
|
||||
[RestPostById]
|
||||
[Consumes("application/json")]
|
||||
public ActionResult<RemotePathMappingResource> CreateMapping(RemotePathMappingResource resource)
|
||||
public ActionResult<RemotePathMappingResource> CreateMapping([FromBody] RemotePathMappingResource resource)
|
||||
{
|
||||
var model = resource.ToModel();
|
||||
|
||||
|
@ -62,7 +74,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
|
|||
}
|
||||
|
||||
[RestPutById]
|
||||
public ActionResult<RemotePathMappingResource> UpdateMapping(RemotePathMappingResource resource)
|
||||
public ActionResult<RemotePathMappingResource> UpdateMapping([FromBody] RemotePathMappingResource resource)
|
||||
{
|
||||
var mapping = resource.ToModel();
|
||||
|
||||
|
|
|
@ -24,7 +24,8 @@ namespace Lidarr.Api.V1.Search
|
|||
}
|
||||
|
||||
[HttpGet]
|
||||
public object Search([FromQuery] string term)
|
||||
[Produces("application/json")]
|
||||
public IEnumerable<SearchResource> Search([FromQuery] string term)
|
||||
{
|
||||
var searchResults = _searchProxy.SearchForNewEntity(term);
|
||||
return MapToResource(searchResults).ToList();
|
||||
|
|
|
@ -50,7 +50,7 @@ namespace Lidarr.Api.V1.System.Backup
|
|||
}
|
||||
|
||||
[RestDeleteById]
|
||||
public void DeleteBackup(int id)
|
||||
public object DeleteBackup(int id)
|
||||
{
|
||||
var backup = GetBackup(id);
|
||||
|
||||
|
@ -67,6 +67,8 @@ namespace Lidarr.Api.V1.System.Backup
|
|||
}
|
||||
|
||||
_diskProvider.DeleteFile(path);
|
||||
|
||||
return new { };
|
||||
}
|
||||
|
||||
[HttpPost("restore/{id:int}")]
|
||||
|
@ -90,7 +92,7 @@ namespace Lidarr.Api.V1.System.Backup
|
|||
}
|
||||
|
||||
[HttpPost("restore/upload")]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1000000000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 5000000000)]
|
||||
public object UploadAndRestore()
|
||||
{
|
||||
var files = Request.Form.Files;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using FluentValidation;
|
||||
using Lidarr.Http;
|
||||
using Lidarr.Http.REST;
|
||||
using Lidarr.Http.REST.Attributes;
|
||||
|
@ -23,6 +24,8 @@ namespace Lidarr.Api.V1.Tags
|
|||
: base(signalRBroadcaster)
|
||||
{
|
||||
_tagService = tagService;
|
||||
|
||||
SharedValidator.RuleFor(c => c.Label).NotEmpty();
|
||||
}
|
||||
|
||||
public override TagResource GetResourceById(int id)
|
||||
|
|
|
@ -327,7 +327,17 @@
|
|||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AlbumResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -620,7 +630,17 @@
|
|||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ArtistResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7292,7 +7312,17 @@
|
|||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SearchResource"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9778,7 +9808,8 @@
|
|||
"nullable": true
|
||||
},
|
||||
"wikiUrl": {
|
||||
"$ref": "#/components/schemas/HttpUri"
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
@ -10025,48 +10056,9 @@
|
|||
"backupRetention": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HttpUri": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fullUri": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"scheme": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"host": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
},
|
||||
"fragment": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"readOnly": true
|
||||
"trustCgnatIpAddresses": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
@ -12394,6 +12386,26 @@
|
|||
],
|
||||
"type": "string"
|
||||
},
|
||||
"SearchResource": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"foreignId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"artist": {
|
||||
"$ref": "#/components/schemas/ArtistResource"
|
||||
},
|
||||
"album": {
|
||||
"$ref": "#/components/schemas/AlbumResource"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SecondaryAlbumType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -12869,6 +12881,7 @@
|
|||
"downloading",
|
||||
"downloadFailed",
|
||||
"downloadFailedPending",
|
||||
"importBlocked",
|
||||
"importPending",
|
||||
"importing",
|
||||
"importFailed",
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.Authentication;
|
||||
using NzbDrone.Core.Configuration;
|
||||
|
@ -16,11 +21,15 @@ namespace Lidarr.Http.Authentication
|
|||
{
|
||||
private readonly IAuthenticationService _authService;
|
||||
private readonly IConfigFileProvider _configFileProvider;
|
||||
private readonly IAppFolderInfo _appFolderInfo;
|
||||
private readonly Logger _logger;
|
||||
|
||||
public AuthenticationController(IAuthenticationService authService, IConfigFileProvider configFileProvider)
|
||||
public AuthenticationController(IAuthenticationService authService, IConfigFileProvider configFileProvider, IAppFolderInfo appFolderInfo, Logger logger)
|
||||
{
|
||||
_authService = authService;
|
||||
_configFileProvider = configFileProvider;
|
||||
_appFolderInfo = appFolderInfo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
|
@ -45,7 +54,23 @@ namespace Lidarr.Http.Authentication
|
|||
IsPersistent = resource.RememberMe == "on"
|
||||
};
|
||||
|
||||
await HttpContext.SignInAsync(AuthenticationType.Forms.ToString(), new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties);
|
||||
try
|
||||
{
|
||||
await HttpContext.SignInAsync(AuthenticationType.Forms.ToString(), new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "user", "identifier")), authProperties);
|
||||
}
|
||||
catch (CryptographicException e)
|
||||
{
|
||||
if (e.InnerException is XmlException)
|
||||
{
|
||||
_logger.Error(e, "Failed to authenticate user due to corrupt XML. Please remove all XML files from {0} and restart Lidarr", Path.Combine(_appFolderInfo.AppDataFolder, "asp"));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error(e, "Failed to authenticate user. {0}", e.Message);
|
||||
}
|
||||
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (returnUrl.IsNullOrWhiteSpace() || !Url.IsLocalUrl(returnUrl))
|
||||
{
|
||||
|
|
|
@ -77,7 +77,7 @@ namespace Lidarr.Http.Authentication
|
|||
|
||||
private void LogSuccess(HttpRequest context, string username)
|
||||
{
|
||||
_authLogger.Info("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username);
|
||||
_authLogger.Debug("Auth-Success ip {0} username '{1}'", context.GetRemoteIP(), username);
|
||||
}
|
||||
|
||||
private void LogLogout(HttpRequest context, string username)
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation" Version="9.5.4" />
|
||||
<PackageReference Include="ImpromptuInterface" Version="7.0.1" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.4.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Core\Lidarr.Core.csproj" />
|
||||
|
|
|
@ -40,15 +40,16 @@ namespace NzbDrone.Automation.Test
|
|||
var service = ChromeDriverService.CreateDefaultService();
|
||||
|
||||
// Timeout as windows automation tests seem to take alot longer to get going
|
||||
driver = new ChromeDriver(service, options, new TimeSpan(0, 3, 0));
|
||||
driver = new ChromeDriver(service, options, TimeSpan.FromMinutes(3));
|
||||
|
||||
driver.Manage().Window.Size = new System.Drawing.Size(1920, 1080);
|
||||
driver.Manage().Window.FullScreen();
|
||||
|
||||
_runner = new NzbDroneRunner(LogManager.GetCurrentClassLogger(), null);
|
||||
_runner.KillAll();
|
||||
_runner.Start(true);
|
||||
|
||||
driver.Url = "http://localhost:8686";
|
||||
driver.Navigate().GoToUrl("http://localhost:8686");
|
||||
|
||||
var page = new PageBase(driver);
|
||||
page.WaitForNoSpinner();
|
||||
|
@ -68,7 +69,7 @@ namespace NzbDrone.Automation.Test
|
|||
{
|
||||
try
|
||||
{
|
||||
var image = ((ITakesScreenshot)driver).GetScreenshot();
|
||||
var image = (driver as ITakesScreenshot).GetScreenshot();
|
||||
image.SaveAsFile($"./{name}_test_screenshot.png", ScreenshotImageFormat.Png);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Selenium.Support" Version="3.141.0" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="111.0.5563.6400" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="134.0.6998.16500" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\NzbDrone.Test.Common\Lidarr.Test.Common.csproj" />
|
||||
|
|
|
@ -1,19 +1,17 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using OpenQA.Selenium;
|
||||
using OpenQA.Selenium.Remote;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
|
||||
namespace NzbDrone.Automation.Test.PageModel
|
||||
{
|
||||
public class PageBase
|
||||
{
|
||||
private readonly RemoteWebDriver _driver;
|
||||
private readonly IWebDriver _driver;
|
||||
|
||||
public PageBase(RemoteWebDriver driver)
|
||||
public PageBase(IWebDriver driver)
|
||||
{
|
||||
_driver = driver;
|
||||
driver.Manage().Window.Maximize();
|
||||
}
|
||||
|
||||
public IWebElement FindByClass(string className, int timeout = 5)
|
||||
|
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
|||
using FluentAssertions;
|
||||
using NLog;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Instrumentation.Sentry;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
|
@ -27,7 +28,7 @@ namespace NzbDrone.Common.Test.InstrumentationTests
|
|||
[SetUp]
|
||||
public void Setup()
|
||||
{
|
||||
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111");
|
||||
_subject = new SentryTarget("https://aaaaaaaaaaaaaaaaaaaaaaaaaa@sentry.io/111111", Mocker.GetMock<IAppFolderInfo>().Object);
|
||||
}
|
||||
|
||||
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)
|
||||
|
|
|
@ -42,17 +42,18 @@ namespace NzbDrone.Common
|
|||
|
||||
public void CreateZip(string path, IEnumerable<string> files)
|
||||
{
|
||||
using (var zipFile = ZipFile.Create(path))
|
||||
_logger.Debug("Creating archive {0}", path);
|
||||
|
||||
using var zipFile = ZipFile.Create(path);
|
||||
|
||||
zipFile.BeginUpdate();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
zipFile.BeginUpdate();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
zipFile.Add(file, Path.GetFileName(file));
|
||||
}
|
||||
|
||||
zipFile.CommitUpdate();
|
||||
zipFile.Add(file, Path.GetFileName(file));
|
||||
}
|
||||
|
||||
zipFile.CommitUpdate();
|
||||
}
|
||||
|
||||
private void ExtractZip(string compressedFile, string destination)
|
||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnsureThat;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
|
@ -306,9 +307,26 @@ namespace NzbDrone.Common.Disk
|
|||
{
|
||||
Ensure.That(path, () => path).IsValidPath(PathValidationType.CurrentOs);
|
||||
|
||||
var files = GetFiles(path, recursive);
|
||||
var files = GetFiles(path, recursive).ToList();
|
||||
|
||||
files.ToList().ForEach(RemoveReadOnly);
|
||||
files.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);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ namespace NzbDrone.Common.Disk
|
|||
private readonly IDiskProvider _diskProvider;
|
||||
private readonly Logger _logger;
|
||||
|
||||
private static readonly string[] _reflinkFilesystems = { "btrfs", "xfs" };
|
||||
private static readonly string[] ReflinkFilesystems = { "btrfs", "xfs", "zfs" };
|
||||
|
||||
public DiskTransferService(IDiskProvider diskProvider, Logger logger)
|
||||
{
|
||||
|
@ -343,7 +343,7 @@ namespace NzbDrone.Common.Disk
|
|||
var targetDriveFormat = targetMount?.DriveFormat ?? string.Empty;
|
||||
|
||||
var isCifs = targetDriveFormat == "cifs";
|
||||
var tryReflink = sourceDriveFormat == targetDriveFormat && _reflinkFilesystems.Contains(sourceDriveFormat);
|
||||
var tryReflink = sourceDriveFormat == targetDriveFormat && ReflinkFilesystems.Contains(sourceDriveFormat);
|
||||
|
||||
if (mode.HasFlag(TransferMode.Copy))
|
||||
{
|
||||
|
|
|
@ -17,37 +17,6 @@ namespace NzbDrone.Common.Disk
|
|||
private readonly IDiskProvider _diskProvider;
|
||||
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)
|
||||
{
|
||||
_diskProvider = diskProvider;
|
||||
|
@ -158,7 +127,7 @@ namespace NzbDrone.Common.Disk
|
|||
})
|
||||
.ToList();
|
||||
|
||||
directories.RemoveAll(d => _setToRemove.Contains(d.Name.ToLowerInvariant()));
|
||||
directories.RemoveAll(d => SpecialFolders.IsSpecialFolder(d.Name));
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
|
47
src/NzbDrone.Common/Disk/SpecialFolders.cs
Normal file
47
src/NzbDrone.Common/Disk/SpecialFolders.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
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());
|
||||
}
|
||||
}
|
|
@ -141,7 +141,7 @@ namespace NzbDrone.Common.Http.Dispatchers
|
|||
}
|
||||
catch (OperationCanceledException ex) when (cts.IsCancellationRequested)
|
||||
{
|
||||
throw new WebException("Http request timed out", ex.InnerException, WebExceptionStatus.Timeout, null);
|
||||
throw new WebException("Http request timed out", ex, WebExceptionStatus.Timeout, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ using NLog.Targets;
|
|||
|
||||
namespace NzbDrone.Common.Instrumentation
|
||||
{
|
||||
public class NzbDroneFileTarget : FileTarget
|
||||
public class CleansingFileTarget : FileTarget
|
||||
{
|
||||
protected override void RenderFormattedMessage(LogEventInfo logEvent, StringBuilder target)
|
||||
{
|
|
@ -3,7 +3,6 @@ using System.Diagnostics;
|
|||
using System.IO;
|
||||
using NLog;
|
||||
using NLog.Config;
|
||||
using NLog.Layouts.ClefJsonLayout;
|
||||
using NLog.Targets;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
@ -13,9 +12,11 @@ namespace NzbDrone.Common.Instrumentation
|
|||
{
|
||||
public static class NzbDroneLogger
|
||||
{
|
||||
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}}";
|
||||
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 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 ConsoleFormat = "[${level}] ${logger}: ${message} ${onexception:inner=${newline}${newline}[v${assembly-version}] ${exception:format=ToString}${newline}${exception:format=Data}${newline}}";
|
||||
|
||||
private static readonly CleansingConsoleLogLayout CleansingConsoleLayout = new (ConsoleFormat);
|
||||
private static readonly CleansingClefLogLayout ClefLogLayout = new ();
|
||||
|
||||
private static bool _isConfigured;
|
||||
|
||||
|
@ -44,7 +45,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
RegisterDebugger();
|
||||
}
|
||||
|
||||
RegisterSentry(updateApp);
|
||||
RegisterSentry(updateApp, appFolderInfo);
|
||||
|
||||
if (updateApp)
|
||||
{
|
||||
|
@ -65,7 +66,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
LogManager.ReconfigExistingLoggers();
|
||||
}
|
||||
|
||||
private static void RegisterSentry(bool updateClient)
|
||||
private static void RegisterSentry(bool updateClient, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
string dsn;
|
||||
|
||||
|
@ -80,7 +81,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
: "https://0522924d625c497f86fc2a1b22aaf21d@sentry.servarr.com/16";
|
||||
}
|
||||
|
||||
var target = new SentryTarget(dsn)
|
||||
var target = new SentryTarget(dsn, appFolderInfo)
|
||||
{
|
||||
Name = "sentryTarget",
|
||||
Layout = "${message}"
|
||||
|
@ -118,11 +119,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
? formatEnumValue
|
||||
: ConsoleLogFormat.Standard;
|
||||
|
||||
coloredConsoleTarget.Layout = logFormat switch
|
||||
{
|
||||
ConsoleLogFormat.Clef => ClefLogLayout,
|
||||
_ => ConsoleLogLayout
|
||||
};
|
||||
ConfigureConsoleLayout(coloredConsoleTarget, logFormat);
|
||||
|
||||
var loggingRule = new LoggingRule("*", level, coloredConsoleTarget);
|
||||
|
||||
|
@ -139,7 +136,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
|
||||
private static void RegisterAppFile(IAppFolderInfo appFolderInfo, string name, string fileName, int maxArchiveFiles, LogLevel minLogLevel)
|
||||
{
|
||||
var fileTarget = new NzbDroneFileTarget();
|
||||
var fileTarget = new CleansingFileTarget();
|
||||
|
||||
fileTarget.Name = name;
|
||||
fileTarget.FileName = Path.Combine(appFolderInfo.GetLogFolder(), fileName);
|
||||
|
@ -152,7 +149,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
fileTarget.MaxArchiveFiles = maxArchiveFiles;
|
||||
fileTarget.EnableFileDelete = true;
|
||||
fileTarget.ArchiveNumbering = ArchiveNumberingMode.Rolling;
|
||||
fileTarget.Layout = FILE_LOG_LAYOUT;
|
||||
fileTarget.Layout = FileLogLayout;
|
||||
|
||||
var loggingRule = new LoggingRule("*", minLogLevel, fileTarget);
|
||||
|
||||
|
@ -171,7 +168,7 @@ namespace NzbDrone.Common.Instrumentation
|
|||
fileTarget.ConcurrentWrites = false;
|
||||
fileTarget.ConcurrentWriteAttemptDelay = 50;
|
||||
fileTarget.ConcurrentWriteAttempts = 100;
|
||||
fileTarget.Layout = FILE_LOG_LAYOUT;
|
||||
fileTarget.Layout = FileLogLayout;
|
||||
|
||||
var loggingRule = new LoggingRule("*", LogLevel.Trace, fileTarget);
|
||||
|
||||
|
@ -216,6 +213,15 @@ namespace NzbDrone.Common.Instrumentation
|
|||
{
|
||||
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
|
||||
|
|
|
@ -9,6 +9,7 @@ using NLog;
|
|||
using NLog.Common;
|
||||
using NLog.Targets;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using Sentry;
|
||||
|
||||
namespace NzbDrone.Common.Instrumentation.Sentry
|
||||
|
@ -99,7 +100,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
|||
public bool FilterEvents { get; set; }
|
||||
public bool SentryEnabled { get; set; }
|
||||
|
||||
public SentryTarget(string dsn)
|
||||
public SentryTarget(string dsn, IAppFolderInfo appFolderInfo)
|
||||
{
|
||||
_sdk = SentrySdk.Init(o =>
|
||||
{
|
||||
|
@ -107,9 +108,33 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
|||
o.AttachStacktrace = true;
|
||||
o.MaxBreadcrumbs = 200;
|
||||
o.Release = $"{BuildInfo.AppName}@{BuildInfo.Release}";
|
||||
o.BeforeSend = x => SentryCleanser.CleanseEvent(x);
|
||||
o.BeforeBreadcrumb = x => SentryCleanser.CleanseBreadcrumb(x);
|
||||
o.SetBeforeSend(x => SentryCleanser.CleanseEvent(x));
|
||||
o.SetBeforeBreadcrumb(x => SentryCleanser.CleanseBreadcrumb(x));
|
||||
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();
|
||||
|
@ -127,7 +152,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
|||
{
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new User
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = HashUtil.AnonymousToken()
|
||||
};
|
||||
|
@ -169,9 +194,7 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
|||
|
||||
private void OnError(Exception ex)
|
||||
{
|
||||
var webException = ex as WebException;
|
||||
|
||||
if (webException != null)
|
||||
if (ex is WebException webException)
|
||||
{
|
||||
var response = webException.Response as HttpWebResponse;
|
||||
var statusCode = response?.StatusCode;
|
||||
|
@ -290,13 +313,21 @@ namespace NzbDrone.Common.Instrumentation.Sentry
|
|||
}
|
||||
}
|
||||
|
||||
var level = LoggingLevelMap[logEvent.Level];
|
||||
var sentryEvent = new SentryEvent(logEvent.Exception)
|
||||
{
|
||||
Level = LoggingLevelMap[logEvent.Level],
|
||||
Level = level,
|
||||
Logger = logEvent.LoggerName,
|
||||
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.SetFingerprint(fingerPrint);
|
||||
|
||||
|
|
|
@ -6,17 +6,17 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
|
||||
<PackageReference Include="IPAddressRange" Version="6.0.0" />
|
||||
<PackageReference Include="IPAddressRange" Version="6.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NLog" Version="5.3.3" />
|
||||
<PackageReference Include="NLog" Version="5.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
|
||||
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.0" />
|
||||
<PackageReference Include="Sentry" Version="3.25.0" />
|
||||
<PackageReference Include="NLog.Layouts.ClefJsonLayout" Version="1.0.3" />
|
||||
<PackageReference Include="Sentry" Version="4.0.2" />
|
||||
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||
<PackageReference Include="System.IO.Abstractions" Version="17.0.24" />
|
||||
<PackageReference Include="System.Text.Json" Version="6.0.10" />
|
||||
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
|
||||
<PackageReference Include="System.ValueTuple" Version="4.6.1" />
|
||||
<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.Configuration.ConfigurationManager" Version="6.0.1" />
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Extensions;
|
||||
|
@ -6,7 +7,7 @@ namespace NzbDrone.Common
|
|||
{
|
||||
public class PathEqualityComparer : IEqualityComparer<string>
|
||||
{
|
||||
public static readonly PathEqualityComparer Instance = new PathEqualityComparer();
|
||||
public static readonly PathEqualityComparer Instance = new ();
|
||||
|
||||
private PathEqualityComparer()
|
||||
{
|
||||
|
@ -19,12 +20,19 @@ namespace NzbDrone.Common
|
|||
|
||||
public int GetHashCode(string obj)
|
||||
{
|
||||
if (OsInfo.IsWindows)
|
||||
try
|
||||
{
|
||||
return obj.CleanFilePath().Normalize().ToLower().GetHashCode();
|
||||
}
|
||||
if (OsInfo.IsWindows)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ using System.ComponentModel;
|
|||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using NLog;
|
||||
using NzbDrone.Common.EnvironmentInfo;
|
||||
using NzbDrone.Common.Model;
|
||||
|
@ -117,7 +118,9 @@ namespace NzbDrone.Common.Processes
|
|||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardInput = true
|
||||
RedirectStandardInput = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
if (environmentVariables != null)
|
||||
|
@ -313,7 +316,7 @@ namespace NzbDrone.Common.Processes
|
|||
processInfo = new ProcessInfo();
|
||||
processInfo.Id = process.Id;
|
||||
processInfo.Name = process.ProcessName;
|
||||
processInfo.StartPath = process.MainModule.FileName;
|
||||
processInfo.StartPath = process.MainModule?.FileName;
|
||||
|
||||
if (process.Id != GetCurrentProcessId() && process.HasExited)
|
||||
{
|
||||
|
|
|
@ -34,7 +34,8 @@ namespace NzbDrone.Common.Reflection
|
|||
|| type == typeof(string)
|
||||
|| type == typeof(DateTime)
|
||||
|| type == typeof(Version)
|
||||
|| type == typeof(decimal);
|
||||
|| type == typeof(decimal)
|
||||
|| (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>));
|
||||
}
|
||||
|
||||
public static bool IsReadable(this PropertyInfo propertyInfo)
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -103,6 +103,7 @@ namespace NzbDrone.Core.Test.DiskSpace
|
|||
[TestCase("/var/lib/docker")]
|
||||
[TestCase("/some/place/docker/aufs")]
|
||||
[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)
|
||||
{
|
||||
var mount = new Mock<IMount>();
|
||||
|
|
|
@ -183,6 +183,8 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
|||
{
|
||||
GivenArtistMatch();
|
||||
|
||||
var tracks = Builder<Track>.CreateListOfSize(3).BuildList();
|
||||
|
||||
_trackedDownload.RemoteAlbum.Albums = new List<Album>
|
||||
{
|
||||
CreateAlbum(1, 3)
|
||||
|
@ -192,9 +194,9 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
|||
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>()))
|
||||
.Returns(new List<ImportResult>
|
||||
{
|
||||
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() })),
|
||||
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[0] } })),
|
||||
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(), Tracks = new List<Track> { tracks[2] } })),
|
||||
new ImportResult(new ImportDecision<LocalTrack>(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() }), "Test Failure")
|
||||
});
|
||||
|
||||
|
@ -290,6 +292,9 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
|||
[Test]
|
||||
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>
|
||||
{
|
||||
CreateAlbum(1, 2)
|
||||
|
@ -301,11 +306,11 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
|||
{
|
||||
new ImportResult(
|
||||
new ImportDecision<LocalTrack>(
|
||||
new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })),
|
||||
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.S01E02.mkv".AsOsAgnostic() }))
|
||||
new LocalTrack { Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic(), Tracks = new List<Track> { track2 } }))
|
||||
});
|
||||
|
||||
Subject.Import(_trackedDownload);
|
||||
|
@ -367,11 +372,13 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
|
|||
{
|
||||
GivenABadlyNamedDownload();
|
||||
|
||||
var track1 = new Track { Id = 1 };
|
||||
|
||||
Mocker.GetMock<IDownloadedTracksImportService>()
|
||||
.Setup(v => v.ProcessPath(It.IsAny<string>(), It.IsAny<ImportMode>(), It.IsAny<Artist>(), It.IsAny<DownloadClientItem>()))
|
||||
.Returns(new List<ImportResult>
|
||||
{
|
||||
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> { track1 } }))
|
||||
});
|
||||
|
||||
Mocker.GetMock<IArtistService>()
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue