-
\\^$.|?*+()[{ have special meanings and need escaping with a \\
' }} />
- {'More details'} {'Here'}
+
- {'Regular expressions can be tested '}
- Here
+
+
+
+
}
diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx
index 734f5efab..b2c1208cb 100644
--- a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx
+++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx
@@ -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(
diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx
index dbb394959..997d1b566 100644
--- a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx
+++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx
@@ -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) {
diff --git a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js
index 47e5dfcf1..dc91e4622 100644
--- a/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js
+++ b/frontend/src/Settings/MediaManagement/RootFolder/RootFolder.js
@@ -94,9 +94,9 @@ class RootFolder extends Component {
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
index e090428a1..860333725 100644
--- a/frontend/src/Settings/Quality/Definition/QualityDefinition.css
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css
@@ -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;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts b/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts
index 2b92fb212..9c9e8393a 100644
--- a/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.css.d.ts
@@ -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;
diff --git a/frontend/src/Settings/Quality/Definition/QualityDefinition.js b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
index 7d8a78737..48251abfb 100644
--- a/frontend/src/Settings/Quality/Definition/QualityDefinition.js
+++ b/frontend/src/Settings/Quality/Definition/QualityDefinition.js
@@ -55,6 +55,27 @@ class QualityDefinition extends Component {
};
}
+ //
+ // Control
+
+ trackRenderer(props, state) {
+ return (
+
+ );
+ }
+
+ thumbRenderer(props, state) {
+ return (
+
+ );
+ }
+
//
// Listeners
@@ -174,6 +195,7 @@ class QualityDefinition extends Component {
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js
index 2ab1e4a1c..04302729b 100644
--- a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js
+++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js
@@ -86,10 +86,10 @@ function EditSpecificationModalContent(props) {
-
+
-
+
}
diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js
index 055e92662..736502460 100644
--- a/frontend/src/Store/Actions/artistIndexActions.js
+++ b/frontend/src/Store/Actions/artistIndexActions.js
@@ -151,7 +151,7 @@ export const defaultState = {
{
name: 'genres',
label: () => translate('Genres'),
- isSortable: false,
+ isSortable: true,
isVisible: false
},
{
diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js
index 225698229..9d16d29c4 100644
--- a/frontend/src/Store/Actions/historyActions.js
+++ b/frontend/src/Store/Actions/historyActions.js
@@ -150,7 +150,7 @@ export const defaultState = {
},
{
key: 'importFailed',
- label: () => translate('ImportFailed'),
+ label: () => translate('ImportCompleteFailed'),
filters: [
{
key: 'eventType',
diff --git a/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts
index baffd87ec..414a451f5 100644
--- a/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts
+++ b/frontend/src/Store/Selectors/createArtistAlbumsSelector.ts
@@ -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(
diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts
index 0acbd3997..fa60d936d 100644
--- a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts
+++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts
@@ -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;
});
diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts
index 99325276f..67639919b 100644
--- a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts
+++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts
@@ -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
);
diff --git a/frontend/src/System/Logs/Files/LogFiles.js b/frontend/src/System/Logs/Files/LogFiles.js
index 83736c617..5339a8590 100644
--- a/frontend/src/System/Logs/Files/LogFiles.js
+++ b/frontend/src/System/Logs/Files/LogFiles.js
@@ -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 {
- Log files are located in: {location}
+ {translate('LogFilesLocation', {
+ location
+ })}
- {
- currentLogView === 'Log Files' &&
-
- The log level defaults to 'Info' and can be changed in General Settings
-
- }
+ {currentLogView === 'Log Files' ? (
+
+
+
+ ) : null}
{
diff --git a/frontend/src/System/Updates/Updates.tsx b/frontend/src/System/Updates/Updates.tsx
index ef3d20288..300ab1f99 100644
--- a/frontend/src/System/Updates/Updates.tsx
+++ b/frontend/src/System/Updates/Updates.tsx
@@ -270,7 +270,7 @@ function Updates() {
{generalSettingsError ? (
- {translate('FailedToUpdateSettings')}
+ {translate('FailedToFetchSettings')}
) : null}
diff --git a/frontend/src/Utilities/Date/getRelativeDate.js b/frontend/src/Utilities/Date/getRelativeDate.ts
similarity index 65%
rename from frontend/src/Utilities/Date/getRelativeDate.js
rename to frontend/src/Utilities/Date/getRelativeDate.ts
index 812064272..178d14fb7 100644
--- a/frontend/src/Utilities/Date/getRelativeDate.js
+++ b/frontend/src/Utilities/Date/getRelativeDate.ts
@@ -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) {
diff --git a/frontend/src/Utilities/String/translate.ts b/frontend/src/Utilities/String/translate.ts
index 98a0418ad..5571ef6b0 100644
--- a/frontend/src/Utilities/String/translate.ts
+++ b/frontend/src/Utilities/String/translate.ts
@@ -17,7 +17,7 @@ export async function fetchTranslations(): Promise {
translations = data.strings;
resolve(true);
- } catch (error) {
+ } catch {
resolve(false);
}
});
@@ -27,6 +27,12 @@ export default function translate(
key: string,
tokens: Record = {}
) {
+ 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';
diff --git a/frontend/src/bootstrap.tsx b/frontend/src/bootstrap.tsx
index 6a6d7fc67..9ecf27e0e 100644
--- a/frontend/src/bootstrap.tsx
+++ b/frontend/src/bootstrap.tsx
@@ -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(
- ,
- document.getElementById('root')
- );
+ const root = createRoot(container!); // createRoot(container!) if you use TypeScript
+ root.render();
}
diff --git a/frontend/src/index.ts b/frontend/src/index.ts
index 36aed4c4b..37e780919 100644
--- a/frontend/src/index.ts
+++ b/frontend/src/index.ts
@@ -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();
diff --git a/frontend/src/typings/inputs.ts b/frontend/src/typings/inputs.ts
index c0fda305c..7d202cd44 100644
--- a/frontend/src/typings/inputs.ts
+++ b/frontend/src/typings/inputs.ts
@@ -1,3 +1,10 @@
+export type InputChanged = {
+ name: string;
+ value: T;
+};
+
+export type InputOnChange = (change: InputChanged) => void;
+
export type CheckInputChanged = {
name: string;
value: boolean;
diff --git a/frontend/typings/Globals.d.ts b/frontend/typings/Globals.d.ts
index b2b10fb70..3509249fc 100644
--- a/frontend/typings/Globals.d.ts
+++ b/frontend/typings/Globals.d.ts
@@ -7,5 +7,6 @@ interface Window {
theme: string;
urlBase: string;
version: string;
+ isProduction: boolean;
};
}
diff --git a/package.json b/package.json
index 7f75cf6a1..642d79a12 100644
--- a/package.json
+++ b/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",
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index adc1a01b7..f9853bdd5 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -99,6 +99,35 @@
$(MSBuildProjectName.Replace('Lidarr','NzbDrone'))
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+
+
+
+ true
+
+ true
+
+
diff --git a/src/Lidarr.Api.V1/Albums/AlbumLookupController.cs b/src/Lidarr.Api.V1/Albums/AlbumLookupController.cs
index cd4e6b54a..e8853f611 100644
--- a/src/Lidarr.Api.V1/Albums/AlbumLookupController.cs
+++ b/src/Lidarr.Api.V1/Albums/AlbumLookupController.cs
@@ -20,7 +20,8 @@ namespace Lidarr.Api.V1.Albums
}
[HttpGet]
- public object Search(string term)
+ [Produces("application/json")]
+ public IEnumerable Search(string term)
{
var searchResults = _searchProxy.SearchForNewAlbum(term, null);
return MapToResource(searchResults).ToList();
diff --git a/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs b/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs
index 27ff021a1..19a18a0ba 100644
--- a/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs
+++ b/src/Lidarr.Api.V1/Artist/ArtistLookupController.cs
@@ -23,7 +23,8 @@ namespace Lidarr.Api.V1.Artist
}
[HttpGet]
- public object Search([FromQuery] string term)
+ [Produces("application/json")]
+ public IEnumerable Search([FromQuery] string term)
{
var searchResults = _searchProxy.SearchForNewArtist(term);
return MapToResource(searchResults).ToList();
diff --git a/src/Lidarr.Api.V1/Config/HostConfigController.cs b/src/Lidarr.Api.V1/Config/HostConfigController.cs
index 9046943e8..00e705325 100644
--- a/src/Lidarr.Api.V1/Config/HostConfigController.cs
+++ b/src/Lidarr.Api.V1/Config/HostConfigController.cs
@@ -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();
diff --git a/src/Lidarr.Api.V1/DownloadClient/DownloadClientController.cs b/src/Lidarr.Api.V1/DownloadClient/DownloadClientController.cs
index bd4c993bf..b1cbb3ab5 100644
--- a/src/Lidarr.Api.V1/DownloadClient/DownloadClientController.cs
+++ b/src/Lidarr.Api.V1/DownloadClient/DownloadClientController.cs
@@ -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);
}
}
}
diff --git a/src/Lidarr.Api.V1/Health/HealthResource.cs b/src/Lidarr.Api.V1/Health/HealthResource.cs
index 9de525009..b059198db 100644
--- a/src/Lidarr.Api.V1/Health/HealthResource.cs
+++ b/src/Lidarr.Api.V1/Health/HealthResource.cs
@@ -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
};
}
diff --git a/src/Lidarr.Api.V1/ImportLists/ImportListController.cs b/src/Lidarr.Api.V1/ImportLists/ImportListController.cs
index ff2ed98db..24a823e58 100644
--- a/src/Lidarr.Api.V1/ImportLists/ImportListController.cs
+++ b/src/Lidarr.Api.V1/ImportLists/ImportListController.cs
@@ -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()
diff --git a/src/Lidarr.Api.V1/Indexers/IndexerController.cs b/src/Lidarr.Api.V1/Indexers/IndexerController.cs
index 2ebcd3f29..462c68898 100644
--- a/src/Lidarr.Api.V1/Indexers/IndexerController.cs
+++ b/src/Lidarr.Api.V1/Indexers/IndexerController.cs
@@ -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);
}
}
diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj
index 7ad7f394f..187fa86ff 100644
--- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj
+++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj
@@ -12,8 +12,8 @@
-
-
+
+
diff --git a/src/Lidarr.Api.V1/Metadata/MetadataController.cs b/src/Lidarr.Api.V1/Metadata/MetadataController.cs
index 01e82ad37..4349058b0 100644
--- a/src/Lidarr.Api.V1/Metadata/MetadataController.cs
+++ b/src/Lidarr.Api.V1/Metadata/MetadataController.cs
@@ -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)
{
}
diff --git a/src/Lidarr.Api.V1/Notifications/NotificationController.cs b/src/Lidarr.Api.V1/Notifications/NotificationController.cs
index dc792fc1f..7e5f45064 100644
--- a/src/Lidarr.Api.V1/Notifications/NotificationController.cs
+++ b/src/Lidarr.Api.V1/Notifications/NotificationController.cs
@@ -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)
{
}
diff --git a/src/Lidarr.Api.V1/ProviderControllerBase.cs b/src/Lidarr.Api.V1/ProviderControllerBase.cs
index 8d0b88c4a..c630dddd9 100644
--- a/src/Lidarr.Api.V1/ProviderControllerBase.cs
+++ b/src/Lidarr.Api.V1/ProviderControllerBase.cs
@@ -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 : RestController
+ public abstract class ProviderControllerBase : RestControllerWithSignalR,
+ IHandle>,
+ IHandle>,
+ IHandle>
where TProviderDefinition : ProviderDefinition, new()
where TProvider : IProvider
where TProviderResource : ProviderResource, new()
@@ -22,11 +29,13 @@ namespace Lidarr.Api.V1
private readonly ProviderResourceMapper _resourceMapper;
private readonly ProviderBulkResourceMapper _bulkResourceMapper;
- protected ProviderControllerBase(IProviderFactory providerFactory,
string resource,
ProviderResourceMapper resourceMapper,
ProviderBulkResourceMapper 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 message)
+ {
+ BroadcastResourceChange(ModelAction.Created, message.Definition.Id);
+ }
+
+ [NonAction]
+ public virtual void Handle(ProviderUpdatedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Updated, message.Definition.Id);
+ }
+
+ [NonAction]
+ public virtual void Handle(ProviderDeletedEvent message)
+ {
+ BroadcastResourceChange(ModelAction.Deleted, message.ProviderId);
+ }
+
protected virtual void Validate(TProviderDefinition definition, bool includeWarnings)
{
var validationResult = definition.Settings.Validate();
diff --git a/src/Lidarr.Api.V1/Queue/QueueController.cs b/src/Lidarr.Api.V1/Queue/QueueController.cs
index 730b50436..8123f30fe 100644
--- a/src/Lidarr.Api.V1/Queue/QueueController.cs
+++ b/src/Lidarr.Api.V1/Queue/QueueController.cs
@@ -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)
diff --git a/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs b/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs
index f0679e27b..fae5b2388 100644
--- a/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs
+++ b/src/Lidarr.Api.V1/RemotePathMappings/RemotePathMappingController.cs
@@ -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 CreateMapping(RemotePathMappingResource resource)
+ public ActionResult CreateMapping([FromBody] RemotePathMappingResource resource)
{
var model = resource.ToModel();
@@ -62,7 +74,7 @@ namespace Lidarr.Api.V1.RemotePathMappings
}
[RestPutById]
- public ActionResult UpdateMapping(RemotePathMappingResource resource)
+ public ActionResult UpdateMapping([FromBody] RemotePathMappingResource resource)
{
var mapping = resource.ToModel();
diff --git a/src/Lidarr.Api.V1/Search/SearchController.cs b/src/Lidarr.Api.V1/Search/SearchController.cs
index 9dc33066c..220afa064 100644
--- a/src/Lidarr.Api.V1/Search/SearchController.cs
+++ b/src/Lidarr.Api.V1/Search/SearchController.cs
@@ -24,7 +24,8 @@ namespace Lidarr.Api.V1.Search
}
[HttpGet]
- public object Search([FromQuery] string term)
+ [Produces("application/json")]
+ public IEnumerable Search([FromQuery] string term)
{
var searchResults = _searchProxy.SearchForNewEntity(term);
return MapToResource(searchResults).ToList();
diff --git a/src/Lidarr.Api.V1/System/Backup/BackupController.cs b/src/Lidarr.Api.V1/System/Backup/BackupController.cs
index fba675267..350ada72b 100644
--- a/src/Lidarr.Api.V1/System/Backup/BackupController.cs
+++ b/src/Lidarr.Api.V1/System/Backup/BackupController.cs
@@ -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;
diff --git a/src/Lidarr.Api.V1/Tags/TagController.cs b/src/Lidarr.Api.V1/Tags/TagController.cs
index a0e76335e..14f1aef64 100644
--- a/src/Lidarr.Api.V1/Tags/TagController.cs
+++ b/src/Lidarr.Api.V1/Tags/TagController.cs
@@ -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)
diff --git a/src/Lidarr.Api.V1/openapi.json b/src/Lidarr.Api.V1/openapi.json
index 51bf02eaa..4c0462717 100644
--- a/src/Lidarr.Api.V1/openapi.json
+++ b/src/Lidarr.Api.V1/openapi.json
@@ -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",
diff --git a/src/Lidarr.Http/Authentication/AuthenticationController.cs b/src/Lidarr.Http/Authentication/AuthenticationController.cs
index 2fc588dd2..f7281cf5c 100644
--- a/src/Lidarr.Http/Authentication/AuthenticationController.cs
+++ b/src/Lidarr.Http/Authentication/AuthenticationController.cs
@@ -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))
{
diff --git a/src/Lidarr.Http/Authentication/AuthenticationService.cs b/src/Lidarr.Http/Authentication/AuthenticationService.cs
index 64dd0f323..d01cd9911 100644
--- a/src/Lidarr.Http/Authentication/AuthenticationService.cs
+++ b/src/Lidarr.Http/Authentication/AuthenticationService.cs
@@ -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)
diff --git a/src/Lidarr.Http/Lidarr.Http.csproj b/src/Lidarr.Http/Lidarr.Http.csproj
index 180b3d08f..103ca71ea 100644
--- a/src/Lidarr.Http/Lidarr.Http.csproj
+++ b/src/Lidarr.Http/Lidarr.Http.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/src/NzbDrone.Automation.Test/AutomationTest.cs b/src/NzbDrone.Automation.Test/AutomationTest.cs
index bcf777431..51c79539e 100644
--- a/src/NzbDrone.Automation.Test/AutomationTest.cs
+++ b/src/NzbDrone.Automation.Test/AutomationTest.cs
@@ -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)
diff --git a/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj b/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj
index ada550253..8204721f3 100644
--- a/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj
+++ b/src/NzbDrone.Automation.Test/Lidarr.Automation.Test.csproj
@@ -4,7 +4,7 @@
-
+
diff --git a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs
index c9a7e8891..664ec7258 100644
--- a/src/NzbDrone.Automation.Test/PageModel/PageBase.cs
+++ b/src/NzbDrone.Automation.Test/PageModel/PageBase.cs
@@ -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)
diff --git a/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs
index a2400c55b..2c730bd93 100644
--- a/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs
+++ b/src/NzbDrone.Common.Test/InstrumentationTests/SentryTargetFixture.cs
@@ -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().Object);
}
private LogEventInfo GivenLogEvent(LogLevel level, Exception ex, string message)
diff --git a/src/NzbDrone.Common/ArchiveService.cs b/src/NzbDrone.Common/ArchiveService.cs
index 800d240ab..d420bbbc0 100644
--- a/src/NzbDrone.Common/ArchiveService.cs
+++ b/src/NzbDrone.Common/ArchiveService.cs
@@ -42,17 +42,18 @@ namespace NzbDrone.Common
public void CreateZip(string path, IEnumerable 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)
diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs
index dfdb6b54c..01aaaaded 100644
--- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs
+++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs
@@ -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);
}
diff --git a/src/NzbDrone.Common/Disk/DiskTransferService.cs b/src/NzbDrone.Common/Disk/DiskTransferService.cs
index 9bfb5a7c1..c0c506c09 100644
--- a/src/NzbDrone.Common/Disk/DiskTransferService.cs
+++ b/src/NzbDrone.Common/Disk/DiskTransferService.cs
@@ -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))
{
diff --git a/src/NzbDrone.Common/Disk/FileSystemLookupService.cs b/src/NzbDrone.Common/Disk/FileSystemLookupService.cs
index 427c4237e..be32f7638 100644
--- a/src/NzbDrone.Common/Disk/FileSystemLookupService.cs
+++ b/src/NzbDrone.Common/Disk/FileSystemLookupService.cs
@@ -17,37 +17,6 @@ namespace NzbDrone.Common.Disk
private readonly IDiskProvider _diskProvider;
private readonly IRuntimeInfo _runtimeInfo;
- private readonly HashSet _setToRemove = new HashSet
- {
- // 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;
}
diff --git a/src/NzbDrone.Common/Disk/SpecialFolders.cs b/src/NzbDrone.Common/Disk/SpecialFolders.cs
new file mode 100644
index 000000000..b1339a7ed
--- /dev/null
+++ b/src/NzbDrone.Common/Disk/SpecialFolders.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+
+namespace NzbDrone.Common.Disk;
+
+public static class SpecialFolders
+{
+ private static readonly HashSet _specialFolders = new HashSet
+ {
+ // 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());
+ }
+}
diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs
index 8ca01f6ec..9d896d15c 100644
--- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs
+++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs
@@ -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);
}
}
diff --git a/src/NzbDrone.Common/Instrumentation/CleansingClefLogLayout.cs b/src/NzbDrone.Common/Instrumentation/CleansingClefLogLayout.cs
new file mode 100644
index 000000000..f110b96ac
--- /dev/null
+++ b/src/NzbDrone.Common/Instrumentation/CleansingClefLogLayout.cs
@@ -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);
+ }
+ }
+}
diff --git a/src/NzbDrone.Common/Instrumentation/CleansingConsoleLogLayout.cs b/src/NzbDrone.Common/Instrumentation/CleansingConsoleLogLayout.cs
new file mode 100644
index 000000000..f894a4df5
--- /dev/null
+++ b/src/NzbDrone.Common/Instrumentation/CleansingConsoleLogLayout.cs
@@ -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);
+ }
+ }
+}
diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneFileTarget.cs b/src/NzbDrone.Common/Instrumentation/CleansingFileTarget.cs
similarity index 87%
rename from src/NzbDrone.Common/Instrumentation/NzbDroneFileTarget.cs
rename to src/NzbDrone.Common/Instrumentation/CleansingFileTarget.cs
index 84658cf74..f74d1fca4 100644
--- a/src/NzbDrone.Common/Instrumentation/NzbDroneFileTarget.cs
+++ b/src/NzbDrone.Common/Instrumentation/CleansingFileTarget.cs
@@ -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)
{
diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs
index 74883c959..c33211019 100644
--- a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs
+++ b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs
@@ -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
diff --git a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs
index 39b0a920a..3f8d4c227 100644
--- a/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs
+++ b/src/NzbDrone.Common/Instrumentation/Sentry/SentryTarget.cs
@@ -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);
diff --git a/src/NzbDrone.Common/Lidarr.Common.csproj b/src/NzbDrone.Common/Lidarr.Common.csproj
index 10870072d..2e5bacde4 100644
--- a/src/NzbDrone.Common/Lidarr.Common.csproj
+++ b/src/NzbDrone.Common/Lidarr.Common.csproj
@@ -6,17 +6,17 @@
-
+
-
+
-
-
+
+
-
+
diff --git a/src/NzbDrone.Common/PathEqualityComparer.cs b/src/NzbDrone.Common/PathEqualityComparer.cs
index bd6fa430d..e8322864a 100644
--- a/src/NzbDrone.Common/PathEqualityComparer.cs
+++ b/src/NzbDrone.Common/PathEqualityComparer.cs
@@ -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
{
- 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);
+ }
}
}
}
diff --git a/src/NzbDrone.Common/Processes/ProcessProvider.cs b/src/NzbDrone.Common/Processes/ProcessProvider.cs
index 4e23a7e0c..bee099319 100644
--- a/src/NzbDrone.Common/Processes/ProcessProvider.cs
+++ b/src/NzbDrone.Common/Processes/ProcessProvider.cs
@@ -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)
{
diff --git a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs
index 8f016450d..a47137bfd 100644
--- a/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs
+++ b/src/NzbDrone.Common/Reflection/ReflectionExtensions.cs
@@ -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)
diff --git a/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs b/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs
new file mode 100644
index 000000000..79d0adaee
--- /dev/null
+++ b/src/NzbDrone.Core.Test/Datastore/Converters/TimeSpanConverterFixture.cs
@@ -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
+{
+ 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);
+ }
+}
diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseVersionParserFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseVersionParserFixture.cs
new file mode 100644
index 000000000..05bf04fea
--- /dev/null
+++ b/src/NzbDrone.Core.Test/Datastore/DatabaseVersionParserFixture.cs
@@ -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);
+ }
+ }
+}
diff --git a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs
index 948ab3a54..dd501374c 100644
--- a/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs
+++ b/src/NzbDrone.Core.Test/DiskSpace/DiskSpaceServiceFixture.cs
@@ -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();
diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs
index 16f6cfd1a..9719b7f1f 100644
--- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs
+++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceTests/ImportFixture.cs
@@ -183,6 +183,8 @@ namespace NzbDrone.Core.Test.Download.CompletedDownloadServiceTests
{
GivenArtistMatch();
+ var tracks = Builder