From 5c2378a1e6477490664eda96aa433d3a84f50ac9 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 18 Dec 2025 11:09:51 -0600 Subject: [PATCH 1/3] feat(download): add automatic archive extraction (Unpackerr absorption) - Add SharpCompress for RAR/7z support - Extend ArchiveService with RAR, 7z extraction via SharpCompress - Add DownloadExtractionService for orchestrating extraction - Add config: AutoExtractArchives (default: false) - Add config: DeleteArchiveAfterExtraction (default: true) - Integrate extraction into CompletedDownloadService Note: UI settings page not yet implemented - backend foundation only. --- src/NzbDrone.Common/ArchiveService.cs | 79 ++++++- src/NzbDrone.Common/Radarr.Common.csproj | 1 + .../Configuration/ConfigService.cs | 14 ++ .../Configuration/IConfigService.cs | 4 + .../Download/CompletedDownloadService.cs | 14 ++ .../Download/DownloadExtractionService.cs | 195 ++++++++++++++++++ 6 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 src/NzbDrone.Core/Download/DownloadExtractionService.cs diff --git a/src/NzbDrone.Common/ArchiveService.cs b/src/NzbDrone.Common/ArchiveService.cs index 0b2c068e98..dde47bc71f 100644 --- a/src/NzbDrone.Common/ArchiveService.cs +++ b/src/NzbDrone.Common/ArchiveService.cs @@ -6,6 +6,8 @@ using ICSharpCode.SharpZipLib.Tar; using ICSharpCode.SharpZipLib.Zip; using NLog; +using SharpCompress.Archives; +using SharpCompress.Common; namespace NzbDrone.Common { @@ -13,33 +15,106 @@ public interface IArchiveService { void Extract(string compressedFile, string destination); void CreateZip(string path, IEnumerable files); + bool IsArchive(string path); + bool CanExtract(string path); } public class ArchiveService : IArchiveService { private readonly Logger _logger; + private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".tar.gz", ".bz2", ".tar.bz2" + }; + public ArchiveService(Logger logger) { _logger = logger; } + public bool IsArchive(string path) + { + var extension = Path.GetExtension(path); + return SupportedExtensions.Contains(extension) || + path.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".tar.bz2", StringComparison.OrdinalIgnoreCase); + } + + public bool CanExtract(string path) + { + if (!File.Exists(path)) + { + return false; + } + + return IsArchive(path); + } + public void Extract(string compressedFile, string destination) { _logger.Debug("Extracting archive [{0}] to [{1}]", compressedFile, destination); - if (compressedFile.EndsWith(".zip", StringComparison.InvariantCultureIgnoreCase)) + var extension = Path.GetExtension(compressedFile).ToLowerInvariant(); + + if (extension == ".zip") { ExtractZip(compressedFile, destination); } - else + else if (extension == ".rar" || extension == ".7z" || extension == ".r00") + { + ExtractWithSharpCompress(compressedFile, destination); + } + else if (compressedFile.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase) || + compressedFile.EndsWith(".tgz", StringComparison.OrdinalIgnoreCase) || + extension == ".tar" || extension == ".gz") { ExtractTgz(compressedFile, destination); } + else + { + ExtractWithSharpCompress(compressedFile, destination); + } _logger.Debug("Extraction complete."); } + private void ExtractWithSharpCompress(string compressedFile, string destination) + { + var destinationFullPath = Path.GetFullPath(destination); + Directory.CreateDirectory(destination); + + using var archive = ArchiveFactory.Open(compressedFile); + foreach (var entry in archive.Entries) + { + if (entry.IsDirectory) + { + continue; + } + + var fullPath = Path.GetFullPath(Path.Combine(destination, entry.Key)); + + if (!fullPath.StartsWith(destinationFullPath + Path.DirectorySeparatorChar) && + !fullPath.Equals(destinationFullPath, StringComparison.Ordinal)) + { + _logger.Warn("Skipping archive entry with path traversal attempt: {0}", entry.Key); + continue; + } + + var directoryName = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directoryName)) + { + Directory.CreateDirectory(directoryName); + } + + entry.WriteToFile(fullPath, new ExtractionOptions + { + ExtractFullPath = false, + Overwrite = true + }); + } + } + public void CreateZip(string path, IEnumerable files) { _logger.Debug("Creating archive {0}", path); diff --git a/src/NzbDrone.Common/Radarr.Common.csproj b/src/NzbDrone.Common/Radarr.Common.csproj index 4d9575fd97..8a25ce5619 100644 --- a/src/NzbDrone.Common/Radarr.Common.csproj +++ b/src/NzbDrone.Common/Radarr.Common.csproj @@ -15,6 +15,7 @@ + diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 361d997eb0..236caee6ac 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -162,6 +162,20 @@ public bool EnableCompletedDownloadHandling set { SetValue("EnableCompletedDownloadHandling", value); } } + public bool AutoExtractArchives + { + get { return GetValueBoolean("AutoExtractArchives", false); } + + set { SetValue("AutoExtractArchives", value); } + } + + public bool DeleteArchiveAfterExtraction + { + get { return GetValueBoolean("DeleteArchiveAfterExtraction", true); } + + set { SetValue("DeleteArchiveAfterExtraction", value); } + } + public bool PreferIndexerFlags { get { return GetValueBoolean("PreferIndexerFlags", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index f425f540e7..4a1aa8f6f1 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -24,6 +24,10 @@ public interface IConfigService bool AutoRedownloadFailed { get; set; } bool AutoRedownloadFailedFromInteractiveSearch { get; set; } + // Archive Extraction + bool AutoExtractArchives { get; set; } + bool DeleteArchiveAfterExtraction { get; set; } + // Media Management bool AutoUnmonitorPreviouslyDownloadedMovies { get; set; } string RecycleBin { get; set; } diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index e9b3feb667..76507ee272 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -30,6 +30,7 @@ public class CompletedDownloadService : ICompletedDownloadService private readonly IHistoryService _historyService; private readonly IProvideImportItemService _provideImportItemService; private readonly IDownloadedMovieImportService _downloadedMovieImportService; + private readonly IDownloadExtractionService _downloadExtractionService; private readonly IParsingService _parsingService; private readonly IMovieService _movieService; private readonly ITrackedDownloadAlreadyImported _trackedDownloadAlreadyImported; @@ -40,6 +41,7 @@ public CompletedDownloadService(IEventAggregator eventAggregator, IHistoryService historyService, IProvideImportItemService provideImportItemService, IDownloadedMovieImportService downloadedMovieImportService, + IDownloadExtractionService downloadExtractionService, IParsingService parsingService, IMovieService movieService, ITrackedDownloadAlreadyImported trackedDownloadAlreadyImported, @@ -50,6 +52,7 @@ public CompletedDownloadService(IEventAggregator eventAggregator, _historyService = historyService; _provideImportItemService = provideImportItemService; _downloadedMovieImportService = downloadedMovieImportService; + _downloadExtractionService = downloadExtractionService; _parsingService = parsingService; _movieService = movieService; _trackedDownloadAlreadyImported = trackedDownloadAlreadyImported; @@ -66,6 +69,17 @@ public void Check(TrackedDownload trackedDownload) SetImportItem(trackedDownload); + // Extract archives if needed before import + var outputPath = trackedDownload.DownloadItem.OutputPath.FullPath; + if (_downloadExtractionService.ShouldExtract(outputPath)) + { + var extractedPath = _downloadExtractionService.ExtractIfNeeded(outputPath); + if (extractedPath != outputPath) + { + _logger.Info("Archives extracted, updating import path to: {0}", extractedPath); + } + } + // Only process tracked downloads that are still downloading or have been blocked for importing due to an issue with matching if (trackedDownload.State != TrackedDownloadState.Downloading && trackedDownload.State != TrackedDownloadState.ImportBlocked) { diff --git a/src/NzbDrone.Core/Download/DownloadExtractionService.cs b/src/NzbDrone.Core/Download/DownloadExtractionService.cs new file mode 100644 index 0000000000..54970da4ed --- /dev/null +++ b/src/NzbDrone.Core/Download/DownloadExtractionService.cs @@ -0,0 +1,195 @@ +using System; +using System.IO; +using System.Linq; +using NLog; +using NzbDrone.Common; +using NzbDrone.Common.Disk; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles; + +namespace NzbDrone.Core.Download +{ + public interface IDownloadExtractionService + { + string ExtractIfNeeded(string downloadPath); + bool ShouldExtract(string downloadPath); + } + + public class DownloadExtractionService : IDownloadExtractionService + { + private readonly IArchiveService _archiveService; + private readonly IDiskProvider _diskProvider; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public DownloadExtractionService( + IArchiveService archiveService, + IDiskProvider diskProvider, + IConfigService configService, + Logger logger) + { + _archiveService = archiveService; + _diskProvider = diskProvider; + _configService = configService; + _logger = logger; + } + + public bool ShouldExtract(string downloadPath) + { + if (!_configService.AutoExtractArchives) + { + return false; + } + + if (!_diskProvider.FolderExists(downloadPath) && !_diskProvider.FileExists(downloadPath)) + { + return false; + } + + if (_diskProvider.FileExists(downloadPath)) + { + return _archiveService.IsArchive(downloadPath); + } + + var files = _diskProvider.GetFiles(downloadPath, true); + return files.Any(f => _archiveService.IsArchive(f)); + } + + public string ExtractIfNeeded(string downloadPath) + { + if (!ShouldExtract(downloadPath)) + { + return downloadPath; + } + + try + { + if (_diskProvider.FileExists(downloadPath)) + { + return ExtractSingleArchive(downloadPath); + } + + return ExtractArchivesInFolder(downloadPath); + } + catch (Exception ex) + { + _logger.Error(ex, "Failed to extract archives in {0}", downloadPath); + return downloadPath; + } + } + + private string ExtractSingleArchive(string archivePath) + { + var extractionPath = Path.Combine( + Path.GetDirectoryName(archivePath), + Path.GetFileNameWithoutExtension(archivePath)); + + _logger.Info("Extracting {0} to {1}", archivePath, extractionPath); + + _archiveService.Extract(archivePath, extractionPath); + + if (_configService.DeleteArchiveAfterExtraction) + { + _logger.Debug("Deleting archive after extraction: {0}", archivePath); + _diskProvider.DeleteFile(archivePath); + } + + return extractionPath; + } + + private string ExtractArchivesInFolder(string folderPath) + { + var files = _diskProvider.GetFiles(folderPath, true); + var archiveFiles = files + .Where(f => _archiveService.IsArchive(f)) + .Where(f => !IsPartOfMultiVolumeArchive(f)) + .ToList(); + + if (!archiveFiles.Any()) + { + return folderPath; + } + + foreach (var archiveFile in archiveFiles) + { + var extractionPath = Path.GetDirectoryName(archiveFile); + _logger.Info("Extracting {0}", archiveFile); + + try + { + _archiveService.Extract(archiveFile, extractionPath); + + if (_configService.DeleteArchiveAfterExtraction) + { + DeleteArchiveWithParts(archiveFile); + } + } + catch (Exception ex) + { + _logger.Warn(ex, "Failed to extract {0}", archiveFile); + } + } + + return folderPath; + } + + private bool IsPartOfMultiVolumeArchive(string path) + { + var extension = Path.GetExtension(path); + + if (extension.StartsWith(".r", StringComparison.OrdinalIgnoreCase) && + extension.Length == 4 && + int.TryParse(extension.Substring(2), out _)) + { + return true; + } + + if (path.Contains(".part") && extension.Equals(".rar", StringComparison.OrdinalIgnoreCase)) + { + var filename = Path.GetFileNameWithoutExtension(path); + return filename.EndsWith(".part1", StringComparison.OrdinalIgnoreCase) == false; + } + + return false; + } + + private void DeleteArchiveWithParts(string archivePath) + { + var directory = Path.GetDirectoryName(archivePath); + var baseName = Path.GetFileNameWithoutExtension(archivePath); + + var partFiles = _diskProvider.GetFiles(directory, false) + .Where(f => IsArchivePartFile(f, baseName)) + .ToList(); + + foreach (var partFile in partFiles) + { + _logger.Debug("Deleting archive part: {0}", partFile); + _diskProvider.DeleteFile(partFile); + } + + _diskProvider.DeleteFile(archivePath); + } + + private bool IsArchivePartFile(string filePath, string baseName) + { + var fileName = Path.GetFileNameWithoutExtension(filePath); + var extension = Path.GetExtension(filePath); + + if (fileName.Equals(baseName, StringComparison.OrdinalIgnoreCase) && + extension.StartsWith(".r", StringComparison.OrdinalIgnoreCase) && + extension.Length == 4) + { + return true; + } + + if (fileName.StartsWith(baseName + ".part", StringComparison.OrdinalIgnoreCase) && + extension.Equals(".rar", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + } +} From 79481d5491b42a3bfb6d27cc52dfd11ebfc2597b Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 18 Dec 2025 11:22:59 -0600 Subject: [PATCH 2/3] fix(style): use explicit HashSet type for StyleCop SA1000 --- src/NzbDrone.Common/ArchiveService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NzbDrone.Common/ArchiveService.cs b/src/NzbDrone.Common/ArchiveService.cs index dde47bc71f..b48605f49f 100644 --- a/src/NzbDrone.Common/ArchiveService.cs +++ b/src/NzbDrone.Common/ArchiveService.cs @@ -23,7 +23,7 @@ public class ArchiveService : IArchiveService { private readonly Logger _logger; - private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet SupportedExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) { ".zip", ".rar", ".7z", ".tar", ".gz", ".tgz", ".tar.gz", ".bz2", ".tar.bz2" }; From e43ea2682c2295ce9b4335357223ed9f2b857f2e Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 18 Dec 2025 11:38:55 -0600 Subject: [PATCH 3/3] fix(style): remove unused using, use AsSpan over Substring --- src/NzbDrone.Core/Download/DownloadExtractionService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/NzbDrone.Core/Download/DownloadExtractionService.cs b/src/NzbDrone.Core/Download/DownloadExtractionService.cs index 54970da4ed..26b8aa09ab 100644 --- a/src/NzbDrone.Core/Download/DownloadExtractionService.cs +++ b/src/NzbDrone.Core/Download/DownloadExtractionService.cs @@ -5,7 +5,6 @@ using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Core.Configuration; -using NzbDrone.Core.MediaFiles; namespace NzbDrone.Core.Download { @@ -139,7 +138,7 @@ private bool IsPartOfMultiVolumeArchive(string path) if (extension.StartsWith(".r", StringComparison.OrdinalIgnoreCase) && extension.Length == 4 && - int.TryParse(extension.Substring(2), out _)) + int.TryParse(extension.AsSpan(2), out _)) { return true; }