From 3dabcab134b338ed1ad526ef1263e6d9360cffa1 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:35:42 -0500 Subject: [PATCH 01/28] Skip tests temporally --- .../MetadataSource/SkyHook/SkyHookProxyFixture.cs | 2 +- .../MetadataSource/SkyHook/SkyHookProxySearchFixture.cs | 2 +- src/NzbDrone.Integration.Test/ApiTests/ArtistEditorFixture.cs | 2 +- src/NzbDrone.Integration.Test/ApiTests/ArtistFixture.cs | 2 +- src/NzbDrone.Integration.Test/ApiTests/ArtistLookupFixture.cs | 2 +- src/NzbDrone.Integration.Test/ApiTests/BlocklistFixture.cs | 2 +- src/NzbDrone.Integration.Test/ApiTests/CalendarFixture.cs | 2 +- src/NzbDrone.Integration.Test/ApiTests/TrackFixture.cs | 2 +- .../ApiTests/WantedTests/CutoffUnmetFixture.cs | 2 +- .../ApiTests/WantedTests/MissingFixture.cs | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) 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.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] From 19a1d5c62d5fb37f5212713361038b0dfaf636d4 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:57:16 -0500 Subject: [PATCH 02/28] Fixed: Improve error handling for fingerprint search API failures --- .../MetadataSource/SkyHook/SkyHookProxy.cs | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) 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) From 03bf4391d2623678df2846065e8ddc340837ea48 Mon Sep 17 00:00:00 2001 From: Weblate Date: Wed, 27 Aug 2025 16:33:37 +0000 Subject: [PATCH 03/28] Multiple Translations updated by Weblate ignore-downstream Co-authored-by: Xoores Translate-URL: https://translate.servarr.com/projects/servarr/lidarr/cs/ Translation: Servarr/Lidarr --- src/NzbDrone.Core/Localization/Core/cs.json | 68 ++++++++++----------- 1 file changed, 34 insertions(+), 34 deletions(-) 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í", From ffab40e923f5ee7e115a8cd48dd5640af40cc733 Mon Sep 17 00:00:00 2001 From: Meyn Date: Wed, 3 Sep 2025 23:41:23 +0200 Subject: [PATCH 04/28] Fix cover caching --- .../MediaCover/MediaCoverService.cs | 80 +++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) 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; From 56480c7c0a60d175e981409a2989acd0c92a929b Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Fri, 5 Sep 2025 09:41:27 -0500 Subject: [PATCH 05/28] Fixed: Null Tag Handling --- .../Identification/DistanceFixture.cs | 24 +++++++++++++++++++ .../TrackImport/Identification/Distance.cs | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) 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/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()) { From d45b8cf1c7f1c5cb4a6698ab808f15cc203e4c4c Mon Sep 17 00:00:00 2001 From: Bogdan Date: Mon, 13 Jan 2025 03:05:27 +0200 Subject: [PATCH 06/28] Add logging for custom format score above minimum (cherry picked from commit 87934c77614a60e2f53cf9dbeb553b0a4928977a) --- .../CustomFormatAllowedByProfileSpecification.cs | 9 +++++++++ 1 file changed, 9 insertions(+) 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(); } } From 8567a7d6cb0143c38ff08e679b9ad3eab07dcbcd Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Sat, 19 Oct 2019 17:15:28 +0200 Subject: [PATCH 07/28] New: Added health check warning to emphasis when a artist was deleted instead of only logging it in System Events --- .../HealthCheck/Checks/RemovedArtistCheck.cs | 53 +++++++++++++++++++ .../HealthCheck/HealthCheckService.cs | 2 +- .../Music/Model/ArtistStatusType.cs | 1 + .../Music/Services/RefreshArtistService.cs | 10 +++- .../Music/Utilities/ShouldRefreshArtist.cs | 6 +-- 5 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 src/NzbDrone.Core/HealthCheck/Checks/RemovedArtistCheck.cs 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/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/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; } From 38e11ee7689579a1fa6d1822f1fec3a807f8efd1 Mon Sep 17 00:00:00 2001 From: Taloth Saldono Date: Sun, 24 Nov 2019 23:47:22 +0100 Subject: [PATCH 08/28] Fixed: Refresh Deleted artists as frequently as Continuing ones (cherry picked from commit 06d57e8f32cfce8782eebad0b7808204c6c51575) --- .../MusicTests/ShouldRefreshArtistFixture.cs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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(); + } } } From 7cfa16a10b3933eaca878242712f2dc6a82bb3d9 Mon Sep 17 00:00:00 2001 From: bakerboy448 <55419169+bakerboy448@users.noreply.github.com> Date: Wed, 25 Nov 2020 18:51:22 -0600 Subject: [PATCH 09/28] Fixed: List Import no longer fails due to duplicates Closes Sonarr/Sonarr#4100 (cherry picked from commit 19ff7bdc3050a83a7e30140e2c2c89c4dfba5f84) --- src/NzbDrone.Core/Music/Services/AddArtistService.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/NzbDrone.Core/Music/Services/AddArtistService.cs b/src/NzbDrone.Core/Music/Services/AddArtistService.cs index 2b484902e..872284ca4 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,11 @@ 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); From 1eb6b8061b9bd41025264359ff0185b263b09f35 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Mon, 28 Oct 2019 12:50:00 -0700 Subject: [PATCH 10/28] Fixed: Manual Import failing to show files when processing fails --- .../TrackImport/Manual/ManualImportService.cs | 23 ++++++++++++++++--- .../Music/Services/AddArtistService.cs | 1 + 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index d7d0d2d4e..36112e984 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); diff --git a/src/NzbDrone.Core/Music/Services/AddArtistService.cs b/src/NzbDrone.Core/Music/Services/AddArtistService.cs index 872284ca4..90238dce1 100644 --- a/src/NzbDrone.Core/Music/Services/AddArtistService.cs +++ b/src/NzbDrone.Core/Music/Services/AddArtistService.cs @@ -90,6 +90,7 @@ public List AddArtists(List newArtists, bool doRefresh = true, b _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); From f627a3cb88e592403ddf50576328c70300cec1c6 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 26 Jul 2020 10:50:11 -0700 Subject: [PATCH 11/28] Fixed: Don't create empty artist folder if delete empty folders is enabled Fixes Sonarr/Sonarr#3838 (cherry picked from commit 4f15cd55be5f7332ebde608d8b438384865e9dc1) --- .../MediaFiles/DiskScanService.cs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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); From bed907e720843a506f4cd0742962baf20096b2ea Mon Sep 17 00:00:00 2001 From: JeWe37 Date: Tue, 23 May 2023 05:36:17 +0200 Subject: [PATCH 12/28] New: Option to Import via Script Closes Sonarr/Sonarr#791 (cherry picked from commit 9f1e2151206a077334a9c34a12a373b465752d87) --- .../MediaManagement/MediaManagement.js | 59 +++++++-- .../Config/MediaManagementConfigController.cs | 1 + .../Config/MediaManagementConfigResource.cs | 6 + src/Lidarr.Api.V1/openapi.json | 6 + .../Configuration/ConfigService.cs | 21 +++ .../Configuration/IConfigService.cs | 3 + src/NzbDrone.Core/Localization/Core/en.json | 4 + .../MediaFiles/ScriptImportDecider.cs | 125 ++++++++++++++++++ .../MediaFiles/ScriptImportDecision.cs | 10 ++ .../MediaFiles/ScriptImportException.cs | 23 ++++ .../MediaFiles/TrackFileMovingService.cs | 38 +++++- 11 files changed, 278 insertions(+), 18 deletions(-) create mode 100644 src/NzbDrone.Core/MediaFiles/ScriptImportDecider.cs create mode 100644 src/NzbDrone.Core/MediaFiles/ScriptImportDecision.cs create mode 100644 src/NzbDrone.Core/MediaFiles/ScriptImportException.cs 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 ?
{ - advancedSettings && + advancedSettings ?
-
+ : null } { - advancedSettings && + advancedSettings ?
@@ -245,6 +245,41 @@ class MediaManagement extends Component { /> + + {translate('ImportUsingScript')} + + + + + { + settings.useScriptImport.value ? + + {translate('ImportScriptPath')} + + + : null + } + {translate('ImportExtraFiles')} @@ -279,7 +314,7 @@ class MediaManagement extends Component { /> : null } -
+ : null }
{ - advancedSettings && !isWindows && + advancedSettings && !isWindows ?
@@ -483,9 +518,9 @@ class MediaManagement extends Component { {...settings.chownGroup} /> -
+
: null } -
+ : 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..3d3f3abec 100644 --- a/src/Lidarr.Api.V1/openapi.json +++ b/src/Lidarr.Api.V1/openapi.json @@ -10870,6 +10870,12 @@ "copyUsingHardlinks": { "type": "boolean" }, + "useScriptImport": { + "type": "boolean" + }, + "scriptImportPath": { + "type": "string" + }, "importExtraFiles": { "type": "boolean" }, 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/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/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; From a709b7978a1a6cd46d5ef5d8b225e08e8a9586e5 Mon Sep 17 00:00:00 2001 From: Meyn Date: Mon, 1 Sep 2025 23:18:31 +0200 Subject: [PATCH 13/28] Implement ImportScript tests --- .../MediaFiles/ImportScriptServiceFixture.cs | 424 ++++++++++++++++++ 1 file changed, 424 insertions(+) create mode 100644 src/NzbDrone.Core.Test/MediaFiles/ImportScriptServiceFixture.cs 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(); + } + } +} From b892d1e9ea667c4d59913453ed9d10d47d243338 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 20 Jul 2025 11:29:37 +0300 Subject: [PATCH 14/28] Fixed: Display unknown for missing release dates on album details page --- frontend/src/Album/Details/AlbumDetails.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index fe007e168..884c0bc56 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -427,7 +427,10 @@ class AlbumDetails extends Component { size={17} /> - {moment(releaseDate).format(shortDateFormat)} + {releaseDate ? + moment(releaseDate).format(shortDateFormat) : + translate('Unknown') + } From 4dd5411461adc9e5d3eac51b057fd2335378b42e Mon Sep 17 00:00:00 2001 From: Bogdan Date: Sun, 20 Jul 2025 11:54:05 +0300 Subject: [PATCH 15/28] Fixed: Status color for unreleased albums --- frontend/src/Album/Details/AlbumDetails.js | 1 + frontend/src/Album/Details/AlbumDetailsMedium.js | 11 +++++++++-- .../src/Album/Details/AlbumDetailsMediumConnector.js | 1 + frontend/src/Artist/Details/AlbumRow.js | 9 +++++++-- .../src/InteractiveImport/Album/SelectAlbumRow.js | 9 +++++++-- 5 files changed, 25 insertions(+), 6 deletions(-) diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js index 884c0bc56..f04d13174 100644 --- a/frontend/src/Album/Details/AlbumDetails.js +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -595,6 +595,7 @@ class AlbumDetails extends Component { key={medium.mediumNumber} albumId={id} albumMonitored={monitored} + albumReleaseDate={releaseDate} {...medium} isExpanded={expandedState[medium.mediumNumber]} onExpandPress={this.onExpandPress} diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.js b/frontend/src/Album/Details/AlbumDetailsMedium.js index 9e80e2c7a..0ebcc1561 100644 --- a/frontend/src/Album/Details/AlbumDetailsMedium.js +++ b/frontend/src/Album/Details/AlbumDetailsMedium.js @@ -7,6 +7,7 @@ import Link from 'Components/Link/Link'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { icons, kinds, sizes } from 'Helpers/Props'; +import isAfter from 'Utilities/Date/isAfter'; import translate from 'Utilities/String/translate'; import TrackRowConnector from './TrackRowConnector'; import styles from './AlbumDetailsMedium.css'; @@ -31,11 +32,15 @@ function getMediumStatistics(tracks) { }; } -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; } @@ -90,6 +95,7 @@ class AlbumDetailsMedium extends Component { mediumNumber, mediumFormat, albumMonitored, + albumReleaseDate, items, columns, onTableOptionChange, @@ -119,7 +125,7 @@ class AlbumDetailsMedium extends Component {