{
@@ -194,6 +200,7 @@ class AlbumDetailsMedium extends Component {
AlbumDetailsMedium.propTypes = {
albumId: PropTypes.number.isRequired,
albumMonitored: PropTypes.bool.isRequired,
+ albumReleaseDate: PropTypes.string,
mediumNumber: PropTypes.number.isRequired,
mediumFormat: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
diff --git a/frontend/src/Album/Details/AlbumDetailsMediumConnector.js b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js
index 17c6969e3..4aade6c32 100644
--- a/frontend/src/Album/Details/AlbumDetailsMediumConnector.js
+++ b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js
@@ -57,6 +57,7 @@ class AlbumDetailsMediumConnector extends Component {
AlbumDetailsMediumConnector.propTypes = {
albumId: PropTypes.number.isRequired,
albumMonitored: PropTypes.bool.isRequired,
+ albumReleaseDate: PropTypes.string,
mediumNumber: PropTypes.number.isRequired,
setTracksTableOption: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js
index db128d493..226ede5f1 100644
--- a/frontend/src/Album/Details/TrackRow.js
+++ b/frontend/src/Album/Details/TrackRow.js
@@ -188,7 +188,7 @@ class TrackRow extends Component {
className={styles.status}
>
diff --git a/frontend/src/Album/EpisodeStatus.js b/frontend/src/Album/EpisodeStatus.js
index fc976103b..cd7086313 100644
--- a/frontend/src/Album/EpisodeStatus.js
+++ b/frontend/src/Album/EpisodeStatus.js
@@ -11,7 +11,7 @@ import styles from './EpisodeStatus.css';
function EpisodeStatus(props) {
const {
- airDateUtc,
+ releaseDate,
monitored,
grabbed,
queueItem,
@@ -20,7 +20,7 @@ function EpisodeStatus(props) {
const hasTrackFile = !!trackFile;
const isQueued = !!queueItem;
- const hasAired = isBefore(airDateUtc);
+ const isReleased = isBefore(releaseDate);
if (isQueued) {
const {
@@ -74,7 +74,7 @@ function EpisodeStatus(props) {
);
}
- if (!airDateUtc) {
+ if (!releaseDate) {
return (
{
const result = _.pick(album, [
- 'airDateUtc',
+ 'releaseDate',
'monitored',
'grabbed'
]);
diff --git a/frontend/src/Artist/Details/AlbumRow.js b/frontend/src/Artist/Details/AlbumRow.js
index b5c8fa2cf..8a9a7a6b9 100644
--- a/frontend/src/Artist/Details/AlbumRow.js
+++ b/frontend/src/Artist/Details/AlbumRow.js
@@ -10,15 +10,20 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { kinds, sizes } from 'Helpers/Props';
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
+import isAfter from 'Utilities/Date/isAfter';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
import styles from './AlbumRow.css';
-function getTrackCountKind(monitored, trackFileCount, trackCount) {
+function getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount) {
if (trackFileCount === trackCount && trackCount > 0) {
return kinds.SUCCESS;
}
+ if (!releaseDate || isAfter(releaseDate)) {
+ return kinds.DISABLED;
+ }
+
if (!monitored) {
return kinds.WARNING;
}
@@ -215,7 +220,7 @@ class AlbumRow extends Component {
>
{
diff --git a/frontend/src/Artist/Details/ArtistDetails.css b/frontend/src/Artist/Details/ArtistDetails.css
index 43daa40d0..50f466683 100644
--- a/frontend/src/Artist/Details/ArtistDetails.css
+++ b/frontend/src/Artist/Details/ArtistDetails.css
@@ -5,7 +5,6 @@
.header {
position: relative;
width: 100%;
- height: 310px;
}
.errorMessage {
@@ -36,20 +35,18 @@
width: 100%;
height: 100%;
color: var(--white);
+ gap: 35px;
}
.poster {
flex-shrink: 0;
- margin-right: 35px;
width: 250px;
height: 250px;
}
.info {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
overflow: hidden;
+ width: 100%;
}
.metadataMessage {
@@ -72,6 +69,8 @@
}
.title {
+ overflow: hidden;
+ max-height: calc(2 * 50px);
font-weight: 300;
font-size: 50px;
line-height: 50px;
@@ -144,8 +143,12 @@
.overview {
flex: 1 0 auto;
+ overflow-x: auto;
margin-top: 8px;
min-height: 0;
+ max-height: 150px;
+ text-wrap: balance;
+ white-space: pre-wrap;
font-size: $intermediateFontSize;
}
@@ -163,6 +166,7 @@
}
.title {
+ max-height: calc(3 * 30px);
font-weight: 300;
font-size: 30px;
line-height: 30px;
diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js
index 1bfa767c3..e7500b74b 100644
--- a/frontend/src/Artist/Details/ArtistDetails.js
+++ b/frontend/src/Artist/Details/ArtistDetails.js
@@ -1,7 +1,6 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
-import TextTruncate from 'react-text-truncate';
import ArtistPoster from 'Artist/ArtistPoster';
import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal';
import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector';
@@ -28,7 +27,6 @@ import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
-import fonts from 'Styles/Variables/fonts';
import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal';
import formatBytes from 'Utilities/Number/formatBytes';
import translate from 'Utilities/String/translate';
@@ -42,9 +40,6 @@ import ArtistGenres from './ArtistGenres';
import ArtistTagsConnector from './ArtistTagsConnector';
import styles from './ArtistDetails.css';
-const defaultFontSize = parseInt(fonts.defaultFontSize);
-const lineHeight = parseFloat(fonts.lineHeight);
-
function getFanartUrl(images) {
return _.find(images, { coverType: 'fanart' })?.url;
}
@@ -394,7 +389,7 @@ class ArtistDetails extends Component {
/>
-
+
{artistName}
@@ -595,12 +590,8 @@ class ArtistDetails extends Component {
}
-
-
-
+
+ {overview}
diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumRow.js b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
index 6b50a1ce9..db0bb6ad0 100644
--- a/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
+++ b/frontend/src/InteractiveImport/Album/SelectAlbumRow.js
@@ -5,14 +5,19 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableRow from 'Components/Table/TableRow';
import { kinds, sizes } from 'Helpers/Props';
+import isAfter from 'Utilities/Date/isAfter';
import translate from 'Utilities/String/translate';
import styles from './SelectAlbumRow.css';
-function getTrackCountKind(monitored, trackFileCount, trackCount) {
+function getTrackCountKind(monitored, releaseDate, trackFileCount, trackCount) {
if (trackFileCount === trackCount && trackCount > 0) {
return kinds.SUCCESS;
}
+ if (!releaseDate || isAfter(releaseDate)) {
+ return kinds.DISABLED;
+ }
+
if (!monitored) {
return kinds.WARNING;
}
@@ -99,7 +104,7 @@ class SelectAlbumRow extends Component {
>
{
diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js
index 627263fff..5a12e6e72 100644
--- a/frontend/src/Settings/MediaManagement/MediaManagement.js
+++ b/frontend/src/Settings/MediaManagement/MediaManagement.js
@@ -124,29 +124,29 @@ class MediaManagement extends Component {
{
- isFetching &&
+ isFetching ?
-
+ : null
}
{
- !isFetching && error &&
+ !isFetching && error ?
{translate('UnableToLoadMediaManagementSettings')}
-
+ : null
}
{
- hasSettings && !isFetching && !error &&
+ hasSettings && !isFetching && !error ?
+ : null
}
diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs
index 8a19c6175..c1be6cbef 100644
--- a/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs
+++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigController.cs
@@ -32,6 +32,7 @@ public MediaManagementConfigController(IConfigService configService,
.When(c => !string.IsNullOrWhiteSpace(c.RecycleBin));
SharedValidator.RuleFor(c => c.RecycleBinCleanupDays).GreaterThanOrEqualTo(0);
SharedValidator.RuleFor(c => c.ChmodFolder).SetValidator(folderChmodValidator).When(c => !string.IsNullOrEmpty(c.ChmodFolder) && (OsInfo.IsLinux || OsInfo.IsOsx));
+ SharedValidator.RuleFor(c => c.ScriptImportPath).IsValidPath().When(c => c.UseScriptImport);
SharedValidator.RuleFor(c => c.MinimumFreeSpaceWhenImporting).GreaterThanOrEqualTo(100);
}
diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs
index e318c3c82..7f6e3030c 100644
--- a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs
+++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs
@@ -25,6 +25,9 @@ public class MediaManagementConfigResource : RestResource
public bool SkipFreeSpaceCheckWhenImporting { get; set; }
public int MinimumFreeSpaceWhenImporting { get; set; }
public bool CopyUsingHardlinks { get; set; }
+ public bool EnableMediaInfo { get; set; }
+ public bool UseScriptImport { get; set; }
+ public string ScriptImportPath { get; set; }
public bool ImportExtraFiles { get; set; }
public string ExtraFileExtensions { get; set; }
}
@@ -53,6 +56,9 @@ public static MediaManagementConfigResource ToResource(IConfigService model)
SkipFreeSpaceCheckWhenImporting = model.SkipFreeSpaceCheckWhenImporting,
MinimumFreeSpaceWhenImporting = model.MinimumFreeSpaceWhenImporting,
CopyUsingHardlinks = model.CopyUsingHardlinks,
+ EnableMediaInfo = model.EnableMediaInfo,
+ UseScriptImport = model.UseScriptImport,
+ ScriptImportPath = model.ScriptImportPath,
ImportExtraFiles = model.ImportExtraFiles,
ExtraFileExtensions = model.ExtraFileExtensions,
};
diff --git a/src/Lidarr.Api.V1/openapi.json b/src/Lidarr.Api.V1/openapi.json
index 4c0462717..9ff57628c 100644
--- a/src/Lidarr.Api.V1/openapi.json
+++ b/src/Lidarr.Api.V1/openapi.json
@@ -8914,7 +8914,8 @@
"ArtistStatusType": {
"enum": [
"continuing",
- "ended"
+ "ended",
+ "deleted"
],
"type": "string"
},
@@ -10870,6 +10871,16 @@
"copyUsingHardlinks": {
"type": "boolean"
},
+ "enableMediaInfo": {
+ "type": "boolean"
+ },
+ "useScriptImport": {
+ "type": "boolean"
+ },
+ "scriptImportPath": {
+ "type": "string",
+ "nullable": true
+ },
"importExtraFiles": {
"type": "boolean"
},
diff --git a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs
index c33211019..36c3b1ec0 100644
--- a/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs
+++ b/src/NzbDrone.Common/Instrumentation/NzbDroneLogger.cs
@@ -201,6 +201,7 @@ private static void RegisterGlobalFilters()
c.ForLogger("Microsoft.*").WriteToNil(LogLevel.Warn);
c.ForLogger("Microsoft.Hosting.Lifetime*").WriteToNil(LogLevel.Info);
c.ForLogger("Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware").WriteToNil(LogLevel.Fatal);
+ c.ForLogger("Lidarr.Http.Authentication.ApiKeyAuthenticationHandler").WriteToNil(LogLevel.Info);
});
}
diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportScriptServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportScriptServiceFixture.cs
new file mode 100644
index 000000000..d769d1396
--- /dev/null
+++ b/src/NzbDrone.Core.Test/MediaFiles/ImportScriptServiceFixture.cs
@@ -0,0 +1,424 @@
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using FizzWare.NBuilder;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.Processes;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.MediaFiles;
+using NzbDrone.Core.Music;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Qualities;
+using NzbDrone.Core.Tags;
+using NzbDrone.Core.Test.Framework;
+
+namespace NzbDrone.Core.Test.MediaFiles
+{
+ [TestFixture]
+ public class ImportScriptServiceFixture : CoreTest
+ {
+ private LocalTrack _localTrack;
+ private TrackFile _trackFile;
+ private Artist _artist;
+ private Album _album;
+ private List _tracks;
+ private Tag _tag;
+
+ [SetUp]
+ public void Setup()
+ {
+ _tag = Builder.CreateNew()
+ .With(t => t.Id = 1)
+ .With(t => t.Label = "TestTag")
+ .Build();
+
+ _artist = Builder.CreateNew()
+ .With(a => a.Id = 1)
+ .With(a => a.Name = "Test Artist")
+ .With(a => a.Path = "/music/Test Artist")
+ .With(a => a.ForeignArtistId = "test-artist-mbid")
+ .With(a => a.Tags = new HashSet { 1 })
+ .Build();
+
+ _album = Builder.CreateNew()
+ .With(a => a.Id = 1)
+ .With(a => a.Title = "Test Album")
+ .With(a => a.ForeignAlbumId = "test-album-mbid")
+ .With(a => a.ReleaseDate = new System.DateTime(2023, 1, 1))
+ .With(a => a.Genres = new List { "Rock", "Alternative" })
+ .Build();
+
+ _tracks = new List
+ {
+ Builder.CreateNew()
+ .With(t => t.Id = 1)
+ .With(t => t.TrackNumber = "1")
+ .With(t => t.Title = "Test Track 1")
+ .Build(),
+ Builder.CreateNew()
+ .With(t => t.Id = 2)
+ .With(t => t.TrackNumber = "2")
+ .With(t => t.Title = "Test Track 2")
+ .Build()
+ };
+
+ var mediaInfo = Builder.CreateNew()
+ .With(m => m.AudioChannels = 2)
+ .With(m => m.AudioFormat = "FLAC")
+ .With(m => m.AudioBitrate = 1000)
+ .With(m => m.AudioSampleRate = 44100)
+ .With(m => m.AudioBits = 16)
+ .Build();
+
+ var fileTrackInfo = Builder.CreateNew()
+ .With(p => p.MediaInfo = mediaInfo)
+ .Build();
+
+ _localTrack = Builder.CreateNew()
+ .With(l => l.Artist = _artist)
+ .With(l => l.Album = _album)
+ .With(l => l.Tracks = _tracks)
+ .With(l => l.Quality = new QualityModel(Quality.FLAC))
+ .With(l => l.ReleaseGroup = "TestGroup")
+ .With(l => l.SceneName = "Test.Scene.Name")
+ .With(l => l.FileTrackInfo = fileTrackInfo)
+ .Build();
+
+ _trackFile = Builder.CreateNew()
+ .With(t => t.Path = "/destination/path/track.flac")
+ .Build();
+
+ Mocker.GetMock()
+ .Setup(s => s.UseScriptImport)
+ .Returns(true);
+
+ Mocker.GetMock()
+ .Setup(s => s.ScriptImportPath)
+ .Returns("/usr/local/bin/import_script.sh");
+
+ Mocker.GetMock()
+ .Setup(s => s.ApplicationUrl)
+ .Returns("http://localhost:8686");
+
+ Mocker.GetMock()
+ .Setup(s => s.InstanceName)
+ .Returns("Lidarr");
+
+ Mocker.GetMock()
+ .Setup(s => s.Get(1))
+ .Returns(_tag);
+
+ var customFormats = Builder.CreateListOfSize(2)
+ .TheFirst(1)
+ .With(f => f.Name = "Lossless")
+ .TheNext(1)
+ .With(f => f.Name = "Scene")
+ .Build().ToList();
+
+ Mocker.GetMock()
+ .Setup(s => s.ParseCustomFormat(_localTrack))
+ .Returns(customFormats);
+ }
+
+ [Test]
+ public void should_return_defer_when_script_import_disabled()
+ {
+ // Given
+ Mocker.GetMock()
+ .Setup(s => s.UseScriptImport)
+ .Returns(false);
+
+ // When
+ var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ result.Should().Be(ScriptImportDecision.DeferMove);
+ Mocker.GetMock()
+ .Verify(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public void should_call_script_with_correct_arguments()
+ {
+ // Given
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 0,
+ Lines = new List { new ProcessOutputLine(ProcessOutputLevel.Standard, "Script executed successfully") }
+ };
+
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(processOutput);
+
+ // When
+ var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ Mocker.GetMock()
+ .Verify(p => p.StartAndCapture(
+ "/usr/local/bin/import_script.sh",
+ "\"/source/path\" \"/dest/path\"",
+ It.IsAny()),
+ Times.Once);
+
+ result.Should().Be(ScriptImportDecision.MoveComplete);
+ }
+
+ [Test]
+ public void should_pass_correct_environment_variables()
+ {
+ // Given
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 3,
+ Lines = new List()
+ };
+
+ StringDictionary capturedEnv = null;
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((script, args, env) => capturedEnv = env)
+ .Returns(processOutput);
+
+ // When
+ Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Copy);
+
+ // Then
+ capturedEnv.Should().NotBeNull();
+
+ // Basic paths and instance info
+ capturedEnv["Lidarr_SourcePath"].Should().Be("/source/path");
+ capturedEnv["Lidarr_DestinationPath"].Should().Be("/dest/path");
+ capturedEnv["Lidarr_InstanceName"].Should().Be("Lidarr");
+ capturedEnv["Lidarr_ApplicationUrl"].Should().Be("http://localhost:8686");
+ capturedEnv["Lidarr_TransferMode"].Should().Be("Copy");
+
+ // Artist info
+ capturedEnv["Lidarr_Artist_Id"].Should().Be("1");
+ capturedEnv["Lidarr_Artist_Name"].Should().Be("Test Artist");
+ capturedEnv["Lidarr_Artist_Path"].Should().Be("/music/Test Artist");
+ capturedEnv["Lidarr_Artist_MBId"].Should().Be("test-artist-mbid");
+ capturedEnv["Lidarr_Artist_Tags"].Should().Be("TestTag");
+
+ // Album info
+ capturedEnv["Lidarr_Album_Id"].Should().Be("1");
+ capturedEnv["Lidarr_Album_Title"].Should().Be("Test Album");
+ capturedEnv["Lidarr_Album_MBId"].Should().Be("test-album-mbid");
+ capturedEnv["Lidarr_Album_ReleaseDate"].Should().Be("2023-01-01");
+ capturedEnv["Lidarr_Album_Genres"].Should().Be("Rock|Alternative");
+
+ // Track info
+ capturedEnv["Lidarr_TrackFile_TrackCount"].Should().Be("2");
+ capturedEnv["Lidarr_TrackFile_TrackIds"].Should().Be("1,2");
+ capturedEnv["Lidarr_TrackFile_TrackNumbers"].Should().Be("1,2");
+ capturedEnv["Lidarr_TrackFile_TrackTitles"].Should().Be("Test Track 1|Test Track 2");
+ capturedEnv["Lidarr_TrackFile_Quality"].Should().Be("FLAC");
+ capturedEnv["Lidarr_TrackFile_ReleaseGroup"].Should().Be("TestGroup");
+ capturedEnv["Lidarr_TrackFile_SceneName"].Should().Be("Test.Scene.Name");
+
+ // Media info
+ capturedEnv["Lidarr_TrackFile_MediaInfo_AudioChannels"].Should().Be("2");
+ capturedEnv["Lidarr_TrackFile_MediaInfo_AudioCodec"].Should().Be("FLAC");
+ capturedEnv["Lidarr_TrackFile_MediaInfo_AudioBitRate"].Should().Be("1000");
+ capturedEnv["Lidarr_TrackFile_MediaInfo_AudioSampleRate"].Should().Be("44100");
+ capturedEnv["Lidarr_TrackFile_MediaInfo_BitsPerSample"].Should().Be("16");
+
+ // Custom formats
+ capturedEnv["Lidarr_TrackFile_CustomFormat"].Should().Be("Lossless|Scene");
+
+ // Download client info (should be empty when not provided)
+ capturedEnv["Lidarr_Download_Client"].Should().Be("");
+ capturedEnv["Lidarr_Download_Client_Type"].Should().Be("");
+ capturedEnv["Lidarr_Download_Id"].Should().Be("");
+ }
+
+ [Test]
+ public void should_include_download_client_info_when_provided()
+ {
+ // Given
+ var downloadClientInfo = Builder.CreateNew()
+ .With(d => d.Name = "qBittorrent")
+ .With(d => d.Type = "Torrent")
+ .Build();
+
+ var downloadClientItem = Builder.CreateNew()
+ .With(d => d.DownloadClientInfo = downloadClientInfo)
+ .With(d => d.DownloadId = "test-download-id")
+ .Build();
+
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 3,
+ Lines = new List()
+ };
+
+ StringDictionary capturedEnv = null;
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((script, args, env) => capturedEnv = env)
+ .Returns(processOutput);
+
+ // When
+ Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move, downloadClientItem);
+
+ // Then
+ capturedEnv["Lidarr_Download_Client"].Should().Be("qBittorrent");
+ capturedEnv["Lidarr_Download_Client_Type"].Should().Be("Torrent");
+ capturedEnv["Lidarr_Download_Id"].Should().Be("test-download-id");
+ }
+
+ [Test]
+ public void should_return_move_complete_when_script_returns_0()
+ {
+ // Given
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 0,
+ Lines = new List()
+ };
+
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(processOutput);
+
+ // When
+ var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ result.Should().Be(ScriptImportDecision.MoveComplete);
+ }
+
+ [Test]
+ public void should_return_rename_requested_when_script_returns_2()
+ {
+ // Given
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 2,
+ Lines = new List()
+ };
+
+ var audioTag = Builder.CreateNew()
+ .With(a => a.MediaInfo = new MediaInfoModel())
+ .Build();
+
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(processOutput);
+
+ Mocker.GetMock()
+ .Setup(s => s.ReadTags("/dest/path"))
+ .Returns(audioTag);
+
+ // When
+ var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ result.Should().Be(ScriptImportDecision.RenameRequested);
+ _trackFile.MediaInfo.Should().Be(audioTag.MediaInfo);
+ _trackFile.Path.Should().BeNull();
+
+ Mocker.GetMock()
+ .Verify(s => s.ReadTags("/dest/path"), Times.Once);
+ }
+
+ [Test]
+ public void should_return_defer_move_when_script_returns_3()
+ {
+ // Given
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 3,
+ Lines = new List()
+ };
+
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(processOutput);
+
+ // When
+ var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ result.Should().Be(ScriptImportDecision.DeferMove);
+ }
+
+ [Test]
+ public void should_throw_exception_when_script_returns_error_code()
+ {
+ // Given
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 1,
+ Lines = new List { new ProcessOutputLine(ProcessOutputLevel.Error, "Error message from script") }
+ };
+
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(processOutput);
+
+ // When & Then
+ Assert.Throws(() =>
+ Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move));
+ }
+
+ [Test]
+ public void should_handle_missing_media_info_gracefully()
+ {
+ // Given
+ _localTrack.FileTrackInfo.MediaInfo = null;
+
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 3,
+ Lines = new List()
+ };
+
+ StringDictionary capturedEnv = null;
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((script, args, env) => capturedEnv = env)
+ .Returns(processOutput);
+
+ // When
+ Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ capturedEnv.Should().NotBeNull();
+ capturedEnv.ContainsKey("Lidarr_TrackFile_MediaInfo_AudioChannels").Should().BeFalse();
+ capturedEnv.ContainsKey("Lidarr_TrackFile_MediaInfo_AudioCodec").Should().BeFalse();
+ }
+
+ [Test]
+ public void should_handle_missing_file_track_info_gracefully()
+ {
+ // Given
+ _localTrack.FileTrackInfo = null;
+
+ var processOutput = new ProcessOutput
+ {
+ ExitCode = 3,
+ Lines = new List()
+ };
+
+ StringDictionary capturedEnv = null;
+ Mocker.GetMock()
+ .Setup(p => p.StartAndCapture(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Callback((script, args, env) => capturedEnv = env)
+ .Returns(processOutput);
+
+ // When
+ var result = Subject.TryImport("/source/path", "/dest/path", _localTrack, _trackFile, TransferMode.Move);
+
+ // Then
+ result.Should().Be(ScriptImportDecision.DeferMove);
+ capturedEnv.ContainsKey("Lidarr_TrackFile_MediaInfo_AudioChannels").Should().BeFalse();
+ }
+ }
+}
diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs
index 8c0851f6d..c263a7e5a 100644
--- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs
+++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs
@@ -163,5 +163,29 @@ public void test_raw_distance()
dist.RawDistance().Should().Be(2.25);
}
+
+ [Test]
+ public void test_add_string_null_handling()
+ {
+ var dist = new Distance();
+
+ dist.AddString("string", null, "target");
+ dist.Penalties.Should().BeEquivalentTo(new Dictionary> { { "string", new List { 1.0 } } });
+
+ dist.AddString("string2", "value", null);
+ dist.Penalties.Should().BeEquivalentTo(new Dictionary>
+ {
+ { "string", new List { 1.0 } },
+ { "string2", new List { 1.0 } }
+ });
+
+ dist.AddString("string3", null, null);
+ dist.Penalties.Should().BeEquivalentTo(new Dictionary>
+ {
+ { "string", new List { 1.0 } },
+ { "string2", new List { 1.0 } },
+ { "string3", new List { 0.0 } }
+ });
+ }
}
}
diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs
index 5f26407d6..c0cf62d3e 100644
--- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs
+++ b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxyFixture.cs
@@ -14,7 +14,7 @@
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class SkyHookProxyFixture : CoreTest
{
private MetadataProfile _metadataProfile;
diff --git a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs
index 3fc41f858..6c79d6340 100644
--- a/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs
+++ b/src/NzbDrone.Core.Test/MetadataSource/SkyHook/SkyHookProxySearchFixture.cs
@@ -12,7 +12,7 @@
namespace NzbDrone.Core.Test.MetadataSource.SkyHook
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class SkyHookProxySearchFixture : CoreTest
{
[SetUp]
diff --git a/src/NzbDrone.Core.Test/MusicTests/MonitorNewAlbumServiceFixture.cs b/src/NzbDrone.Core.Test/MusicTests/MonitorNewAlbumServiceFixture.cs
index e0cee525b..4dd23a8a3 100644
--- a/src/NzbDrone.Core.Test/MusicTests/MonitorNewAlbumServiceFixture.cs
+++ b/src/NzbDrone.Core.Test/MusicTests/MonitorNewAlbumServiceFixture.cs
@@ -21,14 +21,17 @@ public void Setup()
.All()
.With(e => e.Monitored = true)
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-7))
+ .With(e => e.Title = "Test Album")
// Future
.TheFirst(1)
.With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(7))
+ .With(e => e.Title = "Future Album")
// Future/TBA
.TheNext(1)
.With(e => e.ReleaseDate = null)
+ .With(e => e.Title = "TBA Album")
.Build()
.ToList();
}
@@ -61,5 +64,148 @@ public void should_only_monitor_new_with_new()
Subject.ShouldMonitorNewAlbum(album, _albums, NewItemMonitorTypes.New).Should().BeFalse();
}
}
+
+ [Test]
+ public void should_not_monitor_album_with_null_release_date()
+ {
+ var albumWithNullDate = Builder.CreateNew()
+ .With(e => e.ReleaseDate = null)
+ .With(e => e.Title = "No Date Album")
+ .Build();
+
+ var existingAlbums = Builder.CreateListOfSize(2)
+ .All()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(albumWithNullDate, existingAlbums, NewItemMonitorTypes.New)
+ .Should().BeFalse();
+ }
+
+ [Test]
+ public void should_monitor_album_when_no_existing_albums_have_dates()
+ {
+ var newAlbumWithDate = Builder.CreateNew()
+ .With(e => e.ReleaseDate = DateTime.UtcNow)
+ .With(e => e.Title = "New Album With Date")
+ .Build();
+
+ var existingAlbumsWithoutDates = Builder.CreateListOfSize(3)
+ .All()
+ .With(e => e.ReleaseDate = null)
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(newAlbumWithDate, existingAlbumsWithoutDates, NewItemMonitorTypes.New)
+ .Should().BeTrue();
+ }
+
+ [Test]
+ public void should_monitor_album_newer_than_existing_albums()
+ {
+ var newerAlbum = Builder.CreateNew()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(1))
+ .With(e => e.Title = "Newer Album")
+ .Build();
+
+ var existingAlbums = Builder.CreateListOfSize(3)
+ .All()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
+ .TheFirst(1)
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-1)) // Most recent existing
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(newerAlbum, existingAlbums, NewItemMonitorTypes.New)
+ .Should().BeTrue();
+ }
+
+ [Test]
+ public void should_not_monitor_album_older_than_existing_albums()
+ {
+ var olderAlbum = Builder.CreateNew()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-10))
+ .With(e => e.Title = "Older Album")
+ .Build();
+
+ var existingAlbums = Builder.CreateListOfSize(3)
+ .All()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
+ .TheFirst(1)
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-1)) // Most recent existing
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(olderAlbum, existingAlbums, NewItemMonitorTypes.New)
+ .Should().BeFalse();
+ }
+
+ [Test]
+ public void should_monitor_album_with_same_date_as_existing_album()
+ {
+ var sameDate = DateTime.UtcNow.AddDays(-5);
+ var albumWithSameDate = Builder.CreateNew()
+ .With(e => e.ReleaseDate = sameDate)
+ .With(e => e.Title = "Same Date Album")
+ .Build();
+
+ var existingAlbums = Builder.CreateListOfSize(3)
+ .All()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-30))
+ .TheFirst(1)
+ .With(e => e.ReleaseDate = sameDate) // Same date as new album
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(albumWithSameDate, existingAlbums, NewItemMonitorTypes.New)
+ .Should().BeTrue();
+ }
+
+ [Test]
+ public void should_ignore_existing_albums_with_null_dates_when_finding_newest()
+ {
+ var newAlbum = Builder.CreateNew()
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(1))
+ .With(e => e.Title = "New Album")
+ .Build();
+
+ var existingAlbums = Builder.CreateListOfSize(4)
+ .All()
+ .With(e => e.ReleaseDate = null) // All null dates
+ .TheFirst(1)
+ .With(e => e.ReleaseDate = DateTime.UtcNow.AddDays(-5)) // Only one with actual date
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(newAlbum, existingAlbums, NewItemMonitorTypes.New)
+ .Should().BeTrue();
+ }
+
+ [Test]
+ public void should_throw_for_unknown_monitor_type()
+ {
+ var album = _albums.First();
+ Assert.Throws(() =>
+ Subject.ShouldMonitorNewAlbum(album, _albums, (NewItemMonitorTypes)999));
+ }
+
+ [Test]
+ public void should_monitor_album_with_null_date_when_all_existing_albums_also_have_null_dates()
+ {
+ var albumWithNullDate = Builder.CreateNew()
+ .With(e => e.ReleaseDate = null)
+ .With(e => e.Title = "No Date Album")
+ .Build();
+
+ var existingAlbumsWithoutDates = Builder.CreateListOfSize(3)
+ .All()
+ .With(e => e.ReleaseDate = null)
+ .Build()
+ .ToList();
+
+ Subject.ShouldMonitorNewAlbum(albumWithNullDate, existingAlbumsWithoutDates, NewItemMonitorTypes.New)
+ .Should().BeTrue();
+ }
}
}
diff --git a/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs
index 33ca92cc7..91a27af3e 100644
--- a/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs
+++ b/src/NzbDrone.Core.Test/MusicTests/ShouldRefreshArtistFixture.cs
@@ -34,6 +34,11 @@ private void GivenArtistIsEnded()
_artist.Metadata.Value.Status = ArtistStatusType.Ended;
}
+ private void GivenArtistIsDeleted()
+ {
+ _artist.Metadata.Value.Status = ArtistStatusType.Deleted;
+ }
+
private void GivenArtistLastRefreshedMonthsAgo()
{
_artist.LastInfoSync = DateTime.UtcNow.AddDays(-90);
@@ -113,7 +118,7 @@ public void should_return_true_if_album_released_in_last_30_days()
}
[Test]
- public void should_return_false_when_recently_refreshed_ended_show_has_not_aired_for_30_days()
+ public void should_return_false_when_recently_refreshed_ended_artist_has_not_released_for_30_days()
{
GivenArtistIsEnded();
GivenArtistLastRefreshedYesterday();
@@ -122,7 +127,7 @@ public void should_return_false_when_recently_refreshed_ended_show_has_not_aired
}
[Test]
- public void should_return_false_when_recently_refreshed_ended_show_aired_in_last_30_days()
+ public void should_return_false_when_recently_refreshed_ended_artist_released_in_last_30_days()
{
GivenArtistIsEnded();
GivenArtistLastRefreshedRecently();
@@ -131,5 +136,14 @@ public void should_return_false_when_recently_refreshed_ended_show_aired_in_last
Subject.ShouldRefresh(_artist).Should().BeFalse();
}
+
+ [Test]
+ public void should_return_true_if_deleted_artist_last_refreshed_more_than_2_days_ago()
+ {
+ GivenArtistLastRefreshedThreeDaysAgo();
+ GivenArtistIsDeleted();
+
+ Subject.ShouldRefresh(_artist).Should().BeTrue();
+ }
}
}
diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs
index 34b94c927..6983f1b54 100644
--- a/src/NzbDrone.Core/Configuration/ConfigService.cs
+++ b/src/NzbDrone.Core/Configuration/ConfigService.cs
@@ -207,6 +207,27 @@ public bool CopyUsingHardlinks
set { SetValue("CopyUsingHardlinks", value); }
}
+ public bool EnableMediaInfo
+ {
+ get { return GetValueBoolean("EnableMediaInfo", true); }
+
+ set { SetValue("EnableMediaInfo", value); }
+ }
+
+ public bool UseScriptImport
+ {
+ get { return GetValueBoolean("UseScriptImport", false); }
+
+ set { SetValue("UseScriptImport", value); }
+ }
+
+ public string ScriptImportPath
+ {
+ get { return GetValue("ScriptImportPath"); }
+
+ set { SetValue("ScriptImportPath", value); }
+ }
+
public bool ImportExtraFiles
{
get { return GetValueBoolean("ImportExtraFiles", false); }
diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs
index 1665334d4..c78f060ae 100644
--- a/src/NzbDrone.Core/Configuration/IConfigService.cs
+++ b/src/NzbDrone.Core/Configuration/IConfigService.cs
@@ -33,6 +33,9 @@ public interface IConfigService
bool SkipFreeSpaceCheckWhenImporting { get; set; }
int MinimumFreeSpaceWhenImporting { get; set; }
bool CopyUsingHardlinks { get; set; }
+ bool EnableMediaInfo { get; set; }
+ bool UseScriptImport { get; set; }
+ string ScriptImportPath { get; set; }
bool ImportExtraFiles { get; set; }
string ExtraFileExtensions { get; set; }
bool WatchLibraryForChanges { get; set; }
diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/CustomFormatAllowedByProfileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/CustomFormatAllowedByProfileSpecification.cs
index 129c8c022..bf50dddeb 100644
--- a/src/NzbDrone.Core/DecisionEngine/Specifications/CustomFormatAllowedByProfileSpecification.cs
+++ b/src/NzbDrone.Core/DecisionEngine/Specifications/CustomFormatAllowedByProfileSpecification.cs
@@ -1,3 +1,4 @@
+using NLog;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Parser.Model;
@@ -6,9 +7,15 @@ namespace NzbDrone.Core.DecisionEngine.Specifications
{
public class CustomFormatAllowedbyProfileSpecification : IDecisionEngineSpecification
{
+ private readonly Logger _logger;
public SpecificationPriority Priority => SpecificationPriority.Default;
public RejectionType Type => RejectionType.Permanent;
+ public CustomFormatAllowedbyProfileSpecification(Logger logger)
+ {
+ _logger = logger;
+ }
+
public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase searchCriteria)
{
var minScore = subject.Artist.QualityProfile.Value.MinFormatScore;
@@ -19,6 +26,8 @@ public virtual Decision IsSatisfiedBy(RemoteAlbum subject, SearchCriteriaBase se
return Decision.Reject("Custom Formats {0} have score {1} below Artist profile minimum {2}", subject.CustomFormats.ConcatToString(), score, minScore);
}
+ _logger.Trace("Custom Format Score of {0} [{1}] above Artist profile minimum {2}", score, subject.CustomFormats.ConcatToString(), minScore);
+
return Decision.Accept();
}
}
diff --git a/src/NzbDrone.Core/HealthCheck/Checks/RemovedArtistCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/RemovedArtistCheck.cs
new file mode 100644
index 000000000..2654b1d8c
--- /dev/null
+++ b/src/NzbDrone.Core/HealthCheck/Checks/RemovedArtistCheck.cs
@@ -0,0 +1,53 @@
+using System.Linq;
+using NLog;
+using NzbDrone.Common.Extensions;
+using NzbDrone.Core.Localization;
+using NzbDrone.Core.Music;
+using NzbDrone.Core.Music.Events;
+
+namespace NzbDrone.Core.HealthCheck.Checks
+{
+ [CheckOn(typeof(ArtistUpdatedEvent))]
+ [CheckOn(typeof(ArtistsDeletedEvent), CheckOnCondition.FailedOnly)]
+ public class RemovedArtistCheck : HealthCheckBase, ICheckOnCondition, ICheckOnCondition
+ {
+ private readonly IArtistService _artistService;
+ private readonly Logger _logger;
+
+ public RemovedArtistCheck(ILocalizationService localizationService, IArtistService artistService, Logger logger)
+ : base(localizationService)
+ {
+ _artistService = artistService;
+ _logger = logger;
+ }
+
+ public override HealthCheck Check()
+ {
+ var deletedArtists = _artistService.GetAllArtists().Where(v => v.Metadata.Value.Status == ArtistStatusType.Deleted).ToList();
+
+ if (deletedArtists.Empty())
+ {
+ return new HealthCheck(GetType());
+ }
+
+ var artistText = deletedArtists.Select(s => $"{s.Name} (mbid {s.ForeignArtistId})").Join(", ");
+
+ if (deletedArtists.Count == 1)
+ {
+ return new HealthCheck(GetType(), HealthCheckResult.Error, $"Artist {artistText} was removed from MusicBrainz");
+ }
+
+ return new HealthCheck(GetType(), HealthCheckResult.Error, $"Artists {artistText} were removed from MusicBrainz");
+ }
+
+ public bool ShouldCheckOnEvent(ArtistsDeletedEvent deletedEvent)
+ {
+ return deletedEvent.Artists.Any(artist => artist.Metadata.Value.Status == ArtistStatusType.Deleted);
+ }
+
+ public bool ShouldCheckOnEvent(ArtistUpdatedEvent updatedEvent)
+ {
+ return updatedEvent.Artist.Metadata.Value.Status == ArtistStatusType.Deleted;
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs
index a9a89dc48..452286f47 100644
--- a/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs
+++ b/src/NzbDrone.Core/HealthCheck/HealthCheckService.cs
@@ -136,7 +136,7 @@ private void ProcessHealthChecks()
public void Execute(CheckHealthCommand message)
{
- var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks;
+ var healthChecks = message.Trigger == CommandTrigger.Manual ? _healthChecks : _scheduledHealthChecks;
lock (_pendingHealthChecks)
{
diff --git a/src/NzbDrone.Core/Lidarr.Core.csproj b/src/NzbDrone.Core/Lidarr.Core.csproj
index 2b0e4583a..a598b379f 100644
--- a/src/NzbDrone.Core/Lidarr.Core.csproj
+++ b/src/NzbDrone.Core/Lidarr.Core.csproj
@@ -11,7 +11,7 @@
-
+
diff --git a/src/NzbDrone.Core/Localization/Core/cs.json b/src/NzbDrone.Core/Localization/Core/cs.json
index 50f0b31a8..c58d40eb9 100644
--- a/src/NzbDrone.Core/Localization/Core/cs.json
+++ b/src/NzbDrone.Core/Localization/Core/cs.json
@@ -18,7 +18,7 @@
"Calendar": "Kalendář",
"CalendarWeekColumnHeaderHelpText": "Zobrazuje se nad každým sloupcem, když je aktivní zobrazení týden",
"Cancel": "Zrušit",
- "CancelPendingTask": "Opravdu chcete zrušit tento úkol čekající na vyřízení?",
+ "CancelPendingTask": "Opravdu chcete zrušit tento čekající úkol?",
"CertificateValidation": "Ověřování certifikátu",
"CertificateValidationHelpText": "Změňte přísnost ověřování certifikátů HTTPS. Neměňte, pokud nerozumíte rizikům.",
"ChangeFileDate": "Změnit datum souboru",
@@ -284,7 +284,7 @@
"Unmonitored": "Nemonitorováno",
"UnmonitoredHelpText": "Zahrnout nemonitorované filmy do zdroje iCal",
"UpdateAutomaticallyHelpText": "Automaticky stahovat a instalovat aktualizace. Stále budete moci instalovat ze systému: Aktualizace",
- "UpdateMechanismHelpText": "Použijte vestavěný aktualizátor {appName} nebo skript",
+ "UpdateMechanismHelpText": "Použijte vestavěný nástroj {appName}u pro aktualizaci nebo skript",
"Updates": "Aktualizace",
"UpdateScriptPathHelpText": "Cesta k vlastnímu skriptu, který přebírá extrahovaný balíček aktualizace a zpracovává zbytek procesu aktualizace",
"UpgradeAllowedHelpText": "Pokud budou deaktivovány vlastnosti, nebudou upgradovány",
@@ -476,7 +476,7 @@
"Custom": "Vlastní",
"CustomFilters": "Vlastní filtry",
"Date": "Datum",
- "DoNotPrefer": "Nepřednostňovat",
+ "DoNotPrefer": "Neupřednostňovat",
"DoNotUpgradeAutomatically": "Neupgradovat automaticky",
"DownloadFailed": "Stažení se nezdařilo",
"EditDelayProfile": "Upravit profil zpoždění",
@@ -529,7 +529,7 @@
"Apply": "Použít",
"AudioInfo": "Audio informace",
"Deleted": "Smazáno",
- "Details": "Detaily",
+ "Details": "Podrobnosti",
"Donations": "Dary",
"ErrorRestoringBackup": "Chyba při obnovování zálohy",
"Filters": "Filtr",
@@ -557,14 +557,14 @@
"CloneCustomFormat": "Klonovat vlastní formát",
"Conditions": "Podmínky",
"CopyToClipboard": "Zkopírovat do schránky",
- "CouldntFindAnyResultsForTerm": "Nelze najít žádné výsledky pro dotaz „{0}“",
+ "CouldntFindAnyResultsForTerm": "Nelze najít žádné výsledky pro „{0}“",
"CustomFormat": "Vlastní formát",
"CustomFormatRequiredHelpText": "Tato {0} podmínka musí odpovídat, aby se aplikoval vlastní formát. Jinak stačí jedna shoda {0}.",
- "CustomFormatSettings": "Nastavení vlastních formátů",
+ "CustomFormatSettings": "Nastavení vlastního formátu",
"CustomFormats": "Vlastní formáty",
"Customformat": "Vlastní formát",
"CutoffFormatScoreHelpText": "Jakmile je dosaženo tohoto skóre vlastního formátu, {appName} již nebude stahovat filmy",
- "DeleteCustomFormat": "Odstranit vlastní formát",
+ "DeleteCustomFormat": "Smazat vlastní formát",
"DownloadPropersAndRepacksHelpTextWarning": "Použijte automatické formáty pro automatické upgrady na Propers / Repacks",
"DownloadedUnableToImportCheckLogsForDetails": "Staženo - Nelze importovat: zkontrolujte podrobnosti v protokolech",
"ExportCustomFormat": "Exportovat vlastní formát",
@@ -590,8 +590,8 @@
"ColonReplacement": "Nahrazení dvojtečky",
"Disabled": "Zakázáno",
"DownloadClientRootFolderHealthCheckMessage": "Stahovací klient {downloadClientName} umístí stažené soubory do kořenové složky {rootFolderPath}. Neměli byste stahovat do kořenové složky.",
- "DownloadClientCheckNoneAvailableMessage": "Není k dispozici žádný klient pro stahování",
- "DownloadClientCheckUnableToCommunicateMessage": "S uživatelem {0} nelze komunikovat.",
+ "DownloadClientCheckNoneAvailableMessage": "Není dostupný žádný klient pro stahování",
+ "DownloadClientCheckUnableToCommunicateMessage": "Nepodařilo se spojit s {0}.",
"DownloadClientStatusCheckSingleClientMessage": "Stahování klientů není k dispozici z důvodu selhání: {0}",
"ImportListStatusCheckAllClientMessage": "Všechny seznamy nejsou k dispozici z důvodu selhání",
"IndexerLongTermStatusCheckAllClientMessage": "Všechny indexery nejsou k dispozici z důvodu selhání po dobu delší než 6 hodin",
@@ -616,11 +616,11 @@
"ImportMechanismHealthCheckMessage": "Povolit zpracování dokončeného stahování",
"IndexerRssHealthCheckNoAvailableIndexers": "Všechny indexery podporující rss jsou dočasně nedostupné kvůli nedávným chybám indexeru",
"IndexerRssHealthCheckNoIndexers": "Nejsou k dispozici žádné indexery se zapnutou synchronizací RSS, {appName} nové verze automaticky nezachytí",
- "IndexerSearchCheckNoInteractiveMessage": "Při povoleném interaktivním vyhledávání, nejsou dostupné žádné indexovací moduly, {appName} neposkytne žádné interaktivní výsledky hledání",
+ "IndexerSearchCheckNoInteractiveMessage": "Nejsou dostupné žádné indexery s povoleným interaktivním vyhledáváním, {appName} nemůže poskytnout žádné výsledky interaktivního hledání",
"IndexerStatusCheckAllClientMessage": "Všechny indexery nejsou k dispozici z důvodu selhání",
"UpdateCheckUINotWritableMessage": "Aktualizaci nelze nainstalovat, protože uživatelská složka „{0}“ není zapisovatelná uživatelem „{1}“.",
- "DeleteRemotePathMapping": "Upravit vzdálené mapování cesty",
- "DeleteRemotePathMappingMessageText": "Opravdu chcete toto vzdálené mapování cesty odstranit?",
+ "DeleteRemotePathMapping": "Smazat mapování vzdálené cesty",
+ "DeleteRemotePathMappingMessageText": "Opravdu chcete smazat toto mapování vzdálené cesty?",
"BlocklistReleases": "Blocklist pro vydání",
"FailedToLoadQueue": "Načtení fronty se nezdařilo",
"QueueIsEmpty": "Fronta je prázdná",
@@ -637,17 +637,17 @@
"ApplyTagsHelpTextAdd": "Přidat: Přidat štítky do existujícího seznamu štítků",
"ApplyTagsHelpTextRemove": "Odebrat: Odebrat zadané štítky",
"ApplyTagsHelpTextReplace": "Nahradit: Nahradit štítky zadanými štítky (prázdné pole vymaže všechny štítky)",
- "DeleteSelectedIndexers": "Odstranit indexer",
+ "DeleteSelectedIndexers": "Smazat indexer(y)",
"NoEventsFound": "Nebyly nalezeny žádné události",
"Yes": "Ano",
"RemoveSelectedItemQueueMessageText": "Opravdu chcete odebrat 1 položku z fronty?",
"RemoveSelectedItemsQueueMessageText": "Opravdu chcete odebrat {0} položek z fronty?",
- "DeleteSelectedDownloadClientsMessageText": "Opravdu chcete smazat {count} vybraných klientů pro stahování?",
+ "DeleteSelectedDownloadClientsMessageText": "Opravdu chcete smazat {count} vybraný(ch) klient(ů) pro stahování?",
"DeleteSelectedIndexersMessageText": "Opravdu chcete smazat {count} vybraný(ch) indexer(ů)?",
"ApplyTagsHelpTextHowToApplyArtists": "Jak použít značky na vybrané umělce",
"ApplyTagsHelpTextHowToApplyImportLists": "Jak použít značky na vybrané seznamy k importu",
"ApplyTagsHelpTextHowToApplyIndexers": "Jak použít štítky na vybrané indexery",
- "DeleteSelectedImportListsMessageText": "Opravdu chcete smazat {count} vybraných seznamů k importu?",
+ "DeleteSelectedImportListsMessageText": "Opravdu chcete smazat {count} vybraný(ch) importní(ch) seznam(ů)?",
"ApplyTagsHelpTextHowToApplyDownloadClients": "Jak použít značky na vybrané klienty pro stahování",
"SuggestTranslationChange": "Navrhnout změnu překladu",
"UpdateSelected": "Aktualizace vybrána",
@@ -696,7 +696,7 @@
"MetadataProfiles": "profil metadat",
"Theme": "Motiv",
"BypassIfAboveCustomFormatScore": "Obejít, pokud je vyšší než skóre vlastního formátu",
- "Discography": "diskografie",
+ "Discography": "Diskografie",
"CountDownloadClientsSelected": "{selectedCount} klientů ke stahování vybráno",
"Season": "Řada",
"Enabled": "Povoleno",
@@ -719,7 +719,7 @@
"Library": "Knihovna",
"CatalogNumber": "Katalogové číslo",
"Album": "Album",
- "DeleteCondition": "Odstranit podmínku",
+ "DeleteCondition": "Smazat podmínku",
"EditMetadataProfile": "profil metadat",
"IndexerDownloadClientHelpText": "Zvolte, který klient pro stahování bude použit pro zachytávání z toho indexeru",
"AlbumReleaseDate": "Datum vydání alba",
@@ -772,7 +772,7 @@
"DownloadClientRemovesCompletedDownloadsHealthCheckMessage": "Klient pro stahování {0} je nastaven, aby odstraňoval dokončené stahování. To může vést k tomu, že stažená data budou z klienta odstraněna dříve, než je {1} bude moci importovat.",
"ImportListRootFolderMissingRootHealthCheckMessage": "Chybí kořenový adresář pro import seznamu: {0}",
"ImportListRootFolderMultipleMissingRootsHealthCheckMessage": "Několik kořenových adresářů chybí pro seznamy importu: {0}",
- "BlocklistReleaseHelpText": "Zabránit {appName}u v opětovném sebrání tohoto vydání",
+ "BlocklistReleaseHelpText": "Zabránit {appName}u v opětovném stažení těchto souborů",
"Overview": "Přehled",
"PosterOptions": "Možnosti plakátu",
"DownloadClientTagHelpText": "Tohoto klienta pro stahování používat pouze pro filmy s alespoň jednou odpovídající značkou. Pro použití se všemi filmy ponechte prázdné pole.",
@@ -780,7 +780,7 @@
"IndexerTagHelpText": "Tohoto klienta pro stahování používat pouze pro filmy s alespoň jednou odpovídající značkou. Pro použití se všemi filmy ponechte prázdné pole.",
"CountArtistsSelected": "{count} vybraných seznamů pro import",
"GrabId": "Chyť ID",
- "DeleteArtistFolderHelpText": "Odstraňte složku filmu a její obsah",
+ "DeleteArtistFolderHelpText": "Smazat složku umělce včetně jejího obsahu",
"Large": "Velký",
"RenameFiles": "Přejmenovat soubory",
"Posters": "Plakáty",
@@ -797,14 +797,14 @@
"ReleaseProfile": "profil vydání",
"AutoTaggingNegateHelpText": "Pokud je zaškrtnuto, pravidlo automatického značkování se nepoužije, pokud odpovídá této podmínce {implementationName}.",
"AutoTaggingRequiredHelpText": "Tato podmínka {implementationName} musí odpovídat, aby se pravidlo automatického označování použilo. V opačném případě postačí jediná shoda s {implementationName}.",
- "CloneAutoTag": "Klonovat automatické značky",
+ "CloneAutoTag": "Klonovat automatické štítky",
"DeleteArtistFolderCountConfirmation": "Opravdu chcete smazat {count} vybraných umělců?",
- "DeleteSpecification": "Smazat oznámení",
- "DeleteSpecificationHelpText": "Opravdu chcete smazat oznámení '{name}'?",
+ "DeleteSpecification": "Smazat specifikaci",
+ "DeleteSpecificationHelpText": "Opravdu chcete smazat specifikaci '{name}'?",
"Small": "Malý",
"BypassIfHighestQualityHelpText": "Obejít zpoždění, když má vydání nejvyšší povolenou kvalitu v profilu kvality s preferovaným protokolem",
"AutoTagging": "Automatické značkování",
- "ConditionUsingRegularExpressions": "Tato podmínka odpovídá regulárním výrazům. Všimněte si, že znaky `\\^$.|?*+()[{` mají speciální význam a je třeba je negovat pomocí `\\`",
+ "ConditionUsingRegularExpressions": "Tato podmínka používá regulární výrazy. Mějte na paměti, že znaky `\\^$.|?*+()[{` mají speciální význam a je třeba je escapovat pomocí `\\`",
"Connection": "Spojení",
"ImportList": "Seznam k importu",
"NoLimitForAnyDuration": "Žádné omezení za běhu",
@@ -814,7 +814,7 @@
"ImportLists": "Seznam k importu",
"ExtraFileExtensionsHelpText": "Seznam extra souborů k importu oddělených čárkami (.nfo bude importován jako .nfo-orig)",
"ExtraFileExtensionsHelpTextsExamples": "Příklady: „.sub, .nfo“ nebo „sub, nfo“",
- "DeleteArtistFoldersHelpText": "Odstraňte složku filmu a její obsah",
+ "DeleteArtistFoldersHelpText": "Smazat složky umělců včetně jejich obsahu",
"RemoveQueueItemConfirmation": "Opravdu chcete odebrat položku „{sourceTitle}“ z fronty?",
"AutoRedownloadFailed": "Opětovné stažení se nezdařilo",
"AutoRedownloadFailedFromInteractiveSearch": "Opětovné stažení z interaktivního vyhledávání se nezdařilo",
@@ -865,15 +865,15 @@
"ChangeCategory": "Změnit kategorii",
"CustomFormatsSettingsTriggerInfo": "Vlastní formát se použije na vydání nebo soubor, pokud odpovídá alespoň jednomu z různých typů zvolených podmínek.",
"ConnectionSettingsUrlBaseHelpText": "Přidá předponu do {connectionName} url, jako např. {url}",
- "BlocklistOnlyHint": "Blokovat a nehledat náhradu",
+ "BlocklistOnlyHint": "Blacklistovat a nehledat náhradu",
"Any": "Jakákoliv",
"BuiltIn": "Vestavěný",
"Script": "Skript",
- "DeleteSelectedCustomFormats": "Odstranění vlastního formátu",
- "DeleteSelectedCustomFormatsMessageText": "Opravdu chcete smazat {count} vybraných seznamů k importu?",
+ "DeleteSelectedCustomFormats": "Smazat vlastní formát(y)",
+ "DeleteSelectedCustomFormatsMessageText": "Opravdu chcete smazat {count} vybraný(ch) vlastní(ch) formát(ů)?",
"IncludeCustomFormatWhenRenaming": "Při přejmenování zahrnout vlastní formát",
"AptUpdater": "Použít apt pro instalaci aktualizace",
- "DockerUpdater": "aktualizujte kontejner dockeru, abyste aktualizaci obdrželi",
+ "DockerUpdater": "Pro získání aktualizace je třeba aktualizovat docker kontejner",
"InstallLatest": "Nainstalujte nejnovější",
"Shutdown": "Vypnout",
"UpdateAppDirectlyLoadError": "{appName} nelze aktualizovat přímo,",
@@ -894,8 +894,8 @@
"AddAlbumWithTitle": "Přidat {albumTitle}",
"DownloadClientDelugeSettingsDirectory": "Adresář stahování",
"ClickToChangeIndexerFlags": "Kliknutím změníte příznaky indexeru",
- "CustomFormatsSpecificationRegularExpression": "Běžný výraz",
- "Donate": "Daruj",
+ "CustomFormatsSpecificationRegularExpression": "Regulární výraz",
+ "Donate": "Darovat",
"Implementation": "Implementace",
"NoCutoffUnmetItems": "Žádné neodpovídající nesplněné položky",
"HealthMessagesInfoBox": "Další informace o příčině těchto zpráv o kontrole zdraví najdete kliknutím na odkaz wiki (ikona knihy) na konci řádku nebo kontrolou [logů]({link}). Pokud máte potíže s interpretací těchto zpráv, můžete se obrátit na naši podporu, a to na níže uvedených odkazech.",
@@ -906,13 +906,13 @@
"AllowFingerprinting": "Povolit digitální otisk (Fingerprinting)",
"BlocklistAndSearchHint": "Začne hledat náhradu po blokaci",
"BlocklistAndSearchMultipleHint": "Začne vyhledávat náhrady po blokaci",
- "BlocklistOnly": "Pouze seznam blokování",
+ "BlocklistOnly": "Pouze blacklistovat",
"ChangeCategoryHint": "Změní stahování do kategorie „Post-Import“ z aplikace Download Client",
"ChangeCategoryMultipleHint": "Změní stahování do kategorie „Post-Import“ z aplikace Download Client",
"CountCustomFormatsSelected": "{count} vybraný vlastní formát(y)",
"DeleteSelected": "Smazat vybrané",
- "DoNotBlocklist": "Nepřidávat do Seznamu blokování",
- "DoNotBlocklistHint": "Odstraň bez přidání do seznamu blokování",
+ "DoNotBlocklist": "Nepřidávat do blacklistu",
+ "DoNotBlocklistHint": "Smazat bez přidání do blacklistu",
"DownloadClientAriaSettingsDirectoryHelpText": "Volitelné umístění pro stahování, pokud chcete použít výchozí umístění Aria2, ponechte prázdné",
"DownloadClientQbittorrentSettingsContentLayout": "Rozvržení obsahu",
"DownloadClientQbittorrentSettingsContentLayoutHelpText": "Zda použít rozvržení obsahu nakonfigurované v qBittorrentu, původní rozvržení z torrentu nebo vždy vytvořit podsložku (qBittorrent 4.3.2+)",
@@ -1226,7 +1226,7 @@
"EnabledHelpText": "Zaškrnutím zapnete profil vydání",
"EndedAllTracksDownloaded": "Skončeno (Všechny skladby staženy)",
"EpisodeDoesNotHaveAnAbsoluteEpisodeNumber": "Díl nemá absolutní číslo",
- "ExpandEPByDefaultHelpText": "EPs",
+ "ExpandEPByDefaultHelpText": "EP",
"ExpandItemsByDefault": "Automaticky rozbalit položky",
"ExistingTagsScrubbed": "Stávající tagy vyčištěny",
"ExpandOtherByDefaultHelpText": "Ostatní",
diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json
index ade1d9d2b..2b1605702 100644
--- a/src/NzbDrone.Core/Localization/Core/en.json
+++ b/src/NzbDrone.Core/Localization/Core/en.json
@@ -594,6 +594,10 @@
"ImportLists": "Import Lists",
"ImportListsSettingsSummary": "Import from another {appName} instance or Trakt lists and manage list exclusions",
"ImportMechanismHealthCheckMessage": "Enable Completed Download Handling",
+ "ImportScriptPath": "Import Script Path",
+ "ImportScriptPathHelpText": "The path to the script to use for importing",
+ "ImportUsingScript": "Import Using Script",
+ "ImportUsingScriptHelpText": "Copy files for importing using a script (ex. for transcoding)",
"ImportedTo": "Imported To",
"Importing": "Importing",
"Inactive": "Inactive",
diff --git a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs
index 18e8a6ccd..c6811a513 100644
--- a/src/NzbDrone.Core/MediaCover/MediaCoverService.cs
+++ b/src/NzbDrone.Core/MediaCover/MediaCoverService.cs
@@ -18,7 +18,7 @@ namespace NzbDrone.Core.MediaCover
{
public interface IMapCoversToLocal
{
- void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable covers);
+ void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, ICollection covers);
string GetCoverPath(int entityId, MediaCoverEntity coverEntity, MediaCoverTypes coverType, string extension, int? height = null);
bool EnsureAlbumCovers(Album album);
}
@@ -82,7 +82,7 @@ public string GetCoverPath(int entityId, MediaCoverEntity coverEntity, MediaCove
return Path.Combine(GetArtistCoverPath(entityId), coverType.ToString().ToLower() + heightSuffix + GetExtension(coverType, extension));
}
- public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnumerable covers)
+ public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, ICollection covers)
{
if (entityId == 0)
{
@@ -92,34 +92,39 @@ public void ConvertToLocalUrls(int entityId, MediaCoverEntity coverEntity, IEnum
mediaCover.RemoteUrl = mediaCover.Url;
mediaCover.Url = _mediaCoverProxy.RegisterUrl(mediaCover.RemoteUrl);
}
+
+ return;
}
- else
+
+ if (!covers.Any())
{
- foreach (var mediaCover in covers)
+ PopulateCoverWithCache(entityId, coverEntity, covers);
+ }
+
+ foreach (var mediaCover in covers)
+ {
+ if (mediaCover.CoverType == MediaCoverTypes.Unknown)
{
- if (mediaCover.CoverType == MediaCoverTypes.Unknown)
- {
- continue;
- }
+ continue;
+ }
- var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null);
+ var filePath = GetCoverPath(entityId, coverEntity, mediaCover.CoverType, mediaCover.Extension, null);
- mediaCover.RemoteUrl = mediaCover.Url;
+ mediaCover.RemoteUrl = mediaCover.Url;
- if (coverEntity == MediaCoverEntity.Album)
- {
- mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Albums/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
- }
- else
- {
- mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
- }
+ if (coverEntity == MediaCoverEntity.Album)
+ {
+ mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/Albums/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
+ }
+ else
+ {
+ mediaCover.Url = _configFileProvider.UrlBase + @"/MediaCover/" + entityId + "/" + mediaCover.CoverType.ToString().ToLower() + GetExtension(mediaCover.CoverType, mediaCover.Extension);
+ }
- if (_diskProvider.FileExists(filePath))
- {
- var lastWrite = _diskProvider.FileGetLastWrite(filePath);
- mediaCover.Url += "?lastWrite=" + lastWrite.Ticks;
- }
+ if (_diskProvider.FileExists(filePath))
+ {
+ var lastWrite = _diskProvider.FileGetLastWrite(filePath);
+ mediaCover.Url += "?lastWrite=" + lastWrite.Ticks;
}
}
}
@@ -194,6 +199,35 @@ private bool EnsureArtistCovers(Artist artist)
return updated;
}
+ private void PopulateCoverWithCache(int entityId, MediaCoverEntity coverEntity, ICollection covers)
+ {
+ var folderPath = coverEntity == MediaCoverEntity.Album ? GetAlbumCoverPath(entityId) : GetArtistCoverPath(entityId);
+
+ if (_diskProvider.FolderExists(folderPath))
+ {
+ foreach (var fileInfo in _diskProvider.GetFileInfos(folderPath))
+ {
+ var fileName = Path.GetFileNameWithoutExtension(fileInfo.Name);
+ var extension = Path.GetExtension(fileInfo.Name);
+ if (fileName.Contains('-'))
+ {
+ continue;
+ }
+
+ if (Enum.TryParse(fileName, true, out MediaCoverTypes coverType) && !covers.Any(c => c.CoverType == coverType))
+ {
+ var filePath = fileInfo.FullName;
+ var diskCover = new MediaCover(coverType, filePath)
+ {
+ RemoteUrl = filePath
+ };
+
+ covers.Add(diskCover);
+ }
+ }
+ }
+ }
+
public bool EnsureAlbumCovers(Album album)
{
var updated = false;
diff --git a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs
index 7797b5d2f..a01213542 100644
--- a/src/NzbDrone.Core/MediaFiles/AudioTagService.cs
+++ b/src/NzbDrone.Core/MediaFiles/AudioTagService.cs
@@ -74,66 +74,103 @@ public ParsedTrackInfo ReadTags(string path)
public AudioTag GetTrackMetadata(TrackFile trackfile)
{
- var track = trackfile.Tracks.Value[0];
- var release = track.AlbumRelease.Value;
- var album = release.Album.Value;
- var albumartist = album.Artist.Value;
- var artist = track.ArtistMetadata.Value;
-
- string imageFile = null;
- long imageSize = 0;
-
- if (_configService.EmbedCoverArt)
+ try
{
- var cover = album.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
- if (cover != null)
+ if (trackfile.Tracks?.Value == null || !trackfile.Tracks.Value.Any())
{
- imageFile = _mediaCoverService.GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null);
- _logger.Trace("Embedding: {0}", imageFile);
- var fileInfo = _diskProvider.GetFileInfo(imageFile);
- if (fileInfo.Exists)
+ throw new InvalidOperationException("Unable to write tags: Track information is missing from the database");
+ }
+
+ var track = trackfile.Tracks.Value[0];
+
+ if (track.AlbumRelease?.Value == null)
+ {
+ throw new InvalidOperationException("Unable to write tags: Album release information is missing from the database");
+ }
+
+ var release = track.AlbumRelease.Value;
+
+ if (release.Album?.Value == null)
+ {
+ throw new InvalidOperationException("Unable to write tags: Album information is missing from the database");
+ }
+
+ var album = release.Album.Value;
+
+ if (album.Artist?.Value == null)
+ {
+ throw new InvalidOperationException("Unable to write tags: Artist information is missing from the database");
+ }
+
+ var albumartist = album.Artist.Value;
+
+ if (track.ArtistMetadata?.Value == null)
+ {
+ throw new InvalidOperationException("Unable to write tags: Artist metadata is missing from the database");
+ }
+
+ var artist = track.ArtistMetadata.Value;
+
+ string imageFile = null;
+ long imageSize = 0;
+
+ if (_configService.EmbedCoverArt)
+ {
+ var cover = album.Images.FirstOrDefault(x => x.CoverType == MediaCoverTypes.Cover);
+ if (cover != null)
{
- imageSize = fileInfo.Length;
- }
- else
- {
- imageFile = null;
+ imageFile = _mediaCoverService.GetCoverPath(album.Id, MediaCoverEntity.Album, cover.CoverType, cover.Extension, null);
+ _logger.Trace("Embedding: {0}", imageFile);
+ var fileInfo = _diskProvider.GetFileInfo(imageFile);
+ if (fileInfo.Exists)
+ {
+ imageSize = fileInfo.Length;
+ }
+ else
+ {
+ imageFile = null;
+ }
}
}
+
+ return new AudioTag
+ {
+ Title = track.Title,
+ Performers = new[] { artist.Name },
+ AlbumArtists = new[] { albumartist.Name },
+ Track = (uint)track.AbsoluteTrackNumber,
+ TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber),
+ Album = album.Title,
+ Disc = (uint)track.MediumNumber,
+ DiscCount = (uint)release.Media.Count,
+
+ // We may have omitted media so index in the list isn't the same as medium number
+ Media = release.Media.SingleOrDefault(x => x.Number == track.MediumNumber)?.Format,
+ Date = release.ReleaseDate ?? album.ReleaseDate,
+ Year = (uint)(album.ReleaseDate?.Year ?? release.ReleaseDate?.Year ?? 0),
+ OriginalReleaseDate = album.ReleaseDate,
+ OriginalYear = (uint)(album.ReleaseDate?.Year ?? 0),
+ Publisher = release.Label.FirstOrDefault(),
+ Genres = album.Genres.Any() ? album.Genres.ToArray() : artist.Genres.ToArray(),
+ ImageFile = imageFile,
+ ImageSize = imageSize,
+ MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault())?.TwoLetterCode,
+ MusicBrainzReleaseStatus = release.Status.ToLower(),
+ MusicBrainzReleaseType = album.AlbumType.ToLower(),
+ MusicBrainzReleaseId = release.ForeignReleaseId,
+ MusicBrainzArtistId = artist.ForeignArtistId,
+ MusicBrainzReleaseArtistId = albumartist.ForeignArtistId,
+ MusicBrainzReleaseGroupId = album.ForeignAlbumId,
+ MusicBrainzTrackId = track.ForeignRecordingId,
+ MusicBrainzReleaseTrackId = track.ForeignTrackId,
+ MusicBrainzAlbumComment = album.Disambiguation,
+ };
}
-
- return new AudioTag
+ catch (Exception ex)
{
- Title = track.Title,
- Performers = new[] { artist.Name },
- AlbumArtists = new[] { albumartist.Name },
- Track = (uint)track.AbsoluteTrackNumber,
- TrackCount = (uint)release.Tracks.Value.Count(x => x.MediumNumber == track.MediumNumber),
- Album = album.Title,
- Disc = (uint)track.MediumNumber,
- DiscCount = (uint)release.Media.Count,
-
- // We may have omitted media so index in the list isn't the same as medium number
- Media = release.Media.SingleOrDefault(x => x.Number == track.MediumNumber)?.Format,
- Date = release.ReleaseDate ?? album.ReleaseDate,
- Year = (uint)(album.ReleaseDate?.Year ?? release.ReleaseDate?.Year ?? 0),
- OriginalReleaseDate = album.ReleaseDate,
- OriginalYear = (uint)(album.ReleaseDate?.Year ?? 0),
- Publisher = release.Label.FirstOrDefault(),
- Genres = album.Genres.Any() ? album.Genres.ToArray() : artist.Genres.ToArray(),
- ImageFile = imageFile,
- ImageSize = imageSize,
- MusicBrainzReleaseCountry = IsoCountries.Find(release.Country.FirstOrDefault())?.TwoLetterCode,
- MusicBrainzReleaseStatus = release.Status.ToLower(),
- MusicBrainzReleaseType = album.AlbumType.ToLower(),
- MusicBrainzReleaseId = release.ForeignReleaseId,
- MusicBrainzArtistId = artist.ForeignArtistId,
- MusicBrainzReleaseArtistId = albumartist.ForeignArtistId,
- MusicBrainzReleaseGroupId = album.ForeignAlbumId,
- MusicBrainzTrackId = track.ForeignRecordingId,
- MusicBrainzReleaseTrackId = track.ForeignTrackId,
- MusicBrainzAlbumComment = album.Disambiguation,
- };
+ _logger.Error(ex, "Failed to get track metadata for {0}", trackfile.Path);
+ throw;
+ }
}
private void UpdateTrackfileSizeAndModified(TrackFile trackfile, string path)
diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
index 0c5c4ca05..a77432497 100644
--- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
+++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs
@@ -142,6 +142,35 @@ public void Scan(List folders = null, FilterFilesType filter = FilterFil
mediaFileList.AddRange(files);
}
+ var artists = _artistService.GetArtists(artistIds);
+
+ // Check for missing artist folders if specific artists are being scanned
+ if (artistIds != null && artistIds.Any())
+ {
+ foreach (var artist in artists)
+ {
+ if (!_diskProvider.FolderExists(artist.Path))
+ {
+ if (_configService.CreateEmptyArtistFolders)
+ {
+ if (_configService.DeleteEmptyFolders)
+ {
+ _logger.Debug("Not creating missing artist folder: {0} because delete empty folders is enabled", artist.Path);
+ }
+ else
+ {
+ _logger.Debug("Creating missing artist folder: {0}", artist.Path);
+ _diskProvider.CreateFolder(artist.Path);
+ }
+ }
+ else
+ {
+ _logger.Debug("Artist folder doesn't exist: {0}", artist.Path);
+ }
+ }
+ }
+ }
+
musicFilesStopwatch.Stop();
_logger.Trace("Finished getting track files for:\n{0} [{1}]", folders.ConcatToString("\n"), musicFilesStopwatch.Elapsed);
@@ -211,7 +240,6 @@ public void Scan(List folders = null, FilterFilesType filter = FilterFil
_logger.Debug($"Updated info for {updatedFiles.Count} known files");
- var artists = _artistService.GetArtists(artistIds);
foreach (var artist in artists)
{
CompletedScanning(artist);
diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs
new file mode 100644
index 000000000..fc0404ca0
--- /dev/null
+++ b/src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs
@@ -0,0 +1,125 @@
+using System.Collections.Specialized;
+using System.Linq;
+using NLog;
+using NzbDrone.Common.Disk;
+using NzbDrone.Common.Processes;
+using NzbDrone.Core.Configuration;
+using NzbDrone.Core.CustomFormats;
+using NzbDrone.Core.Download;
+using NzbDrone.Core.Parser.Model;
+using NzbDrone.Core.Tags;
+
+namespace NzbDrone.Core.MediaFiles
+{
+ public interface IImportScript
+ {
+ public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalTrack localTrack, TrackFile trackFile, TransferMode mode, DownloadClientItem downloadClientItem = null);
+ }
+
+ public class ImportScriptService : IImportScript
+ {
+ private readonly IConfigFileProvider _configFileProvider;
+ private readonly IAudioTagService _audioTagService;
+ private readonly IProcessProvider _processProvider;
+ private readonly IConfigService _configService;
+ private readonly ITagRepository _tagRepository;
+ private readonly ICustomFormatCalculationService _customFormatCalculationService;
+ private readonly Logger _logger;
+
+ public ImportScriptService(IProcessProvider processProvider,
+ IAudioTagService audioTagService,
+ IConfigService configService,
+ IConfigFileProvider configFileProvider,
+ ITagRepository tagRepository,
+ ICustomFormatCalculationService customFormatCalculationService,
+ Logger logger)
+ {
+ _processProvider = processProvider;
+ _audioTagService = audioTagService;
+ _configService = configService;
+ _configFileProvider = configFileProvider;
+ _tagRepository = tagRepository;
+ _customFormatCalculationService = customFormatCalculationService;
+ _logger = logger;
+ }
+
+ public ScriptImportDecision TryImport(string sourcePath, string destinationFilePath, LocalTrack localTrack, TrackFile trackFile, TransferMode mode, DownloadClientItem downloadClientItem = null)
+ {
+ var artist = localTrack.Artist;
+ var album = localTrack.Album;
+ var downloadClientInfo = downloadClientItem?.DownloadClientInfo;
+ var downloadId = downloadClientItem?.DownloadId;
+
+ if (!_configService.UseScriptImport)
+ {
+ return ScriptImportDecision.DeferMove;
+ }
+
+ var environmentVariables = new StringDictionary
+ {
+ { "Lidarr_SourcePath", sourcePath },
+ { "Lidarr_DestinationPath", destinationFilePath },
+ { "Lidarr_InstanceName", _configFileProvider.InstanceName },
+ { "Lidarr_ApplicationUrl", _configService.ApplicationUrl },
+ { "Lidarr_TransferMode", mode.ToString() },
+ { "Lidarr_Artist_Id", artist.Id.ToString() },
+ { "Lidarr_Artist_Name", artist.Name },
+ { "Lidarr_Artist_Path", artist.Path },
+ { "Lidarr_Artist_MBId", artist.ForeignArtistId },
+ { "Lidarr_Artist_Tags", string.Join("|", artist.Tags.Select(t => _tagRepository.Get(t).Label)) },
+ { "Lidarr_Album_Id", album.Id.ToString() },
+ { "Lidarr_Album_Title", album.Title },
+ { "Lidarr_Album_MBId", album.ForeignAlbumId },
+ { "Lidarr_Album_ReleaseDate", album.ReleaseDate?.ToString("yyyy-MM-dd") ?? string.Empty },
+ { "Lidarr_Album_Genres", string.Join("|", album.Genres) },
+ { "Lidarr_TrackFile_TrackCount", localTrack.Tracks.Count.ToString() },
+ { "Lidarr_TrackFile_TrackIds", string.Join(",", localTrack.Tracks.Select(t => t.Id)) },
+ { "Lidarr_TrackFile_TrackNumbers", string.Join(",", localTrack.Tracks.Select(t => t.TrackNumber)) },
+ { "Lidarr_TrackFile_TrackTitles", string.Join("|", localTrack.Tracks.Select(t => t.Title)) },
+ { "Lidarr_TrackFile_Quality", localTrack.Quality.Quality.Name },
+ { "Lidarr_TrackFile_QualityVersion", localTrack.Quality.Revision.Version.ToString() },
+ { "Lidarr_TrackFile_ReleaseGroup", localTrack.ReleaseGroup ?? string.Empty },
+ { "Lidarr_TrackFile_SceneName", localTrack.SceneName ?? string.Empty },
+ { "Lidarr_Download_Client", downloadClientInfo?.Name ?? string.Empty },
+ { "Lidarr_Download_Client_Type", downloadClientInfo?.Type ?? string.Empty },
+ { "Lidarr_Download_Id", downloadId ?? string.Empty }
+ };
+
+ // Audio-specific MediaInfo (no video properties for music files)
+ if (localTrack.FileTrackInfo?.MediaInfo != null)
+ {
+ var mediaInfo = localTrack.FileTrackInfo.MediaInfo;
+ environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioChannels", mediaInfo.AudioChannels.ToString());
+ environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioCodec", mediaInfo.AudioFormat ?? string.Empty);
+ environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioBitRate", mediaInfo.AudioBitrate.ToString());
+ environmentVariables.Add("Lidarr_TrackFile_MediaInfo_AudioSampleRate", mediaInfo.AudioSampleRate.ToString());
+ environmentVariables.Add("Lidarr_TrackFile_MediaInfo_BitsPerSample", mediaInfo.AudioBits.ToString());
+ }
+
+ // CustomFormats for music files
+ var customFormats = _customFormatCalculationService.ParseCustomFormat(localTrack);
+ environmentVariables.Add("Lidarr_TrackFile_CustomFormat", string.Join("|", customFormats.Select(x => x.Name)));
+
+ _logger.Debug("Executing external script: {0}", _configService.ScriptImportPath);
+
+ var processOutput = _processProvider.StartAndCapture(_configService.ScriptImportPath, $"\"{sourcePath}\" \"{destinationFilePath}\"", environmentVariables);
+
+ _logger.Debug("Executed external script: {0} - Status: {1}", _configService.ScriptImportPath, processOutput.ExitCode);
+ _logger.Debug("Script Output: \r\n{0}", string.Join("\r\n", processOutput.Lines));
+
+ switch (processOutput.ExitCode)
+ {
+ case 0: // Copy complete
+ return ScriptImportDecision.MoveComplete;
+ case 2: // Copy complete, file potentially changed, should try renaming again
+ trackFile.MediaInfo = _audioTagService.ReadTags(destinationFilePath).MediaInfo;
+ trackFile.Path = null;
+ return ScriptImportDecision.RenameRequested;
+ case 3: // Let Lidarr handle it
+ return ScriptImportDecision.DeferMove;
+ default: // Error, fail to import
+ throw new ScriptImportException("Moving with script failed! Exit code {0}", processOutput.ExitCode);
+ }
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs
new file mode 100644
index 000000000..fb2eb4f6f
--- /dev/null
+++ b/src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs
@@ -0,0 +1,10 @@
+namespace NzbDrone.Core.MediaFiles
+{
+ public enum ScriptImportDecision
+ {
+ MoveComplete,
+ RenameRequested,
+ RejectExtra,
+ DeferMove
+ }
+}
diff --git a/src/NzbDrone.Core/MediaFiles/ScriptImportException.cs b/src/NzbDrone.Core/MediaFiles/ScriptImportException.cs
new file mode 100644
index 000000000..9ac0f49d4
--- /dev/null
+++ b/src/NzbDrone.Core/MediaFiles/ScriptImportException.cs
@@ -0,0 +1,23 @@
+using System;
+using NzbDrone.Common.Exceptions;
+
+namespace NzbDrone.Core.MediaFiles
+{
+ public class ScriptImportException : NzbDroneException
+ {
+ public ScriptImportException(string message)
+ : base(message)
+ {
+ }
+
+ public ScriptImportException(string message, params object[] args)
+ : base(message, args)
+ {
+ }
+
+ public ScriptImportException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+ }
+}
diff --git a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs
index 68bc6c21d..151fee3ff 100644
--- a/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs
+++ b/src/NzbDrone.Core/MediaFiles/TrackFileMovingService.cs
@@ -35,6 +35,7 @@ public class TrackFileMovingService : IMoveTrackFiles
private readonly IMediaFileAttributeService _mediaFileAttributeService;
private readonly IRootFolderService _rootFolderService;
private readonly IEventAggregator _eventAggregator;
+ private readonly IImportScript _scriptImportDecider;
private readonly IConfigService _configService;
private readonly Logger _logger;
@@ -48,6 +49,7 @@ public TrackFileMovingService(ITrackService trackService,
IMediaFileAttributeService mediaFileAttributeService,
IRootFolderService rootFolderService,
IEventAggregator eventAggregator,
+ IImportScript scriptImportDecider,
IConfigService configService,
Logger logger)
{
@@ -61,6 +63,7 @@ public TrackFileMovingService(ITrackService trackService,
_mediaFileAttributeService = mediaFileAttributeService;
_rootFolderService = rootFolderService;
_eventAggregator = eventAggregator;
+ _scriptImportDecider = scriptImportDecider;
_configService = configService;
_logger = logger;
}
@@ -86,7 +89,7 @@ public TrackFile MoveTrackFile(TrackFile trackFile, LocalTrack localTrack)
_logger.Debug("Moving track file: {0} to {1}", trackFile.Path, filePath);
- return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move);
+ return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Move, localTrack);
}
public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack)
@@ -98,14 +101,14 @@ public TrackFile CopyTrackFile(TrackFile trackFile, LocalTrack localTrack)
if (_configService.CopyUsingHardlinks)
{
_logger.Debug("Attempting to hardlink track file: {0} to {1}", trackFile.Path, filePath);
- return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy);
+ return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.HardLinkOrCopy, localTrack);
}
_logger.Debug("Copying track file: {0} to {1}", trackFile.Path, filePath);
- return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy);
+ return TransferFile(trackFile, localTrack.Artist, localTrack.Tracks, filePath, TransferMode.Copy, localTrack);
}
- private TrackFile TransferFile(TrackFile trackFile, Artist artist, List tracks, string destinationFilePath, TransferMode mode)
+ private TrackFile TransferFile(TrackFile trackFile, Artist artist, List tracks, string destinationFilePath, TransferMode mode, LocalTrack localTrack = null)
{
Ensure.That(trackFile, () => trackFile).IsNotNull();
Ensure.That(artist, () => artist).IsNotNull();
@@ -123,8 +126,31 @@ private TrackFile TransferFile(TrackFile trackFile, Artist artist, List t
throw new SameFilenameException("File not moved, source and destination are the same", trackFilePath);
}
- _rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath);
- _diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode);
+ var transfer = true;
+
+ if (localTrack is not null)
+ {
+ var scriptImportDecision = _scriptImportDecider.TryImport(trackFilePath, destinationFilePath, localTrack, trackFile, mode, null);
+
+ switch (scriptImportDecision)
+ {
+ case ScriptImportDecision.DeferMove:
+ break;
+ case ScriptImportDecision.RenameRequested:
+ MoveTrackFile(trackFile, artist);
+ transfer = false;
+ break;
+ case ScriptImportDecision.MoveComplete:
+ transfer = false;
+ break;
+ }
+ }
+
+ if (transfer)
+ {
+ _rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath);
+ _diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode);
+ }
trackFile.Path = destinationFilePath;
diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs
index 12fa48af2..63a9f5633 100644
--- a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs
+++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs
@@ -125,8 +125,8 @@ private static string Clean(string input)
public void AddString(string key, string value, string target)
{
// Adds a penaltly based on the distance between value and target
- var cleanValue = Clean(value);
- var cleanTarget = Clean(target);
+ var cleanValue = Clean(value ?? string.Empty);
+ var cleanTarget = Clean(target ?? string.Empty);
if (cleanValue.IsNullOrWhiteSpace() && cleanTarget.IsNotNullOrWhiteSpace())
{
diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs
index 1c5f46343..37128cee8 100644
--- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs
+++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs
@@ -250,7 +250,18 @@ public List Import(List> decisions, boo
_mediaFileService.Delete(previousFile, DeleteMediaFileReason.ManualOverride);
}
- _audioTagService.WriteTags(trackFile, false);
+ try
+ {
+ _audioTagService.WriteTags(trackFile, false);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.Warn(ex, "Failed to write tags for {0}: {1}. Try refreshing the artist to fix missing information.", trackFile.Path, ex.Message);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "Unexpected error writing tags for existing track file {0}", trackFile.Path);
+ }
}
filesToAdd.Add(trackFile);
diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs
index d7d0d2d4e..912bd4a9d 100644
--- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs
+++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs
@@ -123,10 +123,27 @@ public List GetMediaFiles(string path, string downloadId, Arti
AddNewArtists = false
};
- var decision = _importDecisionMaker.GetImportDecisions(files, null, null, config);
- var result = MapItem(decision.First(), downloadId, replaceExistingFiles, false);
+ var decisions = _importDecisionMaker.GetImportDecisions(files, null, null, config);
- return new List { result };
+ if (decisions.Any())
+ {
+ var result = MapItem(decisions.First(), downloadId, replaceExistingFiles, false);
+ return new List { result };
+ }
+
+ return new List
+ {
+ new ManualImportItem()
+ {
+ Id = HashConverter.GetHashInt31(path),
+ DownloadId = downloadId,
+ Path = path,
+ Name = Path.GetFileNameWithoutExtension(path),
+ Size = _diskProvider.GetFileSize(path),
+ Rejections = new List { new Rejection("Unable to process file") },
+ ReplaceExistingFiles = replaceExistingFiles
+ }
+ };
}
return ProcessFolder(path, downloadId, artist, filter, replaceExistingFiles);
@@ -150,6 +167,13 @@ private List ProcessFolder(string folder, string downloadId, A
}
var artistFiles = _diskScanService.GetAudioFiles(folder).ToList();
+
+ if (artist == null && artistFiles.Count > 100)
+ {
+ _logger.Warn("Unable to determine artist from folder name and found more than 100 files. Skipping parsing");
+ return ProcessDownloadDirectory(folder, artistFiles);
+ }
+
var idOverrides = new IdentificationOverrides
{
Artist = artist
@@ -184,6 +208,23 @@ private List ProcessFolder(string folder, string downloadId, A
return newItems.Concat(existingItems).ToList();
}
+ private List ProcessDownloadDirectory(string folder, List audioFiles)
+ {
+ var items = new List();
+
+ foreach (var file in audioFiles)
+ {
+ var localTrack = new LocalTrack();
+ localTrack.Path = file.FullName;
+ localTrack.Quality = new QualityModel(Quality.Unknown);
+ localTrack.Size = file.Length;
+
+ items.Add(MapItem(new ImportDecision(localTrack), null, false, false));
+ }
+
+ return items;
+ }
+
public List UpdateItems(List items)
{
var replaceExistingFiles = items.All(x => x.ReplaceExistingFiles);
diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
index 71da31e3e..1aaeaaba7 100644
--- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
+++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs
@@ -328,19 +328,36 @@ public List SearchForNewAlbum(string title, string artist)
public List SearchForNewAlbumByRecordingIds(List recordingIds)
{
- var ids = recordingIds.Where(x => x.IsNotNullOrWhiteSpace()).Distinct();
- var httpRequest = _requestBuilder.GetRequestBuilder().Create()
- .SetSegment("route", "search/fingerprint")
- .Build();
+ try
+ {
+ var ids = recordingIds.Where(x => x.IsNotNullOrWhiteSpace()).Distinct();
+ var httpRequest = _requestBuilder.GetRequestBuilder().Create()
+ .SetSegment("route", "search/fingerprint")
+ .Build();
- httpRequest.SetContent(ids.ToJson());
- httpRequest.Headers.ContentType = "application/json";
+ httpRequest.SetContent(ids.ToJson());
+ httpRequest.Headers.ContentType = "application/json";
- var httpResponse = _httpClient.Post>(httpRequest);
+ var httpResponse = _httpClient.Post>(httpRequest);
- return httpResponse.Resource.Select(MapSearchResult)
- .Where(x => x != null)
- .ToList();
+ return httpResponse.Resource.Select(MapSearchResult)
+ .Where(x => x != null)
+ .ToList();
+ }
+ catch (HttpException ex)
+ {
+ if (ex.Response != null && ex.Response.StatusCode == HttpStatusCode.ServiceUnavailable)
+ {
+ throw new SkyHookException("Search by fingerprint failed. LidarrAPI Temporarily Unavailable (503)");
+ }
+
+ throw new SkyHookException("Search by fingerprint failed. Unable to communicate with LidarrAPI. {0}", ex, ex.Message);
+ }
+ catch (Exception ex) when (ex is not SkyHookException)
+ {
+ _logger.Warn(ex, ex.Message);
+ throw new SkyHookException("Search by fingerprint failed. Invalid response received from LidarrAPI.");
+ }
}
public List SearchForNewEntity(string title)
diff --git a/src/NzbDrone.Core/Music/Model/ArtistStatusType.cs b/src/NzbDrone.Core/Music/Model/ArtistStatusType.cs
index 478959016..1535c2dcc 100644
--- a/src/NzbDrone.Core/Music/Model/ArtistStatusType.cs
+++ b/src/NzbDrone.Core/Music/Model/ArtistStatusType.cs
@@ -2,6 +2,7 @@ namespace NzbDrone.Core.Music
{
public enum ArtistStatusType
{
+ Deleted = -1,
Continuing = 0,
Ended = 1
}
diff --git a/src/NzbDrone.Core/Music/Repositories/ArtistMetadataRepository.cs b/src/NzbDrone.Core/Music/Repositories/ArtistMetadataRepository.cs
index 1f54aa3e5..de8a95cbc 100644
--- a/src/NzbDrone.Core/Music/Repositories/ArtistMetadataRepository.cs
+++ b/src/NzbDrone.Core/Music/Repositories/ArtistMetadataRepository.cs
@@ -39,6 +39,12 @@ public bool UpsertMany(List data)
var existing = existingMetadata.SingleOrDefault(x => x.ForeignArtistId == meta.ForeignArtistId);
if (existing != null)
{
+ if (IsPlaceholderData(meta) && !IsPlaceholderData(existing))
+ {
+ _logger.Warn($"Skipping metadata downgrade: {existing.Name} -> {meta.Name}, for artist {meta.ForeignArtistId}");
+ continue;
+ }
+
meta.UseDbFieldsFrom(existing);
if (!meta.Equals(existing))
{
@@ -62,5 +68,10 @@ public bool UpsertMany(List data)
return updateMetadataList.Count > 0 || addMetadataList.Count > 0;
}
+
+ private static bool IsPlaceholderData(ArtistMetadata metadata) =>
+ metadata.Name?.StartsWith("Unknown Artist", System.StringComparison.OrdinalIgnoreCase) != false ||
+ metadata.Disambiguation == "Artist not found in database" ||
+ metadata.Type == "Unknown";
}
}
diff --git a/src/NzbDrone.Core/Music/Services/AddArtistService.cs b/src/NzbDrone.Core/Music/Services/AddArtistService.cs
index 2b484902e..90238dce1 100644
--- a/src/NzbDrone.Core/Music/Services/AddArtistService.cs
+++ b/src/NzbDrone.Core/Music/Services/AddArtistService.cs
@@ -67,6 +67,7 @@ public List AddArtists(List newArtists, bool doRefresh = true, b
{
var added = DateTime.UtcNow;
var artistsToAdd = new List();
+ var existingArtists = _artistService.GetAllArtists();
foreach (var s in newArtists)
{
@@ -84,6 +85,12 @@ public List AddArtists(List newArtists, bool doRefresh = true, b
var artist = AddSkyhookData(s);
artist = SetPropertiesAndValidate(artist);
artist.Added = added;
+ if (existingArtists.Any(f => f.ForeignArtistId == artist.ForeignArtistId))
+ {
+ _logger.Debug("Musicbrainz ID {0} was not added due to validation failure: Artist already exists in database", s.ForeignArtistId);
+ continue;
+ }
+
if (artistsToAdd.Any(f => f.ForeignArtistId == artist.ForeignArtistId))
{
_logger.Debug("Musicbrainz ID {0} was not added due to validation failure: Artist already exists on list", s.ForeignArtistId);
diff --git a/src/NzbDrone.Core/Music/Services/MonitorNewAlbumService.cs b/src/NzbDrone.Core/Music/Services/MonitorNewAlbumService.cs
index 3d92b0634..06b5a8031 100644
--- a/src/NzbDrone.Core/Music/Services/MonitorNewAlbumService.cs
+++ b/src/NzbDrone.Core/Music/Services/MonitorNewAlbumService.cs
@@ -23,19 +23,51 @@ public bool ShouldMonitorNewAlbum(Album addedAlbum, List existingAlbums,
{
if (monitorNewItems == NewItemMonitorTypes.None)
{
+ _logger.Trace("Album '{0}' will not be monitored: Monitor setting is set to 'None'", addedAlbum.Title);
return false;
}
if (monitorNewItems == NewItemMonitorTypes.All)
{
+ _logger.Trace("Album '{0}' will be monitored: Monitor setting is set to 'All'", addedAlbum.Title);
return true;
}
if (monitorNewItems == NewItemMonitorTypes.New)
{
- var newest = existingAlbums.MaxBy(x => x.ReleaseDate ?? DateTime.MinValue)?.ReleaseDate ?? DateTime.MinValue;
+ var newestExistingDate = existingAlbums
+ .Where(x => x.ReleaseDate.HasValue)
+ .MaxBy(x => x.ReleaseDate.Value)?.ReleaseDate;
- return (addedAlbum.ReleaseDate ?? DateTime.MinValue) >= newest;
+ if (!addedAlbum.ReleaseDate.HasValue)
+ {
+ if (!newestExistingDate.HasValue)
+ {
+ _logger.Debug("Album '{0}' will be monitored: Both new and existing albums have no release dates", addedAlbum.Title);
+ return true;
+ }
+ else
+ {
+ _logger.Debug("Album '{0}' will not be monitored: Albums without release dates are skipped when existing albums have dates", addedAlbum.Title);
+ return false;
+ }
+ }
+
+ if (!newestExistingDate.HasValue)
+ {
+ _logger.Debug("Album '{0}' will be monitored: No existing albums have release dates, so this is considered the first 'new' release", addedAlbum.Title);
+ return true;
+ }
+
+ var shouldMonitor = addedAlbum.ReleaseDate.Value >= newestExistingDate.Value;
+ _logger.Trace("Album '{0}' ({1}) {2} be monitored: Release date is {3} the most recent existing album ({4})",
+ addedAlbum.Title,
+ addedAlbum.ReleaseDate.Value.ToString("yyyy-MM-dd"),
+ shouldMonitor ? "will" : "will not",
+ shouldMonitor ? "on or after" : "before",
+ newestExistingDate.Value.ToString("yyyy-MM-dd"));
+
+ return shouldMonitor;
}
throw new NotImplementedException($"Unknown new item monitor type {monitorNewItems}");
diff --git a/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs b/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs
index 48223ecf8..2126e54bb 100644
--- a/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs
+++ b/src/NzbDrone.Core/Music/Services/RefreshArtistService.cs
@@ -87,7 +87,15 @@ protected override RemoteData GetRemoteData(Artist local, List remote)
}
catch (ArtistNotFoundException)
{
- _logger.Error($"Could not find artist with id {local.Metadata.Value.ForeignArtistId}");
+ if (local.Metadata.Value.Status != ArtistStatusType.Deleted)
+ {
+ local.Metadata.Value.Status = ArtistStatusType.Deleted;
+ _artistService.UpdateArtist(local);
+ _logger.Debug("Artist marked as deleted on MusicBrainz for {0}", local.Name);
+ _eventAggregator.PublishEvent(new ArtistUpdatedEvent(local));
+ }
+
+ _logger.Error($"Artist '{local.Name}' (mbid {local.Metadata.Value.ForeignArtistId}) was not found, it may have been removed from MusicBrainz.");
}
return result;
diff --git a/src/NzbDrone.Core/Music/Utilities/ShouldRefreshArtist.cs b/src/NzbDrone.Core/Music/Utilities/ShouldRefreshArtist.cs
index 26548d757..3a4eb2130 100644
--- a/src/NzbDrone.Core/Music/Utilities/ShouldRefreshArtist.cs
+++ b/src/NzbDrone.Core/Music/Utilities/ShouldRefreshArtist.cs
@@ -42,9 +42,9 @@ public bool ShouldRefresh(Artist artist)
return false;
}
- if (artist.Metadata.Value.Status == ArtistStatusType.Continuing && artist.LastInfoSync < DateTime.UtcNow.AddDays(-2))
+ if (artist.Metadata.Value.Status != ArtistStatusType.Ended && artist.LastInfoSync < DateTime.UtcNow.AddDays(-2))
{
- _logger.Trace("Artist {0} is continuing and has not been refreshed in 2 days, should refresh.", artist.Name);
+ _logger.Trace("Artist {0} is not ended and has not been refreshed in 2 days, should refresh.", artist.Name);
return true;
}
@@ -52,7 +52,7 @@ public bool ShouldRefresh(Artist artist)
if (lastAlbum != null && lastAlbum.ReleaseDate > DateTime.UtcNow.AddDays(-30))
{
- _logger.Trace("Last album in {0} aired less than 30 days ago, should refresh.", artist.Name);
+ _logger.Trace("Last album in {0} released less than 30 days ago, should refresh.", artist.Name);
return true;
}
diff --git a/src/NzbDrone.Host/Startup.cs b/src/NzbDrone.Host/Startup.cs
index 75f19260e..c64e55638 100644
--- a/src/NzbDrone.Host/Startup.cs
+++ b/src/NzbDrone.Host/Startup.cs
@@ -52,7 +52,7 @@ public void ConfigureServices(IServiceCollection services)
b.ClearProviders();
b.SetMinimumLevel(LogLevel.Trace);
b.AddFilter("Microsoft.AspNetCore", LogLevel.Warning);
- b.AddFilter("Lidarr.Http.Authentication", LogLevel.Information);
+ b.AddFilter("Lidarr.Http.Authentication.ApiKeyAuthenticationHandler", LogLevel.Information);
b.AddFilter("Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager", LogLevel.Error);
b.AddNLog();
});
diff --git a/src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs
index df7fe0919..390cda720 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs
@@ -7,7 +7,7 @@
namespace NzbDrone.Integration.Test.ApiTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class ArtistEditorFixture : IntegrationTest
{
private void GivenExistingArtist()
diff --git a/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs
index 7d4836ef0..9ec659eba 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs
@@ -7,7 +7,7 @@
namespace NzbDrone.Integration.Test.ApiTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class ArtistFixture : IntegrationTest
{
[Test]
diff --git a/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs
index afc485358..e2d6df56f 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs
@@ -4,7 +4,7 @@
namespace NzbDrone.Integration.Test.ApiTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class ArtistLookupFixture : IntegrationTest
{
[TestCase("Kiss", "Kiss")]
diff --git a/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs
index 532bd5829..8d615842c 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs
@@ -6,7 +6,7 @@
namespace NzbDrone.Integration.Test.ApiTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class BlocklistFixture : IntegrationTest
{
private ArtistResource _artist;
diff --git a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs
index e48af394d..223bd6cbc 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs
@@ -9,7 +9,7 @@
namespace NzbDrone.Integration.Test.ApiTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class CalendarFixture : IntegrationTest
{
public ClientBase Calendar;
diff --git a/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs
index 2e2f0cc63..290ec3eaf 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs
@@ -7,7 +7,7 @@
namespace NzbDrone.Integration.Test.ApiTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class TrackFixture : IntegrationTest
{
private ArtistResource _artist;
diff --git a/src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs
index 95c734097..80869c1a9 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/CutoffUnmetFixture.cs
@@ -8,7 +8,7 @@
namespace NzbDrone.Integration.Test.ApiTests.WantedTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class CutoffUnmetFixture : IntegrationTest
{
[SetUp]
diff --git a/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs
index 76437ecc5..bc7403068 100644
--- a/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs
+++ b/src/NzbDrone.Integration.Test/ApiTests/WantedTests/MissingFixture.cs
@@ -7,7 +7,7 @@
namespace NzbDrone.Integration.Test.ApiTests.WantedTests
{
[TestFixture]
- [Ignore("Waiting for metadata to be back again", Until = "2025-09-01 00:00:00Z")]
+ [Ignore("Waiting for metadata to be back again", Until = "2025-10-01 00:00:00Z")]
public class MissingFixture : IntegrationTest
{
[SetUp]