diff --git a/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs b/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs index 68cf76cf..6a767ac4 100644 --- a/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs +++ b/src/Chaptarr.Core.Test/MediaFiles/UpgradeMediaFileServiceFixture.cs @@ -9,6 +9,8 @@ using NzbDrone.Core.Books.Calibre; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; using NzbDrone.Core.RootFolders; namespace Chaptarr.Core.Test.MediaFiles @@ -146,7 +148,8 @@ public void should_not_delete_a_loose_path_match_while_replacing_its_stale_row() DispatchProxy.Create(), DispatchProxy.Create(), DispatchProxy.Create>(), - LogManager.GetCurrentClassLogger()); + LogManager.GetCurrentClassLogger(), + null); Assert.DoesNotThrow(() => subject.UpgradeBookFile(replacement, localBook)); @@ -157,5 +160,125 @@ public void should_not_delete_a_loose_path_match_while_replacing_its_stale_row() Assert.That(mover.Moved, Is.True); }); } + + private class QualityProfileServiceProxy : DispatchProxy + { + public static QualityProfile Profile { get; set; } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + if (targetMethod?.Name == "Get") + { + return Profile; + } + + throw new NotImplementedException($"Test proxy does not implement IQualityProfileService.{targetMethod?.Name}"); + } + } + + private static QualityProfile AudiobookProfile() + { + return new QualityProfile + { + Id = 7, + Name = "Audiobook", + UpgradeAllowed = true, + Cutoff = Quality.M4B.Id, + Items = new List + { + new QualityProfileQualityItem { Quality = Quality.MP3, Allowed = true }, + new QualityProfileQualityItem { Quality = Quality.M4B, Allowed = true } + } + }; + } + + private static (UpgradeMediaFileService subject, RecordingRecycleBinProvider bin, MediaFileServiceProxy media, StubBookFileMover mover) BuildGuardedSubject() + { + QualityProfileServiceProxy.Profile = AudiobookProfile(); + var recycleBin = new RecordingRecycleBinProvider(); + var mediaFileService = DispatchProxy.Create(); + var mediaProxy = (MediaFileServiceProxy)(object)mediaFileService; + var mover = new StubBookFileMover(); + var subject = new UpgradeMediaFileService( + recycleBin, + mediaFileService, + DispatchProxy.Create>(), + mover, + DispatchProxy.Create(), + DispatchProxy.Create(), + DispatchProxy.Create>(), + LogManager.GetCurrentClassLogger(), + DispatchProxy.Create()); + return (subject, recycleBin, mediaProxy, mover); + } + + private static LocalBook BuildLocalBook(BookFile existing, string incomingPath) + { + var author = new Author { Id = 1, Path = "/books/Author", AudiobookQualityProfileId = 7 }; + var book = new Book + { + Id = 2, + Author = author, + MediaType = BookMediaType.Audiobook, + BookFiles = new List { existing } + }; + return new LocalBook { Author = author, Book = book, Path = incomingPath }; + } + + [Test] + public void should_refuse_to_delete_existing_file_that_outranks_the_incoming_file() + { + var existingM4b = new BookFile + { + Id = 1, + Path = "/books/Author/Book (2003)/Book.m4b", + Quality = new QualityModel(Quality.M4B) + }; + var incomingMp3 = new BookFile + { + Id = 2, + Path = "/downloads/Book.mp3", + Quality = new QualityModel(Quality.MP3) + }; + var localBook = BuildLocalBook(existingM4b, incomingMp3.Path); + var (subject, bin, media, mover) = BuildGuardedSubject(); + + Assert.Throws(() => subject.UpgradeBookFile(incomingMp3, localBook)); + + Assert.Multiple(() => + { + Assert.That(bin.DeletedFiles, Is.Empty); + Assert.That(media.Deleted, Is.Empty); + Assert.That(mover.Moved, Is.False); + }); + } + + [Test] + public void should_still_replace_existing_file_when_incoming_file_is_an_upgrade() + { + var existingMp3 = new BookFile + { + Id = 1, + Path = "/books/Author/Book (2003)/Book.mp3", + Quality = new QualityModel(Quality.MP3) + }; + var incomingM4b = new BookFile + { + Id = 2, + Path = "/downloads/Book.m4b", + Quality = new QualityModel(Quality.M4B) + }; + var localBook = BuildLocalBook(existingMp3, incomingM4b.Path); + var (subject, bin, media, mover) = BuildGuardedSubject(); + + Assert.DoesNotThrow(() => subject.UpgradeBookFile(incomingM4b, localBook)); + + Assert.Multiple(() => + { + Assert.That(media.Deleted, Is.EqualTo(new[] { existingMp3 })); + Assert.That(mover.Moved, Is.True); + }); + } + } } diff --git a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs index 277753f9..4fa50978 100644 --- a/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/UpgradeMediaFileService.cs @@ -7,6 +7,8 @@ using NzbDrone.Core.Books.Calibre; using NzbDrone.Core.MediaFiles.BookImport; using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Qualities; using NzbDrone.Core.RootFolders; namespace NzbDrone.Core.MediaFiles @@ -26,6 +28,7 @@ public class UpgradeMediaFileService : IUpgradeMediaFiles private readonly IRootFolderService _rootFolderService; private readonly ICalibreProxy _calibre; private readonly Logger _logger; + private readonly IQualityProfileService _qualityProfileService; public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, IMediaFileService mediaFileService, @@ -34,7 +37,8 @@ public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, IDiskProvider diskProvider, IRootFolderService rootFolderService, ICalibreProxy calibre, - Logger logger) + Logger logger, + IQualityProfileService qualityProfileService) { _recycleBinProvider = recycleBinProvider; _mediaFileService = mediaFileService; @@ -44,6 +48,7 @@ public UpgradeMediaFileService(IRecycleBinProvider recycleBinProvider, _rootFolderService = rootFolderService; _calibre = calibre; _logger = logger; + _qualityProfileService = qualityProfileService; } public BookFileMoveResult UpgradeBookFile(BookFile bookFile, LocalBook localBook, bool copyOnly = false) @@ -53,6 +58,28 @@ public BookFileMoveResult UpgradeBookFile(BookFile bookFile, LocalBook localBook // Ensure BookFiles collection is loaded var existingFiles = localBook.Book.BookFiles ?? new List(); + // Last-line quality guard: an approved import (e.g. an edition + // switch) must never destroy files that outrank the incoming one + // in the author's profile. Decision specs compare within an + // edition; this is the only place that sees the actual deletion. + var guardProfileId = localBook.Book?.MediaType == Books.BookMediaType.Ebook + ? localBook.Author?.EbookQualityProfileId + : localBook.Author?.AudiobookQualityProfileId; + if (guardProfileId > 0 && bookFile.Quality != null && _qualityProfileService != null) + { + var guardProfile = _qualityProfileService.Get(guardProfileId.Value); + if (guardProfile != null) + { + var comparer = new QualityModelComparer(guardProfile); + var betterExisting = existingFiles.FirstOrDefault(f => f.Quality != null && comparer.Compare(f.Quality, bookFile.Quality) > 0); + if (betterExisting != null) + { + throw new System.InvalidOperationException( + $"Refusing to replace '{betterExisting.Path}' ({betterExisting.Quality}) with lower-ranked '{localBook.Path}' ({bookFile.Quality})"); + } + } + } + // Handle cases where author path might not be set (e.g., for downloads) string rootFolderPath; if (localBook.Author != null && !string.IsNullOrWhiteSpace(localBook.Author.Path))