From 724e61fd6d8bdf0e9f1932d287489e36ff5bb72d Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Fri, 28 Aug 2026 13:07:15 -0400 Subject: [PATCH 1/6] fix: reject weak Linux storage identities --- .../Features/Library/RootFoldersController.cs | 8 + .../DirectoryObjectIdentityResolver.cs | 59 ++++-- .../PinnedDirectoryCreation.Hierarchy.cs | 8 + ...rectoryCreation.LinuxIdentityCandidates.cs | 181 +++++++++++++++++- ...edDirectoryCreation.LinuxObjectIdentity.cs | 71 +++++-- .../RootFolderStorageHealthResolver.cs | 12 ++ .../Scanning/ScanPathAuthorizationService.cs | 19 +- tests/Common/PlatformFactAttributes.cs | 58 ++++++ .../Library/RootFoldersControllerTests.cs | 35 ++++ .../DirectoryObjectIdentityResolverTests.cs | 126 ++++++++++++ .../DockerStorageCapabilityContractTests.cs | 148 ++++++++++++++ .../RootFolderStorageHealthResolverTests.cs | 38 ++++ .../ScanPathAuthorizationServiceTests.cs | 57 ++++++ 13 files changed, 779 insertions(+), 41 deletions(-) diff --git a/listenarr.api/Features/Library/RootFoldersController.cs b/listenarr.api/Features/Library/RootFoldersController.cs index e1a345895..20063d2e3 100644 --- a/listenarr.api/Features/Library/RootFoldersController.cs +++ b/listenarr.api/Features/Library/RootFoldersController.cs @@ -338,6 +338,14 @@ public async Task ConfirmCurrentFolder( message = "The root folder confirmation request is invalid." }); } + catch (PlatformNotSupportedException) + { + return Conflict(new + { + message = "The current storage does not expose the durable physical identity required to confirm this folder for filesystem mutation.", + code = "root_folder_identity_unsupported" + }); + } catch (InvalidOperationException) { return Conflict(new diff --git a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs index 8fb40b1d6..ee756e0c7 100644 --- a/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs +++ b/listenarr.infrastructure/FileSystem/DirectoryObjectIdentityResolver.cs @@ -7,13 +7,20 @@ internal sealed class DirectoryObjectIdentityResolver( Func? nativeIdentityResolver = null, Func>? - nativeIdentityCandidatesResolver = null) : IDirectoryObjectIdentityResolver + nativeIdentityCandidatesResolver = null, + Func>? + legacyWeakIdentityCandidatesResolver = null) : IDirectoryObjectIdentityResolver { private readonly Func> _nativeIdentityCandidatesResolver = nativeIdentityCandidatesResolver ?? (nativeIdentityResolver == null ? static anchor => anchor.GetDirectoryObjectIdentityCandidates() : anchor => [nativeIdentityResolver(anchor)]); + private readonly Func> + _legacyWeakIdentityCandidatesResolver = legacyWeakIdentityCandidatesResolver + ?? (nativeIdentityResolver == null && nativeIdentityCandidatesResolver == null + ? static anchor => anchor.GetLegacyWeakDirectoryObjectIdentityCandidates() + : static _ => Array.Empty()); public Task ResolveAsync( string path, @@ -21,10 +28,15 @@ public Task ResolveAsync( ResolvePinnedAsync( path, cancellationToken, - nativeIdentities => new DirectoryObjectIdentityResolution( - ManagedDirectoryIdentity.CurrentVersion, - ManagedDirectoryIdentity.CreateMarkerless(nativeIdentities[0]), - null)); + anchor => + { + var nativeIdentities = _nativeIdentityCandidatesResolver(anchor); + EnsureDurableCandidateAvailable(nativeIdentities); + return new DirectoryObjectIdentityResolution( + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless(nativeIdentities[0]), + null); + }); public Task ResolveExistingAsync( string path, @@ -44,8 +56,22 @@ public Task ResolveExistingAsync( return ResolvePinnedAsync( path, cancellationToken, - nativeIdentities => + anchor => { + var legacyWeakIdentities = _legacyWeakIdentityCandidatesResolver(anchor); + if (legacyWeakIdentities.Any(nativeIdentity => + ManagedDirectoryIdentity.MatchesNativeIdentity( + expectedVersion, + expectedValue, + nativeIdentity))) + { + return DirectoryObjectIdentityResolution.Unavailable( + "The persisted Linux directory identity uses the generic FILEID_INO64_GEN handle, which does not prove a durable filesystem generation.", + DirectoryObjectIdentityFailureKind.LegacyWeakIdentity); + } + + var nativeIdentities = _nativeIdentityCandidatesResolver(anchor); + EnsureDurableCandidateAvailable(nativeIdentities); if (nativeIdentities.Any(nativeIdentity => ManagedDirectoryIdentity.MatchesNativeIdentity( expectedVersion, @@ -77,7 +103,7 @@ public Task ResolveExistingAsync( private Task ResolvePinnedAsync( string path, CancellationToken cancellationToken, - Func, DirectoryObjectIdentityResolution> resolve) + Func resolve) { cancellationToken.ThrowIfCancellationRequested(); ArgumentException.ThrowIfNullOrWhiteSpace(path); @@ -97,12 +123,7 @@ private Task ResolvePinnedAsync( try { using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(canonicalPath); - var nativeIdentities = _nativeIdentityCandidatesResolver(anchor); - if (nativeIdentities.Count == 0) - { - throw new PlatformNotSupportedException( - "The filesystem did not expose a durable directory identity candidate."); - } + var resolution = resolve(anchor); if (!anchor.VisiblePathMatches()) { return Task.FromResult( @@ -111,7 +132,7 @@ private Task ResolvePinnedAsync( DirectoryObjectIdentityFailureKind.IdentityUnstable)); } - return Task.FromResult(resolve(nativeIdentities)); + return Task.FromResult(resolution); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception @@ -124,6 +145,16 @@ IOException or UnauthorizedAccessException or Win32Exception } } + private static void EnsureDurableCandidateAvailable( + IReadOnlyList nativeIdentities) + { + if (nativeIdentities.Count == 0) + { + throw new PlatformNotSupportedException( + "The filesystem did not expose a durable directory identity candidate."); + } + } + private static bool MatchesLegacyLinuxBirthTimeIdentity( int expectedVersion, string expectedValue, diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs index a91815c8e..226f105d1 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.Hierarchy.cs @@ -155,6 +155,14 @@ internal IReadOnlyList GetDirectoryObjectIdentityCandidates() : [PinnedDirectoryCreation.GetDirectoryObjectIdentity(_handle)]; } + internal IReadOnlyList GetLegacyWeakDirectoryObjectIdentityCandidates() + { + ThrowIfDisposed(); + return OperatingSystem.IsLinux() + ? PinnedDirectoryCreation.GetLinuxLegacyWeakObjectIdentityCandidates(_handle) + : Array.Empty(); + } + internal bool MatchesManagedDirectoryIdentity( int? expectedVersion, string? expectedValue) diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs index 1682a8338..1cb430484 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs @@ -8,6 +8,34 @@ internal sealed partial class PinnedDirectoryCreation { private static IReadOnlyList GetLinuxObjectIdentityCandidates( SafeFileHandle handle) + { + var evidence = ReadLinuxObjectIdentityEvidence(handle); + return CreateLinuxObjectIdentityCandidatesFromEvidence( + evidence.DeviceMajor, + evidence.DeviceMinor, + evidence.Inode, + evidence.HasBirthTime, + evidence.BirthTimeSeconds, + evidence.BirthTimeNanoseconds, + evidence.GenerationIdentities); + } + + private static IReadOnlyList GetLinuxLegacyWeakObjectIdentityCandidates( + SafeFileHandle handle) + { + var evidence = ReadLinuxObjectIdentityEvidence(handle); + return CreateLinuxLegacyWeakObjectIdentityCandidatesFromEvidence( + evidence.DeviceMajor, + evidence.DeviceMinor, + evidence.Inode, + evidence.HasBirthTime, + evidence.BirthTimeSeconds, + evidence.BirthTimeNanoseconds, + evidence.GenerationIdentities); + } + + private static LinuxObjectIdentityEvidence ReadLinuxObjectIdentityEvidence( + SafeFileHandle handle) { const uint statxInode = 0x00000100; const uint statxBirthTime = 0x00000800; @@ -27,17 +55,25 @@ private static IReadOnlyList GetLinuxObjectIdentityCandidates( "The filesystem does not expose an inode for durable object identity."); } - var generationIdentities = GetLinuxGenerationIdentityCandidates(handle); - return CreateLinuxObjectIdentityCandidatesFromEvidence( + return new LinuxObjectIdentityEvidence( information.DeviceMajor, information.DeviceMinor, information.Inode, - hasBirthTime: (information.Mask & statxBirthTime) != 0, + (information.Mask & statxBirthTime) != 0, information.BirthTime.Seconds, information.BirthTime.Nanoseconds, - generationIdentities); + GetLinuxGenerationIdentityCandidates(handle)); } + private sealed record LinuxObjectIdentityEvidence( + uint DeviceMajor, + uint DeviceMinor, + ulong Inode, + bool HasBirthTime, + long BirthTimeSeconds, + uint BirthTimeNanoseconds, + IReadOnlyList GenerationIdentities); + internal static string CreateLinuxObjectIdentityFromEvidence( uint deviceMajor, uint deviceMinor, @@ -68,7 +104,7 @@ internal static IReadOnlyList CreateLinuxObjectIdentityCandidatesFromEvi { ArgumentNullException.ThrowIfNull(generationIdentities); var strongGenerations = generationIdentities - .Where(candidate => !string.IsNullOrWhiteSpace(candidate)) + .Where(IsDurableLinuxGenerationIdentity) .Distinct(StringComparer.Ordinal) .ToArray(); if (strongGenerations.Length == 0) @@ -114,6 +150,11 @@ internal static bool ArePersistedObjectIdentitiesDurablyEquivalent( { ArgumentException.ThrowIfNullOrWhiteSpace(left); ArgumentException.ThrowIfNullOrWhiteSpace(right); + if (IsLegacyWeakLinuxObjectIdentity(left) + || IsLegacyWeakLinuxObjectIdentity(right)) + { + return false; + } if (string.Equals(left, right, StringComparison.Ordinal)) { return true; @@ -203,6 +244,108 @@ private static bool TryGetLinuxStrongGenerationKey( return false; } + internal static IReadOnlyList CreateLinuxLegacyWeakObjectIdentityCandidatesFromEvidence( + uint deviceMajor, + uint deviceMinor, + ulong inode, + bool hasBirthTime, + long birthTimeSeconds, + uint birthTimeNanoseconds, + IReadOnlyList generationIdentities) + { + ArgumentNullException.ThrowIfNull(generationIdentities); + var weakGenerations = generationIdentities + .Where(IsLegacyWeakLinuxGenerationIdentity) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (weakGenerations.Length == 0) + { + return Array.Empty(); + } + + var candidates = new List(weakGenerations.Length * 2); + foreach (var generationIdentity in weakGenerations) + { + candidates.Add(FormattableString.Invariant( + $"linux-generation:{deviceMajor:x8}:{deviceMinor:x8}:{inode:x16}:{generationIdentity}")); + } + + if (hasBirthTime) + { + var legacyIdentity = FormattableString.Invariant( + $"linux:{deviceMajor:x8}:{deviceMinor:x8}:{inode:x16}:{birthTimeSeconds:x16}:{birthTimeNanoseconds:x8}"); + foreach (var generationIdentity in weakGenerations) + { + candidates.Add($"{legacyIdentity}:{generationIdentity}"); + } + } + + return candidates.ToArray(); + } + + private static bool IsDurableLinuxGenerationIdentity(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + var parts = candidate.Split(':'); + return parts[0] switch + { + "gen" => parts.Length == 2 && IsFixedHex(parts[1], 8), + "fh" => TryValidateLinuxFileHandle(parts, 0, requireDurable: true), + _ => false + }; + } + + private static bool IsLegacyWeakLinuxGenerationIdentity(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + var parts = candidate.Split(':'); + return parts.Length == 3 + && string.Equals(parts[0], "fh", StringComparison.Ordinal) + && TryValidateLinuxFileHandle(parts, 0, requireDurable: false) + && string.Equals(parts[1], "00000081", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsLegacyWeakLinuxObjectIdentity(string identity) + { + var parts = identity.Split(':'); + if (parts.Length >= 6 + && string.Equals(parts[0], "linux-generation", StringComparison.Ordinal) + && IsFixedHex(parts[1], 8) + && IsFixedHex(parts[2], 8) + && IsFixedHex(parts[3], 16)) + { + return IsLegacyWeakLinuxGenerationSuffix(parts, 4); + } + + return parts.Length >= 8 + && string.Equals(parts[0], "linux", StringComparison.Ordinal) + && IsFixedHex(parts[1], 8) + && IsFixedHex(parts[2], 8) + && IsFixedHex(parts[3], 16) + && IsFixedHex(parts[4], 16) + && IsFixedHex(parts[5], 8) + && IsLegacyWeakLinuxGenerationSuffix(parts, 6); + } + + private static bool IsLegacyWeakLinuxGenerationSuffix( + string[] parts, + int suffixIndex) => + parts.Length == suffixIndex + 3 + && string.Equals(parts[suffixIndex], "fh", StringComparison.Ordinal) + && TryValidateLinuxFileHandle(parts, suffixIndex, requireDurable: false) + && string.Equals( + parts[suffixIndex + 1], + "00000081", + StringComparison.OrdinalIgnoreCase); + private static bool TryValidateLinuxGenerationSuffix( string[] parts, int suffixIndex) => @@ -210,14 +353,32 @@ private static bool TryValidateLinuxGenerationSuffix( { "gen" => parts.Length == suffixIndex + 2 && IsFixedHex(parts[suffixIndex + 1], 8), - "fh" => parts.Length == suffixIndex + 3 - && IsFixedHex(parts[suffixIndex + 1], 8) - && parts[suffixIndex + 2].Length > 0 - && parts[suffixIndex + 2].Length % 2 == 0 - && parts[suffixIndex + 2].All(Uri.IsHexDigit), + "fh" => TryValidateLinuxFileHandle(parts, suffixIndex, requireDurable: true), _ => false }; + private static bool TryValidateLinuxFileHandle( + string[] parts, + int prefixIndex, + bool requireDurable) + { + if (parts.Length != prefixIndex + 3 + || !string.Equals(parts[prefixIndex], "fh", StringComparison.Ordinal) + || !IsFixedHex(parts[prefixIndex + 1], 8) + || parts[prefixIndex + 2].Length == 0 + || parts[prefixIndex + 2].Length % 2 != 0 + || !parts[prefixIndex + 2].All(Uri.IsHexDigit)) + { + return false; + } + + return !requireDurable + || !string.Equals( + parts[prefixIndex + 1], + "00000081", + StringComparison.OrdinalIgnoreCase); + } + private static bool IsFixedHex(string value, int length) => value.Length == length && value.All(Uri.IsHexDigit); } diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs index 2d1a5aa07..076b491f9 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxObjectIdentity.cs @@ -18,20 +18,37 @@ internal sealed partial class PinnedDirectoryCreation private const int LinuxFileHandleHeaderBytes = 8; private const int LinuxInitialFileHandleBytes = 128; private const int LinuxMaximumFileHandleBytes = 4096; + private const int LinuxGenericFileIdentifierType = 0x81; private const ulong LinuxFsIocGetVersion64 = 0x80087601; private const ulong LinuxFsIocGetVersion32 = 0x80047601; private static IReadOnlyList GetLinuxGenerationIdentityCandidates( SafeFileHandle handle) { - var candidates = new List(2); - var fileHandle = TryGetLinuxFileHandleIdentity( + var candidates = new List(3); + var fileHandleEvidence = TryGetLinuxFileHandleEvidence( handle, LinuxAtEmptyPath | LinuxAtHandleFid, retryWithoutHandleFid: true); - if (!string.IsNullOrWhiteSpace(fileHandle)) + if (fileHandleEvidence != null) { - candidates.Add($"fh:{fileHandle}"); + candidates.Add(fileHandleEvidence.ToGenerationIdentity()); + if (!fileHandleEvidence.IsDurableGenerationEvidence) + { + // AT_HANDLE_FID may deliberately return the generic exportfs + // FILEID_INO64_GEN representation even when the filesystem can + // provide a stronger ordinary handle. Probe without FID before + // concluding that only weak evidence is available. + var ordinaryFileHandleEvidence = TryGetLinuxFileHandleEvidence( + handle, + LinuxAtEmptyPath, + retryWithoutHandleFid: false); + if (ordinaryFileHandleEvidence != null + && !ordinaryFileHandleEvidence.HasSameHandle(fileHandleEvidence)) + { + candidates.Add(ordinaryFileHandleEvidence.ToGenerationIdentity()); + } + } } try @@ -41,18 +58,18 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates( candidates.Add(FormattableString.Invariant($"gen:{generation:x8}")); } } - catch (Win32Exception) when (candidates.Count > 0) + catch (Win32Exception) when (candidates.Any(IsDurableLinuxGenerationIdentity)) { // A second, supplementary capability failing unexpectedly must not // invalidate a strong identity already obtained from this pinned - // object. Persisted identities that require the failed scheme will - // still fail closed because their candidate will be absent. + // object. A generic FILEID_INO64_GEN FID is deliberately excluded + // here because it is compatibility evidence, not durable authority. } return candidates; } - private static string? TryGetLinuxFileHandleIdentity( + private static LinuxFileHandleEvidence? TryGetLinuxFileHandleEvidence( SafeFileHandle handle, int flags, bool retryWithoutHandleFid) @@ -87,8 +104,12 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates( bytes, 0, handleBytes); - return FormattableString.Invariant( - $"{handleType:x8}:{Convert.ToHexString(bytes).ToLowerInvariant()}"); + return new LinuxFileHandleEvidence( + handleType, + bytes, + (flags & LinuxAtHandleFid) != 0 + ? LinuxFileHandleProbeKind.FileIdentifier + : LinuxFileHandleProbeKind.FileHandle); } var error = Marshal.GetLastWin32Error(); @@ -102,10 +123,11 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates( } if (retryWithoutHandleFid - && error == LinuxInvalidArgument - && (flags & LinuxAtHandleFid) != 0) + && (flags & LinuxAtHandleFid) != 0 + && (IsUnavailableLinuxGenerationProbeError(error) + || error == LinuxOverflow)) { - return TryGetLinuxFileHandleIdentity( + return TryGetLinuxFileHandleEvidence( handle, LinuxAtEmptyPath, retryWithoutHandleFid: false); @@ -128,6 +150,29 @@ private static IReadOnlyList GetLinuxGenerationIdentityCandidates( return null; } + private enum LinuxFileHandleProbeKind + { + FileHandle, + FileIdentifier + } + + private sealed record LinuxFileHandleEvidence( + int HandleType, + byte[] Bytes, + LinuxFileHandleProbeKind ProbeKind) + { + internal bool IsDurableGenerationEvidence => + HandleType != LinuxGenericFileIdentifierType; + + internal string ToGenerationIdentity() => + FormattableString.Invariant( + $"fh:{HandleType:x8}:{Convert.ToHexString(Bytes).ToLowerInvariant()}"); + + internal bool HasSameHandle(LinuxFileHandleEvidence other) => + HandleType == other.HandleType + && Bytes.AsSpan().SequenceEqual(other.Bytes); + } + private static bool TryGetLinuxInodeGeneration( SafeFileHandle handle, out uint generation) diff --git a/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs b/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs index c73361da5..c8f042f7e 100644 --- a/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs +++ b/listenarr.infrastructure/FileSystem/RootFolderStorageHealthResolver.cs @@ -128,6 +128,18 @@ public async Task ResolveAsync( cancellationToken); if (!legacyCurrentGeneration.IsAvailable) { + if (legacyCurrentGeneration.FailureKind + == DirectoryObjectIdentityFailureKind.IdentityUnsupported) + { + return await ValidateFilesystemSemanticsAsync( + root, + canonicalPath, + LimitedIdentityUnsupported( + legacyCurrentGeneration.UnavailableReason + ?? expected.UnavailableReason), + cancellationToken); + } + return FromFailure(legacyCurrentGeneration); } diff --git a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs index f845addc4..5a00a2c18 100644 --- a/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs +++ b/listenarr.infrastructure/Library/Scanning/ScanPathAuthorizationService.cs @@ -219,16 +219,27 @@ private async Task TryCapturePhysicalIdentityAsync( if (enrolled.FailureKind == DirectoryObjectIdentityFailureKind.LegacyWeakIdentity) { - if (!liveBoundary.IsAvailable) + if (liveBoundary.IsAvailable) + { + verifiedBoundaryIdentity = liveBoundary; + limitedBoundary = true; + } + else if (liveBoundary.FailureKind + == DirectoryObjectIdentityFailureKind.IdentityUnsupported) + { + // A released weak Linux identity may still be the best + // evidence this mount can provide (for example CIFS + // FILEID_INO64_GEN). Keep scanning under pinned path-only + // authority, but do not restore destructive generation proof. + limitedBoundary = true; + } + else { return PhysicalIdentityCapture.Failed( liveBoundary.UnavailableReason ?? enrolled.UnavailableReason ?? "The configured scan root physical identity cannot be verified."); } - - verifiedBoundaryIdentity = liveBoundary; - limitedBoundary = true; } else if (enrolled.FailureKind == DirectoryObjectIdentityFailureKind.IdentityUnsupported) diff --git a/tests/Common/PlatformFactAttributes.cs b/tests/Common/PlatformFactAttributes.cs index 70f4a2f50..0b5183b43 100644 --- a/tests/Common/PlatformFactAttributes.cs +++ b/tests/Common/PlatformFactAttributes.cs @@ -22,6 +22,64 @@ public LinuxFactAttribute() } } +public sealed class NativeStorageIdentityFactAttribute : FactAttribute +{ + public const string PathEnvironmentVariable = + "LISTENARR_NATIVE_STORAGE_TEST_PATH"; + public const string ExpectationEnvironmentVariable = + "LISTENARR_NATIVE_STORAGE_IDENTITY_EXPECTATION"; + + public NativeStorageIdentityFactAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires a native Linux storage mount."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + PathEnvironmentVariable)) + || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + ExpectationEnvironmentVariable))) + { + Skip = "The native test runner did not provide a storage mount and identity expectation."; + } + } +} + +public sealed class NativeStorageRemountFactAttribute : FactAttribute +{ + public const string PathEnvironmentVariable = + NativeStorageIdentityFactAttribute.PathEnvironmentVariable; + public const string StatePathEnvironmentVariable = + "LISTENARR_NATIVE_STORAGE_IDENTITY_STATE_PATH"; + public const string PhaseEnvironmentVariable = + "LISTENARR_NATIVE_STORAGE_IDENTITY_PHASE"; + public const string ExpectationEnvironmentVariable = + NativeStorageIdentityFactAttribute.ExpectationEnvironmentVariable; + + public NativeStorageRemountFactAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires a native Linux storage mount."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + PathEnvironmentVariable)) + || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + StatePathEnvironmentVariable)) + || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + PhaseEnvironmentVariable)) + || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + ExpectationEnvironmentVariable))) + { + Skip = "The native test runner did not provide the storage remount fixture state and identity expectation."; + } + } +} + public sealed class WindowsTheoryAttribute : TheoryAttribute { public WindowsTheoryAttribute() diff --git a/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs b/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs index 494fcf674..1d43a9510 100644 --- a/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs +++ b/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs @@ -2068,6 +2068,41 @@ public async Task ConfirmCurrentFolder_ConfirmedGeneration_ReturnsRoot() confirmationService.VerifyAll(); } + [Fact] + public async Task ConfirmCurrentFolder_IdentityUnsupported_ReturnsSpecificConflictCode() + { + var path = FileUtils.GetAbsolutePath("confirm-unsupported-root"); + const string confirmationToken = "token"; + var svc = new FakeService(); + var confirmationService = new Mock(MockBehavior.Strict); + confirmationService + .Setup(service => service.ConfirmCurrentFolderAsync( + 1, + path, + confirmationToken, + It.IsAny())) + .ThrowsAsync(new PlatformNotSupportedException( + "No durable filesystem identity is available.")); + var db = CreateDb(); + var controller = new RootFoldersController( + svc, + _fakeQueue, + new EfAudiobookFileRepository(db), + new AudiobookRepository(db), + new LocalFileSystem(), + storageConfirmationService: confirmationService.Object); + + var result = await controller.ConfirmCurrentFolder( + 1, + new RootFolderConfirmationRequest(path, confirmationToken), + CancellationToken.None); + + var conflict = Assert.IsType(result); + var json = JsonSerializer.Serialize(conflict.Value); + Assert.Contains("root_folder_identity_unsupported", json, StringComparison.Ordinal); + confirmationService.VerifyAll(); + } + [Fact] public async Task ConfirmCurrentFolder_BlockedState_ReturnsConflictCode() { diff --git a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs index 23d6e1acb..48190edb5 100644 --- a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs @@ -124,6 +124,36 @@ public async Task ResolveAsync_UnixAccessDeniedNativeError_IsClassifiedAsAccessD resolution.FailureKind); } + [Fact] + public async Task ResolveExistingAsync_GenericFidPersistedIdentity_IsClassifiedAsWeakEvidence() + { + var directory = FileService.GetTempDirectory( + "directory-object-identity-generic-fid-legacy"); + const string genericFid = + "linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000"; + var persisted = ManagedDirectoryIdentity.CreateMarkerless(genericFid); + var resolver = new DirectoryObjectIdentityResolver( + nativeIdentityCandidatesResolver: static _ => + [ + "linux-generation:00000008:00000001:0000000000001234:gen:00000002" + ], + legacyWeakIdentityCandidatesResolver: static _ => [genericFid]); + + var existing = await resolver.ResolveExistingAsync( + directory, + ManagedDirectoryIdentity.CurrentVersion, + persisted); + + Assert.False(existing.IsAvailable); + Assert.Equal( + DirectoryObjectIdentityFailureKind.LegacyWeakIdentity, + existing.FailureKind); + Assert.Contains( + "FILEID_INO64_GEN", + existing.UnavailableReason, + StringComparison.Ordinal); + } + [Fact] public async Task ResolveExistingAsync_DifferentNativeGeneration_IsUnavailable() { @@ -239,6 +269,91 @@ public void LinuxIdentity_MultipleGenerationCapabilities_EmitEveryVerificationCa candidates); } + [Fact] + public void LinuxIdentity_GenericFidOnly_FailsClosed() + { + var exception = Assert.Throws(() => + PinnedDirectoryCreation.CreateLinuxObjectIdentityCandidatesFromEvidence( + deviceMajor: 8, + deviceMinor: 1, + inode: 0x1234, + hasBirthTime: false, + birthTimeSeconds: 0, + birthTimeNanoseconds: 0, + generationIdentities: ["fh:00000081:341200000000000000000000"])); + + Assert.Contains( + "durable file handle or inode generation", + exception.Message, + StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LinuxIdentity_GenericFid_DoesNotSuppressStrongInodeGeneration() + { + var candidates = PinnedDirectoryCreation.CreateLinuxObjectIdentityCandidatesFromEvidence( + deviceMajor: 8, + deviceMinor: 1, + inode: 0x1234, + hasBirthTime: false, + birthTimeSeconds: 0, + birthTimeNanoseconds: 0, + generationIdentities: + [ + "fh:00000081:341200000000000000000000", + "gen:00000002" + ]); + + Assert.Equal( + ["linux-generation:00000008:00000001:0000000000001234:gen:00000002"], + candidates); + } + + [Fact] + public void LinuxIdentity_GenericFid_DoesNotSuppressFilesystemSpecificHandle() + { + var candidates = PinnedDirectoryCreation.CreateLinuxObjectIdentityCandidatesFromEvidence( + deviceMajor: 8, + deviceMinor: 1, + inode: 0x1234, + hasBirthTime: false, + birthTimeSeconds: 0, + birthTimeNanoseconds: 0, + generationIdentities: + [ + "fh:00000081:341200000000000000000000", + "fh:00000001:deadbeef" + ]); + + Assert.Equal( + ["linux-generation:00000008:00000001:0000000000001234:fh:00000001:deadbeef"], + candidates); + } + + [Fact] + public void LinuxIdentity_GenericFidWeakCandidates_PreserveReleasedSpellingsOnlyForClassification() + { + var candidates = PinnedDirectoryCreation.CreateLinuxLegacyWeakObjectIdentityCandidatesFromEvidence( + deviceMajor: 8, + deviceMinor: 1, + inode: 0x1234, + hasBirthTime: true, + birthTimeSeconds: 0x5678, + birthTimeNanoseconds: 0x9abc, + generationIdentities: + [ + "fh:00000081:341200000000000000000000", + "gen:00000002" + ]); + + Assert.Equal( + [ + "linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000", + "linux:00000008:00000001:0000000000001234:0000000000005678:00009abc:fh:00000081:341200000000000000000000" + ], + candidates); + } + [Fact] public void LinuxIdentity_WithoutBirthTime_UsesStrongAlternativeGenerationEvidence() { @@ -306,6 +421,17 @@ public void LinuxPersistedIdentityEquivalence_RequiresSameStrongGenerationEviden "linux-generation:00000008:00000001:0000000000001234:fh:00000001:deadbeef")); } + [Fact] + public void LinuxPersistedIdentityEquivalence_GenericFidIsNeverDurableAuthority() + { + const string genericFid = + "linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000"; + + Assert.False(PinnedDirectoryCreation.ArePersistedObjectIdentitiesDurablyEquivalent( + genericFid, + genericFid)); + } + [Fact] public void LinuxIdentity_WithoutAnyGenerationEvidence_FailsClosed() { diff --git a/tests/Features/Infrastructure/FileSystem/DockerStorageCapabilityContractTests.cs b/tests/Features/Infrastructure/FileSystem/DockerStorageCapabilityContractTests.cs index a7ccadcc7..15b18fc36 100644 --- a/tests/Features/Infrastructure/FileSystem/DockerStorageCapabilityContractTests.cs +++ b/tests/Features/Infrastructure/FileSystem/DockerStorageCapabilityContractTests.cs @@ -6,6 +6,154 @@ namespace Listenarr.Tests.Features.Infrastructure.FileSystem; [Trait("Category", "Infrastructure")] public sealed class DockerStorageCapabilityContractTests : BaseTests { + [NativeStorageIdentityFact] + public async Task MountedStorage_IdentityCapability_MatchesDeclaredNativeExpectation() + { + var path = Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.PathEnvironmentVariable)!; + var expectation = Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.ExpectationEnvironmentVariable)!; + + Directory.CreateDirectory(path); + var resolver = new DirectoryObjectIdentityResolver(); + var resolution = await resolver.ResolveAsync(path); + + switch (expectation.Trim().ToLowerInvariant()) + { + case "durable": + Assert.True(resolution.IsAvailable, resolution.UnavailableReason); + break; + case "unsupported": + Assert.False(resolution.IsAvailable); + Assert.Equal( + DirectoryObjectIdentityFailureKind.IdentityUnsupported, + resolution.FailureKind); + break; + case "generic-fid": + Assert.False(resolution.IsAvailable); + Assert.Equal( + DirectoryObjectIdentityFailureKind.IdentityUnsupported, + resolution.FailureKind); + string persistedWeakIdentity; + using (var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(path)) + { + var weakCandidates = + anchor.GetLegacyWeakDirectoryObjectIdentityCandidates(); + persistedWeakIdentity = Assert.Single( + weakCandidates, + candidate => candidate.StartsWith( + "linux-generation:", + StringComparison.Ordinal) + && candidate.Contains( + ":fh:00000081:", + StringComparison.Ordinal)); + } + + var legacyResolution = await resolver.ResolveExistingAsync( + path, + ManagedDirectoryIdentity.CurrentVersion, + ManagedDirectoryIdentity.CreateMarkerless(persistedWeakIdentity)); + Assert.False(legacyResolution.IsAvailable); + Assert.Equal( + DirectoryObjectIdentityFailureKind.LegacyWeakIdentity, + legacyResolution.FailureKind); + break; + default: + throw new InvalidOperationException( + $"Unknown native storage identity expectation '{expectation}'."); + } + } + + [NativeStorageRemountFact] + public async Task MountedStorage_IdentityClassification_SurvivesDeclaredRemount() + { + var path = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PathEnvironmentVariable)!; + var statePath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.StatePathEnvironmentVariable)!; + var phase = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PhaseEnvironmentVariable)!; + var expectation = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.ExpectationEnvironmentVariable)!; + + Directory.CreateDirectory(path); + var resolver = new DirectoryObjectIdentityResolver(); + switch (phase.Trim().ToLowerInvariant()) + { + case "capture": + { + var persistedValue = expectation.Trim().ToLowerInvariant() switch + { + "durable" => await CaptureDurableManagedIdentityAsync(resolver, path), + "generic-fid" => CaptureWeakManagedIdentity(path), + _ => throw new InvalidOperationException( + $"Unknown native storage identity expectation '{expectation}'.") + }; + Directory.CreateDirectory(Path.GetDirectoryName(statePath)!); + await File.WriteAllLinesAsync( + statePath, + [ + ManagedDirectoryIdentity.CurrentVersion.ToString(), + persistedValue, + expectation.Trim().ToLowerInvariant() + ]); + break; + } + case "verify": + { + var persisted = await File.ReadAllLinesAsync(statePath); + Assert.Equal(3, persisted.Length); + var resolution = await resolver.ResolveExistingAsync( + path, + int.Parse(persisted[0]), + persisted[1]); + switch (persisted[2]) + { + case "durable": + Assert.True(resolution.IsAvailable, resolution.UnavailableReason); + break; + case "generic-fid": + Assert.False(resolution.IsAvailable); + Assert.Equal( + DirectoryObjectIdentityFailureKind.LegacyWeakIdentity, + resolution.FailureKind); + break; + default: + throw new InvalidOperationException( + $"Unknown persisted native storage identity expectation '{persisted[2]}'."); + } + break; + } + default: + throw new InvalidOperationException( + $"Unknown native storage identity phase '{phase}'."); + } + } + + private static async Task CaptureDurableManagedIdentityAsync( + DirectoryObjectIdentityResolver resolver, + string path) + { + var resolution = await resolver.ResolveAsync(path); + Assert.True(resolution.IsAvailable, resolution.UnavailableReason); + Assert.Equal(ManagedDirectoryIdentity.CurrentVersion, resolution.Version); + return Assert.IsType(resolution.Value); + } + + private static string CaptureWeakManagedIdentity(string path) + { + using var anchor = PinnedDirectoryCreation.OpenPinnedBoundary(path); + var weakIdentity = Assert.Single( + anchor.GetLegacyWeakDirectoryObjectIdentityCandidates(), + candidate => candidate.StartsWith( + "linux-generation:", + StringComparison.Ordinal) + && candidate.Contains( + ":fh:00000081:", + StringComparison.Ordinal)); + return ManagedDirectoryIdentity.CreateMarkerless(weakIdentity); + } + [Fact] public void Restart_StrongFileHandleThenBirthTimeOnly_FailsClosedRatherThanDowngradingAuthority() { diff --git a/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs b/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs index 049fab86f..1c0ab23b7 100644 --- a/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/RootFolderStorageHealthResolverTests.cs @@ -302,6 +302,44 @@ public async Task ResolveAsync_LegacyWeakIdentity_AllowsScanAndExplicitIdentityU identityResolver.VerifyAll(); } + [Fact] + public async Task ResolveAsync_LegacyWeakIdentityWithoutCurrentStrongIdentity_RemainsLimitedWithoutConfirmation() + { + var path = Path.GetFullPath("root-storage-legacy-weak-unsupported-current"); + var root = BuildRoot(path, identity: "legacy"); + var identityResolver = new Mock(MockBehavior.Strict); + identityResolver + .Setup(resolver => resolver.ResolveExistingAsync( + path, + ManagedDirectoryIdentity.CurrentVersion, + "legacy", + It.IsAny())) + .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( + "Generic FILEID_INO64_GEN evidence is no longer durable authority.", + DirectoryObjectIdentityFailureKind.LegacyWeakIdentity)); + identityResolver + .Setup(resolver => resolver.ResolveAsync( + path, + It.IsAny())) + .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( + "The filesystem does not expose a durable file handle or inode generation for this object.", + DirectoryObjectIdentityFailureKind.IdentityUnsupported)); + var resolver = new RootFolderStorageHealthResolver( + identityResolver.Object, + readOnlyFileSystemProbe: _ => false); + + var result = await resolver.ResolveAsync(root); + + Assert.Equal(RootFolderStorageState.Limited, result.State); + Assert.Equal(RootFolderStorageReason.IdentityUnsupported, result.Reason); + Assert.True(result.CanReadFilesystem); + Assert.True(result.CanScanFilesystem); + Assert.False(result.CanMutateFilesystem); + Assert.False(result.CanConfirmCurrentFolder); + Assert.Null(result.ConfirmationToken); + identityResolver.VerifyAll(); + } + [Fact] public async Task ResolveAsync_LegacyRootWithoutPersistedSemantics_ReturnsUnconfirmed() { diff --git a/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs index 8ef881cb0..da299c09c 100644 --- a/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/ScanPathAuthorizationServiceTests.cs @@ -203,6 +203,63 @@ public async Task AuthorizeAsync_LegacyWeakRootIdentity_UsesPinnedPathOnlyProof( identityResolver.VerifyAll(); } + [Fact] + public async Task AuthorizeAsync_LegacyWeakRootIdentityStillUnsupported_UsesPinnedPathOnlyProof() + { + var configuredRoot = FileService.GetTempDirectory( + "scan-authorization-legacy-weak-unsupported-root"); + var scanRoot = Path.Join(configuredRoot, "Book"); + Directory.CreateDirectory(scanRoot); + var root = await AddAuthorizedRootAsync(configuredRoot); + var rootFolderService = new Mock(MockBehavior.Strict); + rootFolderService.Setup(service => service.GetAllAsync()) + .ReturnsAsync([root]); + var configurationService = new Mock(MockBehavior.Strict); + configurationService.Setup(service => service.GetApplicationSettingsAsync()) + .ReturnsAsync(new ApplicationSettings()); + var identityResolver = new Mock(MockBehavior.Strict); + identityResolver + .Setup(resolver => resolver.ResolveExistingAsync( + configuredRoot, + root.DirectoryObjectIdentityVersion!.Value, + root.DirectoryObjectIdentity!, + It.IsAny())) + .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( + "Released generic Linux FID is weak evidence.", + DirectoryObjectIdentityFailureKind.LegacyWeakIdentity)); + identityResolver + .Setup(resolver => resolver.ResolveAsync( + configuredRoot, + It.IsAny())) + .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( + "The filesystem exposes only generic weak identity evidence.", + DirectoryObjectIdentityFailureKind.IdentityUnsupported)); + identityResolver + .Setup(resolver => resolver.ResolveAsync( + scanRoot, + It.IsAny())) + .ReturnsAsync(DirectoryObjectIdentityResolution.Unavailable( + "The filesystem exposes only generic weak identity evidence.", + DirectoryObjectIdentityFailureKind.IdentityUnsupported)); + var service = new ScanPathAuthorizationService( + configurationService.Object, + rootFolderService.Object, + _provider.GetRequiredService(), + identityResolver.Object, + new CapturingScanAuthorizationLogger()); + + var result = await service.AuthorizeAsync(scanRoot); + + Assert.True(result.IsAuthorized, result.Error); + Assert.True(result.PhysicalIdentity.HasValue); + Assert.Equal( + ScanPathPhysicalProofKind.PinnedPathOnly, + result.PhysicalIdentity.Value.ProofKind); + Assert.False(result.PhysicalIdentity.Value.HasDurableGenerationProof); + rootFolderService.VerifyAll(); + identityResolver.VerifyAll(); + } + [LinuxFact] public async Task AuthorizeAsync_AmbiguousNestedManagedRoot_DoesNotFallBackToBroaderRootAuthority() { From 09d3732ff84f7dc08ed540d5f7bcde5a1948bf9a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Sat, 29 Aug 2026 14:14:31 -0400 Subject: [PATCH 2/6] fix: preserve partial library scan results --- fe/src/__tests__/AudiobookDetailView.spec.ts | 22 ++-- fe/src/__tests__/UnmatchedFilesModal.spec.ts | 109 ++++++++++++++++++ fe/src/__tests__/libraryImport.store.spec.ts | 3 + .../feedback/UnmatchedFilesModal.vue | 89 +++++++++++--- fe/src/stores/libraryImport.ts | 6 + fe/src/types/index.ts | 2 + fe/src/views/library/AudiobookDetailView.vue | 51 +++++++- fe/src/views/library/LibraryImportView.vue | 22 ++++ .../Features/Library/RootFoldersController.cs | 9 +- .../Jobs/UnmatchedScanQueueService.cs | 21 +++- .../UnmatchedScanBackgroundService.cs | 66 +++++++++-- .../Library/RootFoldersControllerTests.cs | 11 +- .../Services/WorkerProcessorBoundaryTests.cs | 60 ++++++++++ .../UnmatchedScanBackgroundServiceTests.cs | 8 +- 14 files changed, 437 insertions(+), 42 deletions(-) diff --git a/fe/src/__tests__/AudiobookDetailView.spec.ts b/fe/src/__tests__/AudiobookDetailView.spec.ts index 703fd1dc6..7df1caf14 100644 --- a/fe/src/__tests__/AudiobookDetailView.spec.ts +++ b/fe/src/__tests__/AudiobookDetailView.spec.ts @@ -23,7 +23,6 @@ import { useLibraryStore } from '@/stores/library' import { useScanNotificationsStore } from '@/stores/scanNotifications' import { useFilesystemReadinessStore } from '@/stores/filesystemReadiness' import { apiService, ensureImageCached } from '@/services/api' -import { signalRService } from '@/services/signalr' import AudiobookDetailViewCmp from '@/views/library/AudiobookDetailView.vue' const routerPushMock = vi.fn() // Mock useRoute to provide params for the detail view @@ -38,6 +37,7 @@ vi.mock('@/services/api', () => ({ getImageUrl: vi.fn((url: string) => url || 'https://via.placeholder.com/300x450?text=No+Image'), getQualityProfiles: vi.fn(async () => []), getLibrary: vi.fn(async () => []), + getAudiobook: vi.fn(async () => undefined), scanAudiobook: vi.fn(), getWeakStorageMissingFiles: vi.fn(async () => ({ items: [] })), confirmWeakStorageMissingFiles: vi.fn(), @@ -329,10 +329,7 @@ describe('AudiobookDetailView image recache behavior', () => { const wrapper = mount(AudiobookDetailViewCmp, { global: { plugins: [pinia] } }) await new Promise((resolve) => setTimeout(resolve, 10)) - const scanCallback = vi.mocked(signalRService.onScanJobUpdate).mock.calls[0]?.[0] as - | ((job: { audiobookId: number; status: string }) => void) - | undefined - expect(scanCallback).toBeDefined() + const scanNotificationsStore = useScanNotificationsStore() let resolveOlder!: (value: { scanToken: string @@ -349,8 +346,19 @@ describe('AudiobookDetailView image recache behavior', () => { .mockImplementationOnce(() => older) .mockImplementationOnce(() => newer) - scanCallback!({ audiobookId: 5, status: 'Completed' }) - scanCallback!({ audiobookId: 5, status: 'Completed' }) + scanNotificationsStore.registerManualScan('older-scan', 5) + scanNotificationsStore.applyUpdate({ + jobId: 'older-scan', + audiobookId: 5, + status: 'Completed', + }) + await new Promise((resolve) => setTimeout(resolve, 2)) + scanNotificationsStore.registerManualScan('newer-scan', 5) + scanNotificationsStore.applyUpdate({ + jobId: 'newer-scan', + audiobookId: 5, + status: 'Completed', + }) resolveNewer({ scanToken: 'new-token', diff --git a/fe/src/__tests__/UnmatchedFilesModal.spec.ts b/fe/src/__tests__/UnmatchedFilesModal.spec.ts index 2d9c1498a..6abbea09a 100644 --- a/fe/src/__tests__/UnmatchedFilesModal.spec.ts +++ b/fe/src/__tests__/UnmatchedFilesModal.spec.ts @@ -111,4 +111,113 @@ describe('UnmatchedFilesModal filesystem readiness', () => { expect(document.body.querySelector('add-library-modal-stub')).toBeNull() wrapper.unmount() }) + + it('shows cached partial-scan warnings even when no unmatched items were found', async () => { + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: true, + filesystemStatus: 'Ready', + filesystemPhase: null, + filesystemErrorCode: null, + filesystemErrorMessage: null, + } + vi.mocked(apiService.getSavedUnmatchedFiles).mockResolvedValueOnce({ + items: [], + lastScannedAt: new Date().toISOString(), + warnings: ['One path could not be read and was skipped.'], + }) + const rootFolder = { + id: 7, + name: 'Library', + path: 'C:\\library', + isDefault: true, + } as unknown as RootFolder + + const wrapper = mount(UnmatchedFilesModal, { + props: { isOpen: false, rootFolder }, + attachTo: document.body, + global: { plugins: [pinia], stubs: { AddLibraryModal: true } }, + }) + await wrapper.setProps({ isOpen: true }) + await flushPromises() + + expect(document.body.textContent).toContain('One path could not be read and was skipped.') + expect(document.body.textContent).toContain('All files are in your library') + wrapper.unmount() + }) + + it('polls a scan to completion when the SignalR terminal event is missed', async () => { + vi.useFakeTimers() + try { + const pinia = createPinia() + setActivePinia(pinia) + useFilesystemReadinessStore().readiness = { + isReady: true, + status: 'ready', + databaseConnected: true, + migrationsCurrent: true, + errorCode: null, + filesystemReady: true, + filesystemStatus: 'Ready', + filesystemPhase: null, + filesystemErrorCode: null, + filesystemErrorMessage: null, + } + vi.mocked(apiService.getSavedUnmatchedFiles).mockResolvedValueOnce({ + items: [], + lastScannedAt: undefined, + warnings: [], + }) + vi.mocked(apiService.scanUnmatchedFiles).mockResolvedValueOnce({ jobId: 'scan-job-7' }) + vi.mocked(apiService.getUnmatchedResults) + .mockResolvedValueOnce({ + jobId: 'scan-job-7', + status: 'Processing', + items: [], + warnings: [], + }) + .mockResolvedValue({ + jobId: 'scan-job-7', + status: 'Completed', + items: [], + warnings: ['One path could not be read and was skipped.'], + }) + const rootFolder = { + id: 7, + name: 'Library', + path: 'C:\\library', + isDefault: true, + } as unknown as RootFolder + + const wrapper = mount(UnmatchedFilesModal, { + props: { isOpen: false, rootFolder }, + attachTo: document.body, + global: { plugins: [pinia], stubs: { AddLibraryModal: true } }, + }) + await wrapper.setProps({ isOpen: true }) + await flushPromises() + const scan = Array.from(document.body.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Scan', + ) + expect(scan).toBeTruthy() + scan!.click() + await flushPromises() + expect(document.body.textContent).toContain('Scanning') + + await vi.advanceTimersByTimeAsync(2500) + await flushPromises() + + expect(document.body.textContent).toContain('All files are in your library') + expect(document.body.textContent).toContain('One path could not be read and was skipped.') + wrapper.unmount() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/fe/src/__tests__/libraryImport.store.spec.ts b/fe/src/__tests__/libraryImport.store.spec.ts index e1739dd7c..1483f6da7 100644 --- a/fe/src/__tests__/libraryImport.store.spec.ts +++ b/fe/src/__tests__/libraryImport.store.spec.ts @@ -348,8 +348,10 @@ describe('library import store', () => { getUnmatchedResults.mockImplementation(async (jobId: string) => { expect(jobId).toBe('own-job') return { + jobId, status: 'Completed', error: null, + warnings: ['One path could not be read and was skipped.'], items: [ { fullPath: 'C:\\incoming\\Book A.mp3', @@ -373,6 +375,7 @@ describe('library import store', () => { expect(getUnmatchedResults).toHaveBeenCalledWith('own-job') expect(Object.keys(store.items)).toEqual(['C:\\incoming\\Book A.mp3']) expect(store.scanStatus).toBe('done') + expect(store.scanWarnings).toEqual(['One path could not be read and was skipped.']) }) it('prefers detected title and author for automatic matching before folder fallback', async () => { diff --git a/fe/src/components/feedback/UnmatchedFilesModal.vue b/fe/src/components/feedback/UnmatchedFilesModal.vue index 9e14ce3a7..95c755611 100644 --- a/fe/src/components/feedback/UnmatchedFilesModal.vue +++ b/fe/src/components/feedback/UnmatchedFilesModal.vue @@ -49,6 +49,12 @@
+
+
+ + {{ warning }} +
+

All files are in your library

@@ -231,6 +237,7 @@ type Phase = 'empty' | 'scanning' | 'results' | 'error' const phase = ref('empty') const items = ref([]) const errorMessage = ref('') +const scanWarnings = ref([]) const lastScannedAt = ref(null) const addingItem = ref(null) const bulkAdding = ref(false) @@ -274,6 +281,16 @@ const fileActionLabel = computed(() => let jobId = '' let offSignalR: (() => void) | null = null +let pollInterval: ReturnType | null = null + +function stopScanTracking() { + offSignalR?.() + offSignalR = null + if (pollInterval) { + clearInterval(pollInterval) + pollInterval = null + } +} // On open: load cached results — no auto-scan watch( @@ -288,7 +305,8 @@ watch( try { const saved = await apiService.getSavedUnmatchedFiles(props.rootFolder.id) - if (saved.items.length > 0) { + scanWarnings.value = saved.warnings ?? [] + if (saved.items.length > 0 || saved.lastScannedAt) { items.value = saved.items lastScannedAt.value = saved.lastScannedAt ?? null phase.value = 'results' @@ -311,10 +329,26 @@ async function startScan() { phase.value = 'scanning' items.value = [] errorMessage.value = '' + scanWarnings.value = [] jobId = '' + stopScanTracking() + + function applyCompletedScan( + response: Awaited>, + ) { + items.value = response.items + scanWarnings.value = response.warnings ?? [] + lastScannedAt.value = new Date().toISOString() + phase.value = 'results' + stopScanTracking() + } + + async function completeScan(completedJobId: string) { + applyCompletedScan(await apiService.getUnmatchedResults(completedJobId)) + } + // Subscribe to SignalR before triggering the scan - offSignalR?.() offSignalR = signalRService.onUnmatchedScanComplete(async (payload) => { if (payload.jobId !== jobId) return if (payload.error) { @@ -323,13 +357,11 @@ async function startScan() { return } try { - const response = await apiService.getUnmatchedResults(payload.jobId) - items.value = response.items - lastScannedAt.value = new Date().toISOString() - phase.value = 'results' + await completeScan(payload.jobId) } catch (e) { phase.value = 'error' errorMessage.value = (e as Error)?.message || 'Failed to fetch results' + stopScanTracking() } }) @@ -339,29 +371,41 @@ async function startScan() { // Poll once immediately — handles fast scans that complete before SignalR fires const check = await apiService.getUnmatchedResults(jobId) if (check.status === 'Completed') { - items.value = check.items - lastScannedAt.value = new Date().toISOString() - phase.value = 'results' + applyCompletedScan(check) } else if (check.status === 'Failed') { phase.value = 'error' errorMessage.value = check.error || 'Scan failed' + stopScanTracking() + } else { + pollInterval = setInterval(async () => { + if (!jobId || phase.value !== 'scanning') return + try { + const poll = await apiService.getUnmatchedResults(jobId) + if (poll.status === 'Completed') { + applyCompletedScan(poll) + } else if (poll.status === 'Failed') { + phase.value = 'error' + errorMessage.value = poll.error || 'Scan failed' + stopScanTracking() + } + } catch { + // Ignore transient polling errors; SignalR or a later poll can still complete the scan. + } + }, 2500) } - // Otherwise SignalR will deliver the completion event } catch (e) { phase.value = 'error' errorMessage.value = (e as Error)?.message || 'Failed to start scan' - offSignalR?.() - offSignalR = null + stopScanTracking() } } onUnmounted(() => { - offSignalR?.() + stopScanTracking() }) function close() { - offSignalR?.() - offSignalR = null + stopScanTracking() emit('close') } @@ -563,6 +607,21 @@ async function addAllWithAsin() { color: #f03e3e; } +.scan-warnings { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.scan-warning { + display: flex; + align-items: flex-start; + gap: 0.35rem; + color: #f59e0b; + font-size: 0.85rem; +} + .error-icon { width: 40px; height: 40px; diff --git a/fe/src/stores/libraryImport.ts b/fe/src/stores/libraryImport.ts index f41d7763c..06b0d68e4 100644 --- a/fe/src/stores/libraryImport.ts +++ b/fe/src/stores/libraryImport.ts @@ -138,6 +138,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { const rootFolderId = ref(null) const scanStatus = ref<'idle' | 'scanning' | 'done' | 'error'>('idle') const scanError = ref(null) + const scanWarnings = ref([]) const lastScannedAt = ref(null) const action = ref<'none' | 'move' | 'hardlink/copy'>('none') const monitor = ref<'none' | 'all'>('all') @@ -157,9 +158,11 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { async function initFromRootFolder(id: number) { rootFolderId.value = id scanStatus.value = 'idle' + scanWarnings.value = [] try { const saved = await apiService.getSavedUnmatchedFiles(id) if (saved.lastScannedAt) lastScannedAt.value = saved.lastScannedAt + scanWarnings.value = saved.warnings ?? [] const persisted = _loadPersistedMatches(id) const newItems: Record = {} for (const item of saved.items) { @@ -196,6 +199,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { rootFolderId.value = id scanStatus.value = 'scanning' scanError.value = null + scanWarnings.value = [] try { localStorage.removeItem(_storageKey(id)) } catch { @@ -222,6 +226,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { try { const response = await apiService.getUnmatchedResults(completedJobId) _populateFromItems(response.items) + scanWarnings.value = response.warnings ?? [] _persistMatches() lastScannedAt.value = new Date().toISOString() scanStatus.value = 'done' @@ -585,6 +590,7 @@ export const useLibraryImportStore = defineStore('libraryImport', () => { rootFolderId, scanStatus, scanError, + scanWarnings, lastScannedAt, action, monitor, diff --git a/fe/src/types/index.ts b/fe/src/types/index.ts index 2497f206a..0519fe3bf 100644 --- a/fe/src/types/index.ts +++ b/fe/src/types/index.ts @@ -1214,11 +1214,13 @@ export interface UnmatchedFilesResponse { jobId: string status: 'Queued' | 'Processing' | 'Completed' | 'Failed' error?: string + warnings?: string[] items: UnmatchedFileItem[] } export interface SavedUnmatchedResponse { lastScannedAt?: string + warnings?: string[] items: UnmatchedFileItem[] } diff --git a/fe/src/views/library/AudiobookDetailView.vue b/fe/src/views/library/AudiobookDetailView.vue index 9de8b6951..7d92c7570 100644 --- a/fe/src/views/library/AudiobookDetailView.vue +++ b/fe/src/views/library/AudiobookDetailView.vue @@ -1260,9 +1260,6 @@ onMounted(async () => { if (!audiobook.value) return if (String(job.audiobookId) !== String(audiobook.value.id)) return scanNotificationsStore.applyUpdate(job) - if (job.status.toLowerCase() === 'completed') { - void loadWeakStorageMissingFiles() - } }) // subscribe to AudiobookUpdate messages and merge detail when this audiobook is updated (e.g., after a move) @@ -1326,6 +1323,54 @@ watch( }, ) +let lastHandledTerminalScanJobId: string | null = null +watch( + () => + [ + trackedScanJob.value?.jobId, + trackedScanJob.value?.status, + trackedScanJob.value?.error, + ] as const, + async ([jobId, status, scanError]) => { + if (!jobId || !status) return + const normalizedStatus = status.toLowerCase() + if (normalizedStatus !== 'completed' && normalizedStatus !== 'failed') return + if (lastHandledTerminalScanJobId === jobId) return + lastHandledTerminalScanJobId = jobId + + if (normalizedStatus === 'completed') { + await refreshAudiobookAfterScan() + return + } + + const toast = useToast() + toast.error('Scan failed', scanError || 'The audiobook scan did not complete successfully.') + }, + { flush: 'post' }, +) + +async function refreshAudiobookAfterScan() { + const id = audiobook.value?.id ?? parseInt(route.params.id as string) + try { + let refreshed: Audiobook | null = null + if (typeof apiService.getAudiobook === 'function') { + refreshed = await apiService.getAudiobook(id) + } else { + await libraryStore.fetchLibrary() + refreshed = libraryStore.audiobooks.find((candidate) => candidate.id === id) ?? null + } + + if (refreshed) { + audiobook.value = refreshed + await afterLoad() + } + } catch (err) { + logger.debug('Unable to refresh audiobook after scan completion', err) + } + + await loadWeakStorageMissingFiles() +} + async function loadAudiobook() { loading.value = true error.value = null diff --git a/fe/src/views/library/LibraryImportView.vue b/fe/src/views/library/LibraryImportView.vue index b79716372..5828bbff7 100644 --- a/fe/src/views/library/LibraryImportView.vue +++ b/fe/src/views/library/LibraryImportView.vue @@ -78,6 +78,13 @@
+
+
+ + {{ warning }} +
+
+

Scanning for unmatched audio files...

@@ -563,6 +570,21 @@ async function refreshRootFolders(newFolder: RootFolder) { color: #ef4444; } +.scan-warnings { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0.75rem 0; +} + +.scan-warning { + display: flex; + align-items: flex-start; + gap: 0.35rem; + font-size: 0.85rem; + color: #f59e0b; +} + .state-panel { display: flex; flex-direction: column; diff --git a/listenarr.api/Features/Library/RootFoldersController.cs b/listenarr.api/Features/Library/RootFoldersController.cs index 20063d2e3..0565e924b 100644 --- a/listenarr.api/Features/Library/RootFoldersController.cs +++ b/listenarr.api/Features/Library/RootFoldersController.cs @@ -430,6 +430,7 @@ public IActionResult GetUnmatchedResults(Guid jobId) jobId = job.Id.ToString(), status = job.Status, error = UnmatchedScanPublicError.FromInternal(job.Error), + warnings = job.Warnings, items = job.Results ?? new List() }); } @@ -476,11 +477,17 @@ public async Task GetSavedUnmatched(int id) return Ok(new { lastScannedAt = job.CompletedAt, + warnings = job.Warnings, items = filtered }); } - return Ok(new { lastScannedAt = (DateTime?)null, items = new List() }); + return Ok(new + { + lastScannedAt = (DateTime?)null, + warnings = new List(), + items = new List() + }); } } diff --git a/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs b/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs index f2e60f324..e85ee3b8a 100644 --- a/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs +++ b/listenarr.application/Audiobooks/Jobs/UnmatchedScanQueueService.cs @@ -59,13 +59,19 @@ public class UnmatchedScanJob public string Status { get; set; } = "Queued"; public string? Error { get; set; } public List? Results { get; set; } + public List Warnings { get; set; } = new(); } public interface IUnmatchedScanQueueService { Task EnqueueAsync(string rootFolderPath); bool TryGetJob(Guid id, out UnmatchedScanJob? job); - void UpdateJob(Guid id, string status, List? results = null, string? error = null); + void UpdateJob( + Guid id, + string status, + List? results = null, + string? error = null, + List? warnings = null); bool TryGetLastJobForPath(string rootFolderPath, out UnmatchedScanJob? job); ChannelReader Reader { get; } } @@ -129,15 +135,24 @@ public async Task EnqueueAsync(string rootFolderPath) public bool TryGetJob(Guid id, out UnmatchedScanJob? job) => _jobs.TryGetValue(id, out job); - public void UpdateJob(Guid id, string status, List? results = null, string? error = null) + public void UpdateJob( + Guid id, + string status, + List? results = null, + string? error = null, + List? warnings = null) { if (!_jobs.TryGetValue(id, out var job)) return; job.Status = status; job.Error = error; if (results != null) job.Results = results; - if (status == "Completed") + if (warnings != null) job.Warnings = warnings; + if (status is "Completed" or "Failed") { job.CompletedAt = DateTime.UtcNow; + } + if (status == "Completed") + { _lastJobByPath[job.RootFolderPath] = id; } _jobs[id] = job; diff --git a/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs b/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs index 4d6028ba4..495493438 100644 --- a/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs +++ b/listenarr.infrastructure/Library/Scanning/UnmatchedScanBackgroundService.cs @@ -178,6 +178,9 @@ public partial class UnmatchedScanProcessor : IUnmatchedScanProcessor private static readonly string[] AudioExtensions = { ".m4b", ".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wav" }; private sealed record StemGroup(string Stem, List Files); private sealed record GroupCandidate(string FilePath, string Stem, bool IsAncillary, string TitleKey, string AuthorKey); + private sealed record UnmatchedScanOutcome( + List Results, + List Warnings); private readonly IUnmatchedScanQueueService _queue; private readonly IServiceScopeFactory _scopeFactory; @@ -207,18 +210,31 @@ public async Task ProcessJobAsync(UnmatchedScanJob job, CancellationToken cancel _logger.LogInformation("Processing unmatched scan job {JobId} for {Path}", job.Id, job.RootFolderPath); _queue.UpdateJob(job.Id, "Processing"); - var results = await ScanAsync(job.RootFolderPath, cancellationToken); + var outcome = await ScanAsync(job.RootFolderPath, cancellationToken); - _queue.UpdateJob(job.Id, "Completed", results); - _logger.LogInformation("Unmatched scan job {JobId} completed: {Count} unmatched items", job.Id, results.Count); + _queue.UpdateJob( + job.Id, + "Completed", + outcome.Results, + warnings: outcome.Warnings); + _logger.LogInformation( + "Unmatched scan job {JobId} completed: {Count} unmatched items, {WarningCount} warning(s)", + job.Id, + outcome.Results.Count, + outcome.Warnings.Count); await _hubContext.Clients.All.SendAsync( "UnmatchedScanComplete", - new { jobId = job.Id.ToString(), count = results.Count }, + new + { + jobId = job.Id.ToString(), + count = outcome.Results.Count, + warningCount = outcome.Warnings.Count + }, cancellationToken); } - private async Task> ScanAsync(string rootFolderPath, CancellationToken ct) + private async Task ScanAsync(string rootFolderPath, CancellationToken ct) { using var scope = _scopeFactory.CreateScope(); var fileRepository = scope.ServiceProvider.GetRequiredService(); @@ -283,13 +299,37 @@ private async Task> ScanAsync(string rootFolderPath, C semantics, pinnedRoot, authorization.PhysicalIdentity.Value.HasDurableGenerationProof); - if (enumeration.Issues.Any(issue => issue.Kind is - ScanDiscoveryIssueKind.DirectoryGenerationChanged - or ScanDiscoveryIssueKind.EnumerationFailure)) + if (enumeration.Issues.Any(issue => + issue.Kind == ScanDiscoveryIssueKind.DirectoryGenerationChanged)) + { + throw new InvalidOperationException( + "The unmatched scan root changed during enumeration."); + } + + var rootEnumerationFailure = enumeration.Issues.Any(issue => + issue.Kind == ScanDiscoveryIssueKind.EnumerationFailure + && !string.IsNullOrWhiteSpace(issue.Path) + && FileSystemPathIdentity.AreEquivalent( + issue.Path!, + canonicalRootFolderPath, + semantics)); + if (rootEnumerationFailure) { throw new InvalidOperationException( - "The unmatched scan root changed or became unavailable during enumeration."); + "The unmatched scan root became unavailable during enumeration."); } + + var skippedPathCount = enumeration.Issues.Count(issue => + issue.Kind == ScanDiscoveryIssueKind.EnumerationFailure); + var warnings = new List(); + if (skippedPathCount > 0) + { + warnings.Add( + skippedPathCount == 1 + ? "One path could not be read and was skipped. Other readable library-import results were preserved." + : $"{skippedPathCount} paths could not be read and were skipped. Other readable library-import results were preserved."); + } + var candidates = enumeration.Candidates.ToList(); // Filter to untracked files @@ -434,7 +474,13 @@ await ApplyPinnedFolderMetadataAsync( } }); - return results.OrderBy(r => r.Author).ThenBy(r => r.Series).ThenBy(r => r.Title).ToList(); + return new UnmatchedScanOutcome( + results + .OrderBy(r => r.Author) + .ThenBy(r => r.Series) + .ThenBy(r => r.Title) + .ToList(), + warnings); } } diff --git a/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs b/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs index 1d43a9510..9817f7bd0 100644 --- a/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs +++ b/tests/Features/Api/Features/Library/RootFoldersControllerTests.cs @@ -44,7 +44,14 @@ public bool TryGetJob(Guid id, out UnmatchedScanJob? job) job = LastJob; return job != null && job.Id == id; } - public void UpdateJob(Guid id, string status, List? results = null, string? error = null) { } + public void UpdateJob( + Guid id, + string status, + List? results = null, + string? error = null, + List? warnings = null) + { + } public bool TryGetLastJobForPath(string rootFolderPath, out UnmatchedScanJob? job) { job = LastJob; @@ -446,6 +453,7 @@ public void GetUnmatchedResults_RedactsInternalFailureDetails() RootFolderPath = "C:\\private\\library", Status = "Failed", Error = "C:\\private\\library failed with worker secret", + Warnings = ["One path could not be read and was skipped."], Results = [ new UnmatchedFileResult @@ -470,6 +478,7 @@ public void GetUnmatchedResults_RedactsInternalFailureDetails() var json = JsonSerializer.Serialize(ok.Value); Assert.Contains("The unmatched scan failed", json, StringComparison.Ordinal); Assert.Contains("book.m4b", json, StringComparison.Ordinal); + Assert.Contains("One path could not be read and was skipped.", json, StringComparison.Ordinal); Assert.DoesNotContain("worker secret", json, StringComparison.OrdinalIgnoreCase); } diff --git a/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs b/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs index e51769150..53126bb22 100644 --- a/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs +++ b/tests/Features/Api/Services/WorkerProcessorBoundaryTests.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; using Listenarr.Tests.Common; using Microsoft.AspNetCore.SignalR; @@ -279,6 +281,61 @@ public async Task UnmatchedScanProcessor_ProcessJob_CompletesWithUntrackedAudio( Assert.Equal("M4B", result.Format); } + [LinuxFact] + [SupportedOSPlatform("linux")] + public async Task UnmatchedScanProcessor_UnreadableChild_PreservesReadableResultsAndWarning() + { + Assert.NotEqual((uint)0, GetEffectiveUserId()); + + var root = FileService.GetTempDirectory("unmatched-processor-partial-root"); + var readableDirectory = Path.Join(root, "Readable Book"); + var unreadableDirectory = Path.Join(root, "Unreadable Book"); + Directory.CreateDirectory(readableDirectory); + Directory.CreateDirectory(unreadableDirectory); + var readableFile = await FileService.GetFileAsync( + readableDirectory, + "Readable Book.m4b", + "audio"); + await FileService.GetFileAsync( + unreadableDirectory, + "Unreadable Book.m4b", + "audio"); + await AddAuthorizedRootAsync(root); + await CreateApplicationSettings(); + var queue = new UnmatchedScanQueueService( + _provider.GetRequiredService>(), + _provider.GetRequiredService()); + CreateHubProxy(out var hubContext); + var processor = new UnmatchedScanProcessor( + queue, + _provider.GetRequiredService(), + _provider.GetRequiredService>(), + hubContext.Object, + _provider.GetRequiredService(), + _provider.GetRequiredService()); + await queue.EnqueueAsync(root); + Assert.True(queue.Reader.TryRead(out var job)); + + var originalMode = File.GetUnixFileMode(unreadableDirectory); + File.SetUnixFileMode(unreadableDirectory, UnixFileMode.None); + try + { + await processor.ProcessJobAsync(job, CancellationToken.None); + } + finally + { + File.SetUnixFileMode(unreadableDirectory, originalMode); + } + + Assert.True(queue.TryGetJob(job.Id, out var updatedJob)); + Assert.Equal("Completed", updatedJob!.Status); + var result = Assert.Single(updatedJob.Results!); + Assert.Equal(readableFile, result.FullPath); + var warning = Assert.Single(updatedJob.Warnings); + Assert.Contains("could not be read", warning, StringComparison.OrdinalIgnoreCase); + Assert.Contains("preserved", warning, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task UnmatchedScanProcessor_PinnedPathOnly_DoesNotReopenFilesForMetadataEnrichment() { @@ -686,6 +743,9 @@ await Assert.ThrowsAsync(() => Assert.Null(updatedJob.Results); } + [DllImport("libc", EntryPoint = "geteuid")] + private static extern uint GetEffectiveUserId(); + private static Mock CreateHubProxy(out Mock> hubContext) where THub : Hub { diff --git a/tests/Features/Infrastructure/Library/Scanning/UnmatchedScanBackgroundServiceTests.cs b/tests/Features/Infrastructure/Library/Scanning/UnmatchedScanBackgroundServiceTests.cs index 79b0e3cad..1cf0e5fc2 100644 --- a/tests/Features/Infrastructure/Library/Scanning/UnmatchedScanBackgroundServiceTests.cs +++ b/tests/Features/Infrastructure/Library/Scanning/UnmatchedScanBackgroundServiceTests.cs @@ -284,8 +284,8 @@ private static Mock CreateQueue( } else { - update.Callback?, string?>( - (_, status, results, error) => + update.Callback?, string?, List?>( + (_, status, results, error, warnings) => { failedJob.Status = status; failedJob.Error = error; @@ -293,6 +293,10 @@ private static Mock CreateQueue( { failedJob.Results = results; } + if (warnings != null) + { + failedJob.Warnings = warnings; + } }); } From 6e5b38e792b0a81b9a2042f60e4367e1546514b0 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Mon, 31 Aug 2026 19:17:57 -0400 Subject: [PATCH 3/6] fix: recover weak storage publication batches --- .../Contracts/CompatibilityBatchManifest.cs | 58 + .../Contracts/IDownloadImportService.cs | 4 +- .../IFilePublicationCapabilityResolver.cs | 17 +- .../DownloadImportService.Coordination.cs | 3 +- ...ownloadImportService.DirectoryOwnership.cs | 23 +- .../Import/DownloadImportService.Quality.cs | 49 + .../Downloads/Import/DownloadImportService.cs | 37 +- .../CompatibilityFilePublicationJournal.cs | 3 + listenarr.domain/Downloads/ImportResult.cs | 10 +- .../DownloadProcessingJobProcessor.cs | 26 +- ...ompatibilityFilePublicationJournalStore.cs | 86 +- ...ySourceCleanupCoordinator.BatchManifest.cs | 84 + ...rceCleanupCoordinator.QuarantineCleanup.cs | 41 + .../CompatibilitySourceCleanupCoordinator.cs | 91 +- .../FileMover.CompatibilityRegistration.cs | 4 +- ...atibilityFilePublicationRecoveryService.cs | 87 +- ...lityFilePublicationJournalConfiguration.cs | 2 + ..._AddCompatibilityBatchManifest.Designer.cs | 2762 +++++++++++++++++ ...830025709_AddCompatibilityBatchManifest.cs | 39 + .../ListenArrDbContextModelSnapshot.cs | 7 + tests/Common/PlatformFactAttributes.cs | 50 + .../Import/DownloadImportServiceTests.cs | 388 +++ .../DownloadProcessingJobProcessorTests.cs | 144 +- ...lityFilePublicationRecoveryServiceTests.cs | 336 +- ...patibilitySourceCleanupCoordinatorTests.cs | 30 + .../DockerWeakStorageImportContractTests.cs | 316 ++ .../Migrations/MigrationMetadataTests.cs | 7 + .../Persistence/SqliteMigrationSchemaTests.cs | 13 +- 28 files changed, 4612 insertions(+), 105 deletions(-) create mode 100644 listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs create mode 100644 listenarr.application/Downloads/Import/DownloadImportService.Quality.cs create mode 100644 listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs create mode 100644 listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.cs create mode 100644 tests/Features/Infrastructure/FileSystem/DockerWeakStorageImportContractTests.cs diff --git a/listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs b/listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs new file mode 100644 index 000000000..14948b910 --- /dev/null +++ b/listenarr.application/Downloads/Contracts/CompatibilityBatchManifest.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Listenarr.Application.Downloads.Contracts; + +public readonly record struct CompatibilityBatchManifest( + int ExpectedMemberCount, + string SourceManifestSha256) +{ + public static CompatibilityBatchManifest Create( + IEnumerable sourcePaths) + { + ArgumentNullException.ThrowIfNull(sourcePaths); + var normalized = sourcePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.Ordinal) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + if (normalized.Length == 0) + { + throw new ArgumentException( + "A compatibility batch manifest requires at least one source path.", + nameof(sourcePaths)); + } + + var payload = Encoding.UTF8.GetBytes(string.Join('\0', normalized)); + return new CompatibilityBatchManifest( + normalized.Length, + Convert.ToHexString(SHA256.HashData(payload))); + } + + public bool Matches(IEnumerable sourcePaths) + { + var actual = Create(sourcePaths); + return actual.ExpectedMemberCount == ExpectedMemberCount + && string.Equals( + actual.SourceManifestSha256, + SourceManifestSha256, + StringComparison.OrdinalIgnoreCase); + } + + public void Validate() + { + if (ExpectedMemberCount <= 0) + { + throw new InvalidOperationException( + "A compatibility batch manifest must contain at least one expected member."); + } + if (string.IsNullOrWhiteSpace(SourceManifestSha256) + || SourceManifestSha256.Length != 64 + || !SourceManifestSha256.All(Uri.IsHexDigit)) + { + throw new InvalidOperationException( + "A compatibility batch manifest SHA-256 must contain exactly 64 hexadecimal characters."); + } + } +} diff --git a/listenarr.application/Downloads/Contracts/IDownloadImportService.cs b/listenarr.application/Downloads/Contracts/IDownloadImportService.cs index f95bcd211..a9dcede72 100644 --- a/listenarr.application/Downloads/Contracts/IDownloadImportService.cs +++ b/listenarr.application/Downloads/Contracts/IDownloadImportService.cs @@ -1,7 +1,9 @@ namespace Listenarr.Application.Downloads.Contracts { - public sealed record DownloadImportOptions(bool ForceArchiveExtraction = false); + public sealed record DownloadImportOptions( + bool ForceArchiveExtraction = false, + Guid? CompatibilityBatchId = null); /// /// Download import responsible for processing a given download importation diff --git a/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs b/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs index a2ca1abf6..9fd936a12 100644 --- a/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs +++ b/listenarr.application/Downloads/Contracts/IFilePublicationCapabilityResolver.cs @@ -30,10 +30,25 @@ public sealed record FilePublicationPlan( int? DestinationRootFolderId = null, int? DestinationPolicyRevision = null, int? SourceStorageContractRevision = null, - int? DestinationStorageContractRevision = null) + int? DestinationStorageContractRevision = null, + int? ExpectedBatchMemberCount = null, + string? ExpectedBatchSourceManifestSha256 = null) { public bool IsAllowed => Mode != FilePublicationExecutionMode.Blocked; + public FilePublicationPlan WithCompatibilityBatchManifest( + CompatibilityBatchManifest manifest) + { + manifest.Validate(); + return Mode == FilePublicationExecutionMode.CompatibilityCopyVerifiedCleanup + ? this with + { + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchSourceManifestSha256 = manifest.SourceManifestSha256 + } + : this; + } + public static FilePublicationPlan Durable(FileAction action) => new( action, diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs b/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs index feae4ee81..a4a381cab 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.Coordination.cs @@ -28,7 +28,8 @@ await moveQueueService.EnsureFilesystemMutationAllowedAsync( token) ?? throw new InvalidOperationException( $"Audiobook {audiobook.Id} no longer exists"); - var compatibilityBatchId = Guid.NewGuid(); + var compatibilityBatchId = options?.CompatibilityBatchId + ?? Guid.NewGuid(); var results = await ImportDownloadFilesCoreAsync( currentAudiobook, files, diff --git a/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs b/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs index 119296feb..576de4f0c 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.DirectoryOwnership.cs @@ -5,19 +5,20 @@ namespace Listenarr.Application.Downloads.Import; public partial class DownloadImportService { - private Task ResolvePublicationPlanAsync( + private async Task ResolvePublicationPlanAsync( FileAction requestedAction, string source, string destination, FilePublicationSourceProof sourceProof, Guid compatibilityBatchId, + CompatibilityBatchManifest? compatibilityBatchManifest, CancellationToken cancellationToken) { - return filePublicationCapabilityResolver == null - ? Task.FromResult(sourceProof.HasDurablePhysicalObjectIdentity + var plan = filePublicationCapabilityResolver == null + ? sourceProof.HasDurablePhysicalObjectIdentity ? FilePublicationPlan.Durable(requestedAction) - : FilePublicationPlan.Additive(requestedAction)) - : filePublicationCapabilityResolver.ResolveAsync( + : FilePublicationPlan.Additive(requestedAction) + : await filePublicationCapabilityResolver.ResolveAsync( requestedAction, source, destination, @@ -25,6 +26,12 @@ private Task ResolvePublicationPlanAsync( cancellationToken, compatibilityBatchId, CompatibilityCleanupOwner.DownloadClient); + if (compatibilityBatchManifest.HasValue) + { + plan = plan.WithCompatibilityBatchManifest( + compatibilityBatchManifest.Value); + } + return plan; } private static ImportResult CreateBlockedImportResult( @@ -112,6 +119,7 @@ private static ImportSourceDisposition ToImportSourceDisposition( FilePublicationSourceProof expectedSourceProof, int audiobookId, Guid compatibilityBatchId, + CompatibilityBatchManifest? compatibilityBatchManifest, CancellationToken cancellationToken) { expectedSourceProof.Validate(); @@ -127,6 +135,11 @@ private static ImportSourceDisposition ToImportSourceDisposition( cancellationToken, compatibilityBatchId, CompatibilityCleanupOwner.DownloadClient); + if (compatibilityBatchManifest.HasValue) + { + publicationPlan = publicationPlan.WithCompatibilityBatchManifest( + compatibilityBatchManifest.Value); + } if (!publicationPlan.IsAllowed) { logger.LogWarning( diff --git a/listenarr.application/Downloads/Import/DownloadImportService.Quality.cs b/listenarr.application/Downloads/Import/DownloadImportService.Quality.cs new file mode 100644 index 000000000..95f339d2f --- /dev/null +++ b/listenarr.application/Downloads/Import/DownloadImportService.Quality.cs @@ -0,0 +1,49 @@ +namespace Listenarr.Application.Downloads.Import; + +public partial class DownloadImportService +{ + private static string? ResolveBestExistingQuality( + Audiobook audiobook, + QualityProfile? profile) + { + string? bestExisting = null; + if (audiobook.Files == null || audiobook.Files.Count == 0) + { + return bestExisting; + } + + foreach (var file in audiobook.Files) + { + var quality = file.Format ?? string.Empty; + if (file.Bitrate.HasValue) + { + var kbps = file.Bitrate.Value / 1000; + if (kbps >= 320) quality = "MP3 320kbps"; + else if (kbps >= 256) quality = "MP3 256kbps"; + else if (kbps >= 192) quality = "MP3 192kbps"; + else if (kbps >= 128) quality = "MP3 128kbps"; + } + + if (string.IsNullOrEmpty(quality) && !string.IsNullOrEmpty(file.Path)) + { + quality = ImportQualityEvaluator.Determine(null, file.Path); + } + + if (string.IsNullOrEmpty(bestExisting)) + { + bestExisting = quality; + } + else if (!string.IsNullOrEmpty(quality) + && profile != null + && ImportQualityEvaluator.IsAcceptable( + quality, + bestExisting, + profile)) + { + bestExisting = quality; + } + } + + return bestExisting; + } +} diff --git a/listenarr.application/Downloads/Import/DownloadImportService.cs b/listenarr.application/Downloads/Import/DownloadImportService.cs index 5296e87b8..3ebf064cb 100644 --- a/listenarr.application/Downloads/Import/DownloadImportService.cs +++ b/listenarr.application/Downloads/Import/DownloadImportService.cs @@ -140,30 +140,15 @@ private async Task> ImportDownloadFilesCoreAsync( var orderedFiles = plannedAudioFiles.Select(p => p.FullPath) .Concat(sourceFiles.Where(f => !planByPath.ContainsKey(f))) .ToList(); + var compatibilityBatchManifest = + completedFileAction == FileAction.Move && orderedFiles.Count > 0 + ? CompatibilityBatchManifest.Create(orderedFiles) + : (CompatibilityBatchManifest?)null; try { - string? bestExisting = null; - QualityProfile? abProfile = audiobook.QualityProfile; - if (audiobook.Files != null && audiobook.Files.Count != 0) - { - foreach (var f in audiobook.Files) - { - string q = string.Empty; - if (!string.IsNullOrEmpty(f.Format)) q = f.Format; - if (f.Bitrate.HasValue) - { - var kb = f.Bitrate.Value / 1000; - if (kb >= 320) q = "MP3 320kbps"; - else if (kb >= 256) q = "MP3 256kbps"; - else if (kb >= 192) q = "MP3 192kbps"; - else if (kb >= 128) q = "MP3 128kbps"; - } - if (string.IsNullOrEmpty(q) && !string.IsNullOrEmpty(f.Path)) q = ImportQualityEvaluator.Determine(null, f.Path); - if (string.IsNullOrEmpty(bestExisting)) bestExisting = q; - else if (!string.IsNullOrEmpty(q) && !string.IsNullOrEmpty(bestExisting) && abProfile != null && ImportQualityEvaluator.IsAcceptable(q, bestExisting, abProfile)) bestExisting = q; - } - } + var abProfile = audiobook.QualityProfile; + var bestExisting = ResolveBestExistingQuality(audiobook, abProfile); foreach (var file in orderedFiles) { @@ -177,7 +162,9 @@ private async Task> ImportDownloadFilesCoreAsync( var hasSuccessfulAudioImport = results.Any(r => r.Success && !string.IsNullOrWhiteSpace(r.FinalPath) && !string.IsNullOrWhiteSpace(r.SourcePath) && FileUtils.IsAudioFile(r.SourcePath!)); if (!hasSuccessfulAudioImport || string.IsNullOrWhiteSpace(audiobook.BasePath)) { - results.Add(ImportResult.Skipped("No successful audio import in batch")); + results.Add(ImportResult.Skipped( + "No successful audio import in batch", + file)); logger.LogDebug("ImportFilesFromDirectory: Skipping companion file {File} because no successful audio import was recorded for the batch", file); continue; } @@ -227,6 +214,7 @@ await PerformOwnedFileActionAsync( sourceProof.Value, audiobook.Id, compatibilityBatchId, + compatibilityBatchManifest, ct); if (companionPublication == null) { @@ -282,7 +270,9 @@ await ResolvePublishableSourceProofAsync( { if (audiobook.Files != null && audiobook.Files.Count != 0 && !ImportQualityEvaluator.IsAcceptable(candidateQuality, bestExisting, abProfile)) { - results.Add(ImportResult.Skipped($"candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'")); + results.Add(ImportResult.Skipped( + $"candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'", + file)); logger.LogInformation($"Skipping import of file {file} for audiobook {audiobook.Id} because candidate quality '{candidateQuality}' is not better than existing '{bestExisting}'"); continue; } @@ -435,6 +425,7 @@ AudiobookFileOwnershipCheckOutcome.Available or destination, sourceProof.Value, compatibilityBatchId, + compatibilityBatchManifest, ct); if (!publicationPlan.IsAllowed) { diff --git a/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs b/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs index 426525f08..16bd3f03e 100644 --- a/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs +++ b/listenarr.domain/Downloads/CompatibilityFilePublicationJournal.cs @@ -61,6 +61,9 @@ public sealed class CompatibilityFilePublicationJournal public int? DestinationRootFolderId { get; set; } public int? DestinationPolicyRevision { get; set; } public int? DestinationStorageContractRevision { get; set; } + public int? ExpectedBatchMemberCount { get; set; } + [MaxLength(64)] + public string? ExpectedBatchSourceManifestSha256 { get; set; } [Required, MaxLength(4096)] public string SourcePath { get; set; } = string.Empty; [Required, MaxLength(4096)] diff --git a/listenarr.domain/Downloads/ImportResult.cs b/listenarr.domain/Downloads/ImportResult.cs index 94a701971..8437f85e8 100644 --- a/listenarr.domain/Downloads/ImportResult.cs +++ b/listenarr.domain/Downloads/ImportResult.cs @@ -112,12 +112,18 @@ public static ImportResult Exception(Exception exception, string sourcePath = "" }; } - public static ImportResult Skipped(string message) + public static ImportResult Skipped( + string message, + string? sourcePath = null) { return new ImportResult { Success = true, - Message = message + Message = message, + SourcePath = sourcePath, + SourceDisposition = string.IsNullOrWhiteSpace(sourcePath) + ? ImportSourceDisposition.Unchanged + : ImportSourceDisposition.Retained }; } } diff --git a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs index f05afe9a0..0504539a9 100644 --- a/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs +++ b/listenarr.infrastructure/Downloads/Processing/DownloadProcessingJobProcessor.cs @@ -17,6 +17,8 @@ */ using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace Listenarr.Infrastructure.Downloads.Processing @@ -277,12 +279,12 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d try { var downloadImportService = scope.ServiceProvider.GetRequiredService(); - var importOptions = isDirectDownload && string.Equals( - download.GetMetadataString(DirectDownloadMetadataKeys.RequiresArchiveExtraction), - bool.TrueString, - StringComparison.OrdinalIgnoreCase) - ? new DownloadImportOptions(ForceArchiveExtraction: true) - : null; + var importOptions = new DownloadImportOptions( + ForceArchiveExtraction: isDirectDownload && string.Equals( + download.GetMetadataString(DirectDownloadMetadataKeys.RequiresArchiveExtraction), + bool.TrueString, + StringComparison.OrdinalIgnoreCase), + CompatibilityBatchId: ResolveCompatibilityBatchId(job.Id)); results = await downloadImportService.ImportDownloadFilesAsync( audiobook, files, @@ -453,5 +455,17 @@ await ScheduleRetryAsync(job, downloadProcessingJobService, historyRepository, d correlationId, $"Unable to commit import finalization: {exception.Message}", cancellationToken); } } + + private static Guid ResolveCompatibilityBatchId(string jobId) + { + if (Guid.TryParse(jobId, out var parsed)) + { + return parsed; + } + + var hash = SHA256.HashData( + Encoding.UTF8.GetBytes("download-import:" + jobId)); + return new Guid(hash.AsSpan(0, 16)); + } } } diff --git a/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs b/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs index cf766d27b..7f80aaa17 100644 --- a/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs +++ b/listenarr.infrastructure/FileSystem/CompatibilityFilePublicationJournalStore.cs @@ -19,7 +19,9 @@ internal sealed record CompatibilityFilePublicationClaim( int? DestinationRootFolderId = null, int? DestinationPolicyRevision = null, int? SourceStorageContractRevision = null, - int? DestinationStorageContractRevision = null); + int? DestinationStorageContractRevision = null, + int? ExpectedBatchMemberCount = null, + string? ExpectedBatchSourceManifestSha256 = null); internal sealed class CompatibilityFilePublicationJournalStore( IDbContextFactory dbContextFactory, @@ -50,6 +52,14 @@ public async Task GetOrCreateAsync( var existing = await GetAsync(claim.OperationId, cancellationToken); if (existing != null) { + if (CanRebindLegacyRetainedAttempt(existing, claim)) + { + return await RebindLegacyRetainedAttemptAsync( + existing.OperationId, + claim, + cancellationToken); + } + ValidateClaim(existing, claim); return existing; } @@ -69,6 +79,8 @@ public async Task GetOrCreateAsync( DestinationRootFolderId = claim.DestinationRootFolderId, DestinationPolicyRevision = claim.DestinationPolicyRevision, DestinationStorageContractRevision = claim.DestinationStorageContractRevision, + ExpectedBatchMemberCount = claim.ExpectedBatchMemberCount, + ExpectedBatchSourceManifestSha256 = claim.ExpectedBatchSourceManifestSha256, SourcePath = Path.GetFullPath(claim.SourcePath), DestinationPath = Path.GetFullPath(claim.DestinationPath), SourceLength = claim.SourceLength, @@ -190,6 +202,71 @@ next is CompatibilityFilePublicationState.Completed }; } + private static bool CanRebindLegacyRetainedAttempt( + CompatibilityFilePublicationJournal journal, + CompatibilityFilePublicationClaim claim) => + journal.ProtocolVersion == CompatibilityFilePublicationProtocol.Current + && journal.State == CompatibilityFilePublicationState.Completed + && journal.SourceDisposition == CompatibilitySourceDisposition.Retained + && journal.CleanupOwner != CompatibilityCleanupOwner.None + && journal.CleanupOwner == claim.CleanupOwner + && journal.ExpectedBatchMemberCount == null + && string.IsNullOrWhiteSpace(journal.ExpectedBatchSourceManifestSha256) + && string.IsNullOrWhiteSpace(journal.QuarantinePath) + && claim.ExpectedBatchMemberCount.HasValue + && !string.IsNullOrWhiteSpace(claim.ExpectedBatchSourceManifestSha256) + && journal.RequestedAction == claim.RequestedAction + && string.Equals( + journal.SourcePath, + Path.GetFullPath(claim.SourcePath), + StringComparison.Ordinal) + && string.Equals( + journal.DestinationPath, + Path.GetFullPath(claim.DestinationPath), + StringComparison.Ordinal) + && journal.SourceLength == claim.SourceLength + && string.Equals( + journal.SourceSha256, + claim.SourceSha256, + StringComparison.OrdinalIgnoreCase) + && journal.IsCompanionFile == claim.IsCompanionFile + && journal.SourceRootFolderId == claim.SourceRootFolderId + && journal.DestinationRootFolderId == claim.DestinationRootFolderId; + + private async Task + RebindLegacyRetainedAttemptAsync( + Guid operationId, + CompatibilityFilePublicationClaim claim, + CancellationToken cancellationToken) + { + await using var context = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var journal = await context.CompatibilityFilePublicationJournals + .SingleAsync( + candidate => candidate.OperationId == operationId, + cancellationToken); + if (!CanRebindLegacyRetainedAttempt(journal, claim)) + { + ValidateClaim(journal, claim); + return journal; + } + + journal.BatchId = claim.BatchId; + journal.SourcePolicyRevision = claim.SourcePolicyRevision; + journal.SourceStorageContractRevision = claim.SourceStorageContractRevision; + journal.DestinationPolicyRevision = claim.DestinationPolicyRevision; + journal.DestinationStorageContractRevision = + claim.DestinationStorageContractRevision; + journal.ExpectedBatchMemberCount = claim.ExpectedBatchMemberCount; + journal.ExpectedBatchSourceManifestSha256 = + claim.ExpectedBatchSourceManifestSha256; + journal.State = CompatibilityFilePublicationState.RegistrationCommitted; + journal.Error = null; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await context.SaveChangesAsync(cancellationToken); + return journal; + } + private static void ValidateClaim( CompatibilityFilePublicationJournal journal, CompatibilityFilePublicationClaim claim) @@ -216,7 +293,12 @@ CompatibilityFilePublicationProtocol.RetainOnly or || journal.SourceStorageContractRevision != claim.SourceStorageContractRevision || journal.DestinationRootFolderId != claim.DestinationRootFolderId || journal.DestinationPolicyRevision != claim.DestinationPolicyRevision - || journal.DestinationStorageContractRevision != claim.DestinationStorageContractRevision)) + || journal.DestinationStorageContractRevision != claim.DestinationStorageContractRevision + || journal.ExpectedBatchMemberCount != claim.ExpectedBatchMemberCount + || !string.Equals( + journal.ExpectedBatchSourceManifestSha256, + claim.ExpectedBatchSourceManifestSha256, + StringComparison.OrdinalIgnoreCase))) || !string.Equals( journal.SourceSha256, claim.SourceSha256, diff --git a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs new file mode 100644 index 000000000..022cbb0f8 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.BatchManifest.cs @@ -0,0 +1,84 @@ +using Listenarr.Infrastructure.Persistence; + +namespace Listenarr.Infrastructure.FileSystem; + +public sealed partial class CompatibilitySourceCleanupCoordinator +{ + private static bool HasPersistedBatchManifest( + IReadOnlyCollection journals) => + journals.Count > 0 + && journals.All(journal => + journal.ExpectedBatchMemberCount.HasValue + && !string.IsNullOrWhiteSpace( + journal.ExpectedBatchSourceManifestSha256)); + + private static bool BatchManifestMatches( + IReadOnlyCollection journals) + { + var manifestJournals = journals + .Where(journal => + journal.ExpectedBatchMemberCount.HasValue + || !string.IsNullOrWhiteSpace( + journal.ExpectedBatchSourceManifestSha256)) + .ToList(); + if (manifestJournals.Count == 0) + { + // Released verified-cleanup journals predate persisted batch manifests. + // Same-process completion remains compatible; startup recovery keeps + // those older batches retain-only because it cannot prove completeness. + return true; + } + if (manifestJournals.Count != journals.Count) + { + return false; + } + + var first = manifestJournals[0]; + if (!first.ExpectedBatchMemberCount.HasValue + || string.IsNullOrWhiteSpace( + first.ExpectedBatchSourceManifestSha256)) + { + return false; + } + + var manifest = new CompatibilityBatchManifest( + first.ExpectedBatchMemberCount.Value, + first.ExpectedBatchSourceManifestSha256); + try + { + manifest.Validate(); + } + catch (InvalidOperationException) + { + return false; + } + + if (manifestJournals.Any(journal => + journal.ExpectedBatchMemberCount != manifest.ExpectedMemberCount + || !string.Equals( + journal.ExpectedBatchSourceManifestSha256, + manifest.SourceManifestSha256, + StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return manifest.Matches(journals.Select(journal => journal.SourcePath)); + } + + private async Task RetainBatchAsync( + ListenArrDbContext context, + IReadOnlyCollection journals, + CancellationToken cancellationToken) + { + foreach (var journal in journals.Where(journal => + journal.State == CompatibilityFilePublicationState.RegistrationCommitted)) + { + journal.SourceDisposition = CompatibilitySourceDisposition.Retained; + journal.State = CompatibilityFilePublicationState.Completed; + journal.Error = null; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + } + await context.SaveChangesAsync(cancellationToken); + } +} diff --git a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs new file mode 100644 index 000000000..3c7bb97b9 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.QuarantineCleanup.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public sealed partial class CompatibilitySourceCleanupCoordinator +{ + private void TryRemoveEmptyOwnedQuarantine(string path, Guid batchId) + { + try + { + var markerPath = Path.Join(path, OwnershipMarkerName); + var expectedMarker = JsonSerializer.Serialize(new + { + ProtocolVersion = CompatibilityFilePublicationProtocol.Current, + BatchId = batchId + }); + if (!File.Exists(markerPath) + || !string.Equals( + File.ReadAllText(markerPath), + expectedMarker, + StringComparison.Ordinal) + || Directory.EnumerateFileSystemEntries(path) + .Any(entry => !string.Equals(entry, markerPath, StringComparison.Ordinal))) + { + return; + } + + File.Delete(markerPath); + Directory.Delete(path, recursive: false); + } + catch (Exception exception) when (exception is not ( + OutOfMemoryException or StackOverflowException)) + { + logger.LogDebug( + exception, + "Could not remove empty compatibility quarantine {QuarantinePath}", + path); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs index 5f06aa8fb..da1cda4e0 100644 --- a/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs +++ b/listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Listenarr.Domain.Audiobooks.Enumerations; using Listenarr.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -43,12 +42,52 @@ public async Task CompleteBatchAsync( CompatibilityBatchCleanupDisposition.NotApplicable); } + if (journals.All(journal => + journal.State == CompatibilityFilePublicationState.Completed)) + { + if (journals.All(journal => + journal.ProtocolVersion == CompatibilityFilePublicationProtocol.Current + && journal.RequestedAction == FileAction.Move + && journal.CleanupOwner == CompatibilityCleanupOwner.DownloadClient + && journal.SourceDisposition + == CompatibilitySourceDisposition.DeferredToDownloadClient) + && HasPersistedBatchManifest(journals) + && BatchManifestMatches(journals) + && await PoliciesStillAuthorizeAsync( + context, + journals, + cancellationToken) + && journals.All(journal => ContentMatches( + journal.DestinationPath, + journal.TargetLength ?? journal.SourceLength, + journal.TargetSha256 ?? journal.SourceSha256))) + { + return new CompatibilityBatchCleanupResult( + CompatibilityBatchCleanupDisposition.DeferredToDownloadClient, + RetainedCount: journals.Count); + } + + if (journals.All(journal => + journal.SourceDisposition + == CompatibilitySourceDisposition.RetiredByListenarr)) + { + return new CompatibilityBatchCleanupResult( + CompatibilityBatchCleanupDisposition.RetiredByListenarr, + RemovedCount: journals.Count); + } + + return new CompatibilityBatchCleanupResult( + CompatibilityBatchCleanupDisposition.Retained, + RetainedCount: journals.Count); + } + if (!batchSucceeded || journals.Any(journal => journal.ProtocolVersion != CompatibilityFilePublicationProtocol.Current || journal.State != CompatibilityFilePublicationState.RegistrationCommitted || journal.RequestedAction != FileAction.Move || journal.CleanupOwner == CompatibilityCleanupOwner.None) + || !BatchManifestMatches(journals) || !await PoliciesStillAuthorizeAsync(context, journals, cancellationToken) || journals.Any(journal => !ContentMatches( journal.DestinationPath, @@ -436,54 +475,4 @@ CompatibilityFilePublicationState.RegistrationCommitted or } } - private void TryRemoveEmptyOwnedQuarantine(string path, Guid batchId) - { - try - { - var markerPath = Path.Join(path, OwnershipMarkerName); - var expectedMarker = JsonSerializer.Serialize(new - { - ProtocolVersion = CompatibilityFilePublicationProtocol.Current, - BatchId = batchId - }); - if (!File.Exists(markerPath) - || !string.Equals( - File.ReadAllText(markerPath), - expectedMarker, - StringComparison.Ordinal) - || Directory.EnumerateFileSystemEntries(path) - .Any(entry => !string.Equals(entry, markerPath, StringComparison.Ordinal))) - { - return; - } - - File.Delete(markerPath); - Directory.Delete(path, recursive: false); - } - catch (Exception exception) when (exception is not ( - OutOfMemoryException or StackOverflowException)) - { - logger.LogDebug( - exception, - "Could not remove empty compatibility quarantine {QuarantinePath}", - path); - } - } - - private async Task RetainBatchAsync( - ListenArrDbContext context, - IReadOnlyCollection journals, - CancellationToken cancellationToken) - { - foreach (var journal in journals.Where(journal => - journal.State == CompatibilityFilePublicationState.RegistrationCommitted)) - { - journal.SourceDisposition = CompatibilitySourceDisposition.Retained; - journal.State = CompatibilityFilePublicationState.Completed; - journal.Error = null; - journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; - } - await context.SaveChangesAsync(cancellationToken); - } - } diff --git a/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs index abcb9a2ab..2fb67f1f6 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.CompatibilityRegistration.cs @@ -102,7 +102,9 @@ private async Task plan.DestinationRootFolderId, plan.DestinationPolicyRevision, plan.SourceStorageContractRevision, - plan.DestinationStorageContractRevision), + plan.DestinationStorageContractRevision, + plan.ExpectedBatchMemberCount, + plan.ExpectedBatchSourceManifestSha256), cancellationToken); if (journal.State == CompatibilityFilePublicationState.NeedsAttention) { diff --git a/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs b/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs index 7ac339535..334e2889e 100644 --- a/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs +++ b/listenarr.infrastructure/Persistence/CompatibilityFilePublicationRecoveryService.cs @@ -10,6 +10,7 @@ internal interface ICompatibilityFilePublicationRecoveryService internal sealed class CompatibilityFilePublicationRecoveryService( IDbContextFactory dbContextFactory, + ICompatibilitySourceCleanupCoordinator compatibilitySourceCleanupCoordinator, TimeProvider timeProvider, ILogger logger) : ICompatibilityFilePublicationRecoveryService @@ -34,6 +35,8 @@ public async Task ReconcileAsync( cancellationToken.ThrowIfCancellationRequested(); await ReconcileOperationAsync(operationId, cancellationToken); } + + await RecoverManifestedBatchesAsync(cancellationToken); } private async Task ReconcileOperationAsync( @@ -123,9 +126,20 @@ CompatibilityFilePublicationState.SourceQuarantined or == CompatibilityFilePublicationProtocol.Current && journal.CleanupOwner != CompatibilityCleanupOwner.None) { - // The original batch must decide whether every publication succeeded. - // Startup recovery cannot reconstruct that manifest, so it revokes - // destructive authority and completes retain-only. + if (journal.BatchId.HasValue + && journal.ExpectedBatchMemberCount.HasValue + && !string.IsNullOrWhiteSpace( + journal.ExpectedBatchSourceManifestSha256)) + { + // A sealed manifest can be revalidated after every operation-level + // recovery pass completes. Leave this journal committed so the + // batch coordinator can decide the whole batch atomically. + return; + } + + // Released verified-cleanup journals predate persisted manifests. + // Without a durable expected-member set, startup cannot prove that + // another source should have produced a journal, so fail closed. journal.SourceDisposition = CompatibilitySourceDisposition.Retained; journal.State = CompatibilityFilePublicationState.Completed; journal.Error = "Interrupted compatibility batch recovered retain-only."; @@ -147,6 +161,73 @@ CompatibilityFilePublicationState.SourceQuarantined or await context.SaveChangesAsync(cancellationToken); } + private async Task RecoverManifestedBatchesAsync( + CancellationToken cancellationToken) + { + await using var context = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var batchIds = await context.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => + journal.BatchId.HasValue + && journal.State == CompatibilityFilePublicationState.RegistrationCommitted + && journal.CleanupOwner != CompatibilityCleanupOwner.None + && journal.ExpectedBatchMemberCount.HasValue + && journal.ExpectedBatchSourceManifestSha256 != null) + .Select(journal => journal.BatchId!.Value) + .Distinct() + .OrderBy(batchId => batchId) + .ToListAsync(cancellationToken); + + foreach (var batchId in batchIds) + { + cancellationToken.ThrowIfCancellationRequested(); + var journals = await context.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => journal.BatchId == batchId) + .ToListAsync(cancellationToken); + var incompleteSealedBatch = journals.Count > 0 + && journals.All(journal => + journal.ExpectedBatchMemberCount.HasValue + && journal.ExpectedBatchMemberCount.Value > 0 + && !string.IsNullOrWhiteSpace( + journal.ExpectedBatchSourceManifestSha256)) + && journals.Select(journal => journal.ExpectedBatchMemberCount!.Value) + .Distinct() + .Count() == 1 + && journals.Select(journal => journal.ExpectedBatchSourceManifestSha256) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() == 1 + && journals[0].ExpectedBatchMemberCount!.Value > journals.Count; + if (incompleteSealedBatch) + { + logger.LogInformation( + "Manifested compatibility batch {BatchId} is incomplete at startup ({ObservedCount}/{ExpectedCount}); leaving committed members pending for retry", + batchId, + journals.Count, + journals[0].ExpectedBatchMemberCount!.Value); + continue; + } + + try + { + await compatibilitySourceCleanupCoordinator.CompleteBatchAsync( + batchId, + batchSucceeded: true, + cancellationToken); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + logger.LogWarning( + exception, + "Manifested compatibility batch {BatchId} could not be recovered", + batchId); + } + } + } + private void ReconcileInterruptedCleanup( CompatibilityFilePublicationJournal journal) { diff --git a/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs index 4868d5053..a877e843a 100644 --- a/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs +++ b/listenarr.infrastructure/Persistence/Configurations/CompatibilityFilePublicationJournalConfiguration.cs @@ -22,6 +22,8 @@ public void Configure( .HasMaxLength(64); builder.Property(journal => journal.TargetSha256) .HasMaxLength(64); + builder.Property(journal => journal.ExpectedBatchSourceManifestSha256) + .HasMaxLength(64); builder.Property(journal => journal.Error) .HasMaxLength(2048); builder.Property(journal => journal.QuarantinePath) diff --git a/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs new file mode 100644 index 000000000..a71dc0b22 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.Designer.cs @@ -0,0 +1,2762 @@ +// +using System; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ListenArrDbContext))] + [Migration("20260830025709_AddCompatibilityBatchManifest")] + partial class AddCompatibilityBatchManifest + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.DownloadHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClient") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WasImported") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventDate"); + + b.HasIndex("DownloadId", "EventType"); + + b.ToTable("DownloadHistories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.History", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookExternalId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookTitle") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("NotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("ParentEventId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceTitle") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookExternalId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("\"IdempotencyKey\" IS NOT NULL"); + + b.HasIndex("Outcome"); + + b.HasIndex("Timestamp"); + + b.ToTable("History"); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.ProcessExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Arguments") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Stderr") + .HasColumnType("TEXT"); + + b.Property("Stdout") + .HasColumnType("TEXT"); + + b.Property("TimedOut") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ProcessExecutionLogs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Abridged") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AuthorAsins") + .HasColumnType("TEXT"); + + b.Property("Authors") + .HasColumnType("TEXT"); + + b.Property("BasePath") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Edition") + .HasColumnType("TEXT"); + + b.Property("Explicit") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastSearchTime") + .HasColumnType("TEXT"); + + b.Property("Monitored") + .HasColumnType("INTEGER"); + + b.Property("Narrators") + .HasColumnType("TEXT"); + + b.Property("OpenLibraryId") + .HasColumnType("TEXT"); + + b.Property("PublishYear") + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Quality") + .HasColumnType("TEXT"); + + b.Property("QualityProfileId") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastSearchTime"); + + b.HasIndex("Monitored"); + + b.HasIndex("QualityProfileId"); + + b.ToTable("Audiobooks"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookDeletionIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteFolder") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId") + .IsUnique() + .HasFilter("\"State\" <> 'Completed'"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("AudiobookId", "State"); + + b.ToTable("AudiobookDeletionIntents", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ValueRaw") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("Type", "ValueNormalized"); + + b.HasIndex("AudiobookId", "Type", "IsPrimary"); + + b.HasIndex("Type", "ValueNormalized", "Region"); + + b.ToTable("AudiobookExternalIdentifiers", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("Bitrate") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Container") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("REAL"); + + b.Property("Format") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("PathIdentityBoundary") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathIdentityReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("PathIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityObservedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.ToTable("AudiobookFiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SeriesAsin") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("AudiobookId", "IsPrimary"); + + b.HasIndex("AudiobookId", "SortOrder"); + + b.ToTable("AudiobookSeriesMemberships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AuthorCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SimilarAuthors") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorAsin", "Region"); + + b.HasIndex("AuthorNameNormalized", "Region") + .IsUnique(); + + b.ToTable("AuthorCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationOperationId") + .HasColumnType("TEXT"); + + b.Property("CreationWorkflow") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("ManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StateReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ManagedRootFolderId"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.HasIndex("CreationOperationId", "State"); + + b.ToTable("LibraryDirectoryOwnerships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("SourceCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourceOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId"); + + b.HasIndex("TargetOwnershipKey") + .IsUnique(); + + b.HasIndex("OwnershipId", "RelocationId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("AuthorNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredAuthors"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("SeriesNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredSeries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("EnqueuedAt") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ExecutionProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FailureKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("ForceCopyAndRetainSource") + .HasColumnType("INTEGER"); + + b.Property("IdentityKeyVersion") + .HasColumnType("INTEGER"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("RequestedPath") + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("RetainSource"); + + b.Property("SourceDirectoryCleanupState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Pending"); + + b.Property("SourceDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourcePolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetPolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("TargetRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("TargetStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("RelocationId"); + + b.HasIndex("AudiobookId", "Status"); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "Path") + .IsUnique(); + + b.ToTable("MoveJobCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleanupProtectionVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("CleanupState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CopyState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "RelativePath") + .IsUnique(); + + b.ToTable("MoveJobEntries", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveScanJobId") + .HasColumnType("TEXT"); + + b.Property("AttemptGeneration") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .HasColumnType("INTEGER"); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveScanHandoffs", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.QualityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGroupNames") + .HasColumnType("TEXT") + .HasColumnName("CustomGroupNames"); + + b.Property("CutoffQuality") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("MaximumAge") + .HasColumnType("INTEGER"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumScore") + .HasColumnType("INTEGER"); + + b.Property("MinimumSeeders") + .HasColumnType("INTEGER"); + + b.Property("MinimumSize") + .HasColumnType("INTEGER"); + + b.Property("MustContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustContain"); + + b.Property("MustNotContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustNotContain"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PreferNewerReleases") + .HasColumnType("INTEGER"); + + b.Property("PreferredFormats") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredFormats"); + + b.Property("PreferredLanguages") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredLanguages"); + + b.PrimitiveCollection("PreferredWords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualities") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Qualities"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("QualityProfiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PathIdentityKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("ResolvedCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("StorageContractRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeakStoragePolicyRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("WeakStorageSourceCleanupPolicy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("TEXT") + .HasDefaultValue("RetainSource"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault") + .IsUnique() + .HasDatabaseName("IX_RootFolders_SingleDefault") + .HasFilter("\"IsDefault\" = 1"); + + b.HasIndex("Name"); + + b.HasIndex("Path") + .IsUnique(); + + b.HasIndex("PathIdentityKey") + .IsUnique() + .HasFilter("\"PathIdentityKey\" IS NOT NULL"); + + b.ToTable("RootFolders", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CompletedJobs") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("DesiredIsDefault") + .HasColumnType("INTEGER"); + + b.Property("DesiredName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("RootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("TargetIdentityEnrollmentState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Authorized"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("TotalJobs") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRootFolderId") + .IsUnique() + .HasFilter("\"ActiveRootFolderId\" IS NOT NULL"); + + b.HasIndex("RootFolderId"); + + b.ToTable("RootFolderRelocations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("RelocationId", "CanonicalPath") + .IsUnique(); + + b.ToTable("RootFolderRelocationCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId", "AudiobookId") + .IsUnique(); + + b.ToTable("RootFolderRelocationSkippedItems", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.SeriesCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesAsin", "Region"); + + b.HasIndex("SeriesNameNormalized", "Region") + .IsUnique(); + + b.ToTable("SeriesCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.WeakStorageScanCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("ConfirmedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ExpectedResolvedPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("ExpectedStoredPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ScanToken") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ScanToken"); + + b.HasIndex("AudiobookId", "ConfirmedAt", "ExpiresAt"); + + b.ToTable("WeakStorageScanCandidates", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Headers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("HeadersJson"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ParametersJson"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RateLimitPerMinute") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApiConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowedFileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AudnexusApiUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompletedFileAction") + .HasColumnType("INTEGER"); + + b.Property("DefaultSearchLanguage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSearchRegion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscordApplicationId") + .HasColumnType("TEXT"); + + b.Property("DiscordBotAvatar") + .HasColumnType("TEXT"); + + b.Property("DiscordBotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscordBotToken") + .HasColumnType("TEXT"); + + b.Property("DiscordBotUsername") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandGroupName") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandSubcommandName") + .HasColumnType("TEXT"); + + b.Property("DiscordGuildId") + .HasColumnType("TEXT"); + + b.Property("DownloadCompletionStabilitySeconds") + .HasColumnType("INTEGER"); + + b.Property("EnableAmazonSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAudibleSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverArtDownload") + .HasColumnType("INTEGER"); + + b.Property("EnableMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableOpenLibrarySearch") + .HasColumnType("INTEGER"); + + b.Property("EnabledNotificationTriggers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExtractArchives") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadAutoSearch") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadHandlingEnabled") + .HasColumnType("INTEGER"); + + b.Property("FileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER"); + + b.Property("ImportBlacklistExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceMaxRetries") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceRetryInitialDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("MultiFileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("ProwlarrPort") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrTagFilter") + .HasColumnType("TEXT"); + + b.Property("ProwlarrUrl") + .HasColumnType("TEXT"); + + b.Property("ShowCompletedExternalDownloads") + .HasColumnType("INTEGER"); + + b.Property("UnmatchedScanConcurrency") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("WebhookUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Webhooks") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.CompatibilityFilePublicationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("BatchId") + .HasColumnType("TEXT"); + + b.Property("CleanupOwner") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("DestinationPolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("DestinationRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("DestinationStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("EffectiveAction") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ExpectedBatchSourceManifestSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsCompanionFile") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("QuarantinePath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RequestedAction") + .HasColumnType("INTEGER"); + + b.Property("SourceDisposition") + .HasColumnType("INTEGER"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TargetLength") + .HasColumnType("INTEGER"); + + b.Property("TargetSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("BatchId"); + + b.HasIndex("State"); + + b.ToTable("CompatibilityFilePublicationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveAudiobookDeduplicationKey") + .HasColumnType("INTEGER"); + + b.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadedSize") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("ExpectedFileSize") + .HasColumnType("INTEGER"); + + b.Property("FinalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryId") + .HasColumnType("INTEGER"); + + b.Property("ImportAttempts") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ImportBlockMessages") + .HasColumnType("TEXT"); + + b.Property("ImportBlockReason") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastImportedAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Metadata"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAudiobookDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveAudiobookDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("CompletedAt"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("Status"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RemoveCompletedDownloads") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SettingsJson"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseSSL") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DownloadClientConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("JobData") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("JobData"); + + b.Property("JobType") + .HasColumnType("INTEGER"); + + b.Property("MaxRetries") + .HasColumnType("INTEGER"); + + b.Property("NextRetryAt") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ProcessingLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("Status"); + + b.HasIndex("DownloadId", "Status"); + + b.ToTable("DownloadProcessingJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.FileMutationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourceParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("State"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("FileMutationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.RemotePathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RemotePathMappings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastAccessed") + .HasColumnType("TEXT"); + + b.Property("RememberMe") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalSettings") + .HasColumnType("TEXT"); + + b.Property("AnimeCategories") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnableAnimeStandardSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAutomaticSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableInteractiveSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableRss") + .HasColumnType("INTEGER"); + + b.Property("Implementation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestError") + .HasColumnType("TEXT"); + + b.Property("LastTestSuccessful") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumAge") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Retention") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Indexers"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.QualityProfile", "QualityProfile") + .WithMany() + .HasForeignKey("QualityProfileId"); + + b.Navigation("QualityProfile"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", null) + .WithMany("ExternalIdentifiers") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("Files") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("SeriesMemberships") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", null) + .WithMany() + .HasForeignKey("ManagedRootFolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithMany("PathMigrations") + .HasForeignKey("OwnershipId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("OwnershipPathMigrations") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ownership"); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("MoveJobs") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("CreatedDirectories") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("Entries") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithOne("ScanHandoff") + .HasForeignKey("Listenarr.Domain.Audiobooks.MoveScanHandoff", "MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") + .WithMany("Relocations") + .HasForeignKey("RootFolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RootFolder"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("CreatedDirectories") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("SkippedItems") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Navigation("ExternalIdentifiers"); + + b.Navigation("Files"); + + b.Navigation("SeriesMemberships"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Navigation("PathMigrations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("Entries"); + + b.Navigation("ScanHandoff"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Navigation("Relocations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("MoveJobs"); + + b.Navigation("OwnershipPathMigrations"); + + b.Navigation("SkippedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.cs b/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.cs new file mode 100644 index 000000000..60bdf3509 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260830025709_AddCompatibilityBatchManifest.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCompatibilityBatchManifest : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExpectedBatchMemberCount", + table: "CompatibilityFilePublicationJournals", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ExpectedBatchSourceManifestSha256", + table: "CompatibilityFilePublicationJournals", + type: "TEXT", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ExpectedBatchMemberCount", + table: "CompatibilityFilePublicationJournals"); + + migrationBuilder.DropColumn( + name: "ExpectedBatchSourceManifestSha256", + table: "CompatibilityFilePublicationJournals"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index d5b008e1c..0a1ad4343 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -1987,6 +1987,13 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(2048) .HasColumnType("TEXT"); + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ExpectedBatchSourceManifestSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("IsCompanionFile") .HasColumnType("INTEGER"); diff --git a/tests/Common/PlatformFactAttributes.cs b/tests/Common/PlatformFactAttributes.cs index 0b5183b43..aec3e0f75 100644 --- a/tests/Common/PlatformFactAttributes.cs +++ b/tests/Common/PlatformFactAttributes.cs @@ -47,6 +47,29 @@ public NativeStorageIdentityFactAttribute() } } +public sealed class NativeWeakStorageFactAttribute : FactAttribute +{ + public NativeWeakStorageFactAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires a native Linux weak-storage mount."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.PathEnvironmentVariable)) + || !string.Equals( + Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.ExpectationEnvironmentVariable), + "generic-fid", + StringComparison.OrdinalIgnoreCase)) + { + Skip = "The native test runner did not provide a generic-FID weak-storage mount."; + } + } +} + public sealed class NativeStorageRemountFactAttribute : FactAttribute { public const string PathEnvironmentVariable = @@ -80,6 +103,33 @@ public NativeStorageRemountFactAttribute() } } +public sealed class NativeWeakStorageRemountFactAttribute : FactAttribute +{ + public NativeWeakStorageRemountFactAttribute() + { + if (!OperatingSystem.IsLinux()) + { + Skip = "This test requires a native Linux weak-storage remount fixture."; + return; + } + + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PathEnvironmentVariable)) + || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.StatePathEnvironmentVariable)) + || string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PhaseEnvironmentVariable)) + || !string.Equals( + Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.ExpectationEnvironmentVariable), + "generic-fid", + StringComparison.OrdinalIgnoreCase)) + { + Skip = "The native test runner did not provide a generic-FID weak-storage remount fixture."; + } + } +} + public sealed class WindowsTheoryAttribute : TheoryAttribute { public WindowsTheoryAttribute() diff --git a/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs b/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs index 060623621..2bacadc4c 100644 --- a/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs +++ b/tests/Features/Application/Downloads/Import/DownloadImportServiceTests.cs @@ -1450,6 +1450,394 @@ await _applicationSettingsRepository.SaveAsync( CompatibilitySourceDisposition.DeferredToDownloadClient, journal.SourceDisposition); Assert.Equal(CompatibilityCleanupOwner.DownloadClient, journal.CleanupOwner); + var manifest = CompatibilityBatchManifest.Create([source]); + Assert.Equal(manifest.ExpectedMemberCount, journal.ExpectedBatchMemberCount); + Assert.Equal( + manifest.SourceManifestSha256, + journal.ExpectedBatchSourceManifestSha256); + publicationResolver.VerifyAll(); + } + + [Fact] + public async Task ImportDownloadFilesAsync_VerifiedCleanup_RetryWithStableBatchIdRemainsDeferred() + { + RootFolder? authorizedRoot = null; + var publicationResolver = new Mock( + MockBehavior.Strict); + publicationResolver.Setup(resolver => resolver.ResolveAsync( + FileAction.Move, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CompatibilityCleanupOwner.DownloadClient)) + .Returns(( + _, + _, + _, + _, + _, + batchId, + cleanupOwner) => + { + Assert.True(batchId.HasValue); + var root = Assert.IsType(authorizedRoot); + return Task.FromResult(FilePublicationPlan.VerifiedCleanup( + batchId.Value, + cleanupOwner, + sourceRootFolderId: null, + sourcePolicyRevision: null, + root.Id, + root.WeakStoragePolicyRevision, + sourceStorageContractRevision: null, + root.StorageContractRevision)); + }); + Init(builder => builder + .WithSingleton( + publicationResolver.Object) + .WithScoped()); + + var outputDirectory = FileService.GetTempDirectory( + "download-import-verified-retry-dst"); + authorizedRoot = await AddAuthorizedRootAsync(outputDirectory); + authorizedRoot.WeakStorageSourceCleanupPolicy = + WeakStorageSourceCleanupPolicy.DeleteSourceAfterVerifiedCopy; + authorizedRoot.WeakStoragePolicyRevision = 8; + authorizedRoot.StorageContractRevision = 13; + await _rootFolderRepository.UpdateAsync(authorizedRoot); + + var sourceDirectory = FileService.GetTempDirectory( + "download-import-verified-retry-src"); + var source = await FileService.GetFileAsync( + sourceDirectory, + "retry.mp3", + "audio"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Verified Retry") + .WithBasePath(outputDirectory) + .Build()); + await _applicationSettingsRepository.SaveAsync( + new ApplicationSettingsBuilder() + .WithOutputPath(outputDirectory) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("") + .WithFileNamingPattern("{Title}") + .WithMultiFileNamingPattern("{Title}") + .Build()); + var batchId = Guid.NewGuid(); + var options = new DownloadImportOptions( + CompatibilityBatchId: batchId); + var service = _provider.GetRequiredService(); + + var first = Assert.Single(await service.ImportDownloadFilesAsync( + audiobook, + [source], + options: options)); + var second = Assert.Single(await service.ImportDownloadFilesAsync( + audiobook, + [source], + options: options)); + + Assert.True(first.Success, first.Message); + Assert.True(second.Success, second.Message); + Assert.Equal(ImportSourceDisposition.Retired, first.SourceDisposition); + Assert.Equal(ImportSourceDisposition.Retired, second.SourceDisposition); + Assert.Equal( + "source_cleanup_deferred_to_download_client", + first.WarningCode); + Assert.Equal( + "source_cleanup_deferred_to_download_client", + second.WarningCode); + Assert.True(File.Exists(source)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.CompatibilityFilePublicationJournals + .SingleAsync(); + Assert.Equal(batchId, journal.BatchId); + Assert.Equal( + CompatibilityFilePublicationState.Completed, + journal.State); + Assert.Equal( + CompatibilitySourceDisposition.DeferredToDownloadClient, + journal.SourceDisposition); + publicationResolver.VerifyAll(); + } + + [Fact] + public async Task ImportDownloadFilesAsync_VerifiedCleanup_MultiFileAndCompanionPersistSameManifest() + { + RootFolder? authorizedRoot = null; + var publicationResolver = new Mock( + MockBehavior.Strict); + publicationResolver.Setup(resolver => resolver.ResolveAsync( + FileAction.Move, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CompatibilityCleanupOwner.DownloadClient)) + .Returns(( + _, + _, + _, + _, + _, + batchId, + cleanupOwner) => + { + Assert.True(batchId.HasValue); + var root = Assert.IsType(authorizedRoot); + return Task.FromResult(FilePublicationPlan.VerifiedCleanup( + batchId.Value, + cleanupOwner, + sourceRootFolderId: null, + sourcePolicyRevision: null, + root.Id, + root.WeakStoragePolicyRevision, + sourceStorageContractRevision: null, + root.StorageContractRevision)); + }); + Init(builder => builder + .WithSingleton( + publicationResolver.Object) + .WithScoped()); + + var outputDirectory = FileService.GetTempDirectory( + "download-import-verified-manifest-dst"); + authorizedRoot = await AddAuthorizedRootAsync(outputDirectory); + authorizedRoot.WeakStorageSourceCleanupPolicy = + WeakStorageSourceCleanupPolicy.DeleteSourceAfterVerifiedCopy; + authorizedRoot.WeakStoragePolicyRevision = 4; + authorizedRoot.StorageContractRevision = 6; + await _rootFolderRepository.UpdateAsync(authorizedRoot); + + var sourceDirectory = FileService.GetTempDirectory( + "download-import-verified-manifest-src"); + var part1 = await FileService.GetFileAsync( + sourceDirectory, + "Part 1.mp3", + "one"); + var part2 = await FileService.GetFileAsync( + sourceDirectory, + "Part 2.mp3", + "two"); + var companion = await FileService.GetFileAsync( + sourceDirectory, + "cover.jpg", + "cover"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Manifested Book") + .WithBasePath(outputDirectory) + .Build()); + await _applicationSettingsRepository.SaveAsync( + new ApplicationSettingsBuilder() + .WithOutputPath(outputDirectory) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("") + .WithFileNamingPattern("{Title}") + .WithMultiFileNamingPattern("{Title}-{DiskNumber:00}") + .Build()); + + var results = await _provider + .GetRequiredService() + .ImportDownloadFilesAsync( + audiobook, + [part1, part2, companion]); + + Assert.Equal(3, results.Count); + Assert.All(results, result => Assert.True(result.Success, result.Message)); + Assert.All(results, result => + Assert.Equal(ImportSourceDisposition.Retired, result.SourceDisposition)); + Assert.All(results, result => + Assert.Equal( + "source_cleanup_deferred_to_download_client", + result.WarningCode)); + Assert.True(File.Exists(part1)); + Assert.True(File.Exists(part2)); + Assert.True(File.Exists(companion)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journals = await db.CompatibilityFilePublicationJournals + .OrderBy(journal => journal.SourcePath) + .ToListAsync(); + Assert.Equal(3, journals.Count); + var batchId = Assert.Single(journals + .Select(journal => journal.BatchId) + .Distinct()); + Assert.True(batchId.HasValue); + var manifest = CompatibilityBatchManifest.Create( + [part1, part2, companion]); + Assert.All(journals, journal => + { + Assert.Equal( + CompatibilityFilePublicationState.Completed, + journal.State); + Assert.Equal( + CompatibilitySourceDisposition.DeferredToDownloadClient, + journal.SourceDisposition); + Assert.Equal( + manifest.ExpectedMemberCount, + journal.ExpectedBatchMemberCount); + Assert.Equal( + manifest.SourceManifestSha256, + journal.ExpectedBatchSourceManifestSha256); + }); + publicationResolver.VerifyAll(); + } + + [Fact] + public async Task ImportDownloadFilesAsync_VerifiedCleanup_SkippedCandidateRetainsPublishedSource() + { + RootFolder? authorizedRoot = null; + var publicationResolver = new Mock( + MockBehavior.Strict); + publicationResolver.Setup(resolver => resolver.ResolveAsync( + FileAction.Move, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + CompatibilityCleanupOwner.DownloadClient)) + .Returns(( + _, + _, + _, + _, + _, + batchId, + cleanupOwner) => + { + Assert.True(batchId.HasValue); + var root = Assert.IsType(authorizedRoot); + return Task.FromResult(FilePublicationPlan.VerifiedCleanup( + batchId.Value, + cleanupOwner, + sourceRootFolderId: null, + sourcePolicyRevision: null, + root.Id, + root.WeakStoragePolicyRevision, + sourceStorageContractRevision: null, + root.StorageContractRevision)); + }); + Init(builder => builder + .WithSingleton( + publicationResolver.Object) + .WithScoped()); + + var outputDirectory = FileService.GetTempDirectory( + "download-import-verified-skip-dst"); + authorizedRoot = await AddAuthorizedRootAsync(outputDirectory); + authorizedRoot.WeakStorageSourceCleanupPolicy = + WeakStorageSourceCleanupPolicy.DeleteSourceAfterVerifiedCopy; + authorizedRoot.WeakStoragePolicyRevision = 5; + authorizedRoot.StorageContractRevision = 7; + await _rootFolderRepository.UpdateAsync(authorizedRoot); + + var profile = await _qualityProfileRepository.AddAsync( + new QualityProfileBuilder().Build()); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Manifest Skip") + .WithBasePath(outputDirectory) + .WithQualityProfile(profile) + .Build()); + var existing = await FileService.GetFileAsync( + outputDirectory, + "existing.mp3", + "existing"); + await _audiobookFileRepository.AddAsync( + new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(existing) + .WithFormat("mp3") + .WithBitrate(192000) + .Build()); + + var sourceDirectory = FileService.GetTempDirectory( + "download-import-verified-skip-src"); + var high = await FileService.GetFileAsync( + sourceDirectory, + "high-source.mp3", + "high"); + var low = await FileService.GetFileAsync( + sourceDirectory, + "low-source.mp3", + "low"); + metadataServiceMock.AddMetadata( + "high-source", + new AudioMetadata + { + Title = "High Candidate", + BitRate = 320000 + }); + metadataServiceMock.AddMetadata( + "low-source", + new AudioMetadata + { + Title = "Low Candidate", + BitRate = 128000 + }); + await _applicationSettingsRepository.SaveAsync( + new ApplicationSettingsBuilder() + .WithOutputPath(outputDirectory) + .WithMoveFileOnCompleted() + .WithMetadataProcessing() + .WithFolderNamingPattern("") + .WithFileNamingPattern("{Title}") + .WithMultiFileNamingPattern("{Title}-{DiskNumber:00}") + .Build()); + + var results = await _provider + .GetRequiredService() + .ImportDownloadFilesAsync(audiobook, [high, low]); + + Assert.Equal(2, results.Count); + var imported = Assert.Single(results, result => + string.Equals(result.SourcePath, high, StringComparison.Ordinal)); + Assert.True(imported.Success, imported.Message); + Assert.Equal(ImportSourceDisposition.Retained, imported.SourceDisposition); + Assert.Equal("source_retained", imported.WarningCode); + Assert.Contains(results, result => + result.Success + && string.Equals(result.SourcePath, low, StringComparison.Ordinal) + && result.SourceDisposition == ImportSourceDisposition.Retained + && result.Message != null + && result.Message.Contains( + "not better", + StringComparison.OrdinalIgnoreCase)); + Assert.True(File.Exists(high)); + Assert.True(File.Exists(low)); + + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.CompatibilityFilePublicationJournals + .SingleAsync(); + var manifest = CompatibilityBatchManifest.Create([high, low]); + Assert.Equal(manifest.ExpectedMemberCount, journal.ExpectedBatchMemberCount); + Assert.Equal( + manifest.SourceManifestSha256, + journal.ExpectedBatchSourceManifestSha256); + Assert.Equal( + CompatibilityFilePublicationState.Completed, + journal.State); + Assert.Equal( + CompatibilitySourceDisposition.Retained, + journal.SourceDisposition); publicationResolver.VerifyAll(); } diff --git a/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorTests.cs b/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorTests.cs index 8da530fff..df4fc4b5e 100644 --- a/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorTests.cs +++ b/tests/Features/Infrastructure/Downloads/Processing/DownloadProcessingJobProcessorTests.cs @@ -220,9 +220,10 @@ public async Task Import_DirectDownloadArchivePlan_ForcesArchiveExtraction() [DirectDownloadMetadataKeys.RequiresArchiveExtraction] = true } }); - await _downloadProcessingJobRepository.AddAsync(new DownloadProcessingJobBuilder() - .WithDownload(download) - .Build()); + var job = await _downloadProcessingJobRepository.AddAsync( + new DownloadProcessingJobBuilder() + .WithDownload(download) + .Build()); // When await _provider.GetRequiredService() @@ -233,7 +234,142 @@ await _provider.GetRequiredService() It.Is(item => item.Id == audiobook.Id), It.Is>(files => files.Contains(archivePath)), It.IsAny(), - It.Is(options => options.ForceArchiveExtraction)), Times.Once); + It.Is(options => + options.ForceArchiveExtraction + && options.CompatibilityBatchId == Guid.Parse(job.Id))), Times.Once); + } + + [Fact] + public async Task Import_DeferredCompatibilityCleanup_PersistsSourceRetainedFalseAndStableBatchId() + { + var importService = new Mock(MockBehavior.Strict); + Init(builder => builder.WithSingleton(importService.Object)); + var sourceDirectory = FileService.GetTempDirectory( + "deferred-cleanup-processing-source"); + var sourcePath = await FileService.GetFileAsync( + sourceDirectory, + "book.m4b", + "audio"); + downloadClientGatewayMock.SourceFiles = [sourcePath]; + var audiobook = await CreateAudiobook(); + var finalPath = Path.Join(audiobook.BasePath, "book.m4b"); + await File.WriteAllTextAsync(finalPath, "audio"); + importService + .Setup(service => service.ImportDownloadFilesAsync( + It.Is(candidate => candidate.Id == audiobook.Id), + It.Is>(files => files.SequenceEqual(new[] { sourcePath })), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(( + Audiobook _, + List _, + CancellationToken _, + DownloadImportOptions _) => + [ImportResult.ImportSuccess( + FileAction.Move, + FileAction.Copy, + ImportSourceDisposition.Retired, + sourcePath, + finalPath, + wasRegisteredToAudiobook: true, + warningCode: "source_cleanup_deferred_to_download_client", + message: "Destination verified; source cleanup is deferred to the download client.")]); + var download = await _downloadRepository.AddAsync(new DownloadBuilder() + .WithCompletedStatus(at: DateTime.UtcNow) + .WithDownloadClientConfiguration(await CreateDownloadClientConfiguration()) + .WithAudiobook(audiobook) + .WithPath(sourceDirectory) + .Build()); + var job = await _downloadProcessingJobRepository.AddAsync( + new DownloadProcessingJobBuilder() + .WithDownload(download) + .Build()); + + await _provider.GetRequiredService() + .ProcessQueueAsync(CancellationToken.None); + + job = (await _downloadProcessingJobRepository.GetByIdAsync(job.Id))!; + Assert.True( + job.Status == ProcessingJobStatus.Completed, + $"Expected Completed, got {job.Status}: {job.ErrorMessage}; log: {string.Join(" | ", job.ProcessingLog)}"); + Assert.True(job.TryGetJobDataString( + Download.SourceRetainedMetadataKey, + out var sourceRetained)); + Assert.Equal(bool.FalseString, sourceRetained); + var persistedDownload = (await _downloadRepository.GetByIdAsync(download.Id))!; + Assert.Equal(DownloadStatus.Moved, persistedDownload.Status); + Assert.Equal( + bool.FalseString, + persistedDownload.GetMetadataString( + Download.SourceRetainedMetadataKey)); + importService.Verify(service => service.ImportDownloadFilesAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.Is(options => + options.CompatibilityBatchId == Guid.Parse(job.Id))), + Times.Once); + } + + [Fact] + public async Task Import_AllCandidatesSkipped_PersistsSourceRetainedTrue() + { + var importService = new Mock(MockBehavior.Strict); + Init(builder => builder.WithSingleton(importService.Object)); + var sourceDirectory = FileService.GetTempDirectory( + "all-skipped-processing-source"); + var sourcePath = await FileService.GetFileAsync( + sourceDirectory, + "candidate.m4b", + "candidate"); + downloadClientGatewayMock.SourceFiles = [sourcePath]; + var audiobook = await CreateAudiobook(); + var existingPath = await FileService.GetFileAsync( + audiobook.BasePath, + "existing.m4b", + "existing"); + await _audiobookFileRepository.AddAsync(new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(existingPath) + .WithFormat("m4b") + .Build()); + importService + .Setup(service => service.ImportDownloadFilesAsync( + It.Is(candidate => candidate.Id == audiobook.Id), + It.Is>(files => files.SequenceEqual(new[] { sourcePath })), + It.IsAny(), + It.IsAny())) + .ReturnsAsync([ + ImportResult.Skipped( + "candidate quality is not better than existing", + sourcePath) + ]); + var download = await _downloadRepository.AddAsync(new DownloadBuilder() + .WithCompletedStatus(at: DateTime.UtcNow) + .WithDownloadClientConfiguration(await CreateDownloadClientConfiguration()) + .WithAudiobook(audiobook) + .WithPath(sourceDirectory) + .Build()); + var job = await _downloadProcessingJobRepository.AddAsync( + new DownloadProcessingJobBuilder() + .WithDownload(download) + .Build()); + + await _provider.GetRequiredService() + .ProcessQueueAsync(CancellationToken.None); + + job = (await _downloadProcessingJobRepository.GetByIdAsync(job.Id))!; + Assert.Equal(ProcessingJobStatus.Completed, job.Status); + Assert.True(job.TryGetJobDataString( + Download.SourceRetainedMetadataKey, + out var sourceRetained)); + Assert.Equal(bool.TrueString, sourceRetained); + var persistedDownload = (await _downloadRepository.GetByIdAsync(download.Id))!; + Assert.Equal( + bool.TrueString, + persistedDownload.GetMetadataString( + Download.SourceRetainedMetadataKey)); + Assert.True(File.Exists(sourcePath)); } [Fact] diff --git a/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs b/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs index a29167347..d6823486a 100644 --- a/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs +++ b/tests/Features/Infrastructure/FileSystem/CompatibilityFilePublicationRecoveryServiceTests.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Listenarr.Tests.Common; @@ -35,10 +36,7 @@ public async Task ReconcileAsync_PlannedJournalWithTarget_PreservesBothAndMarksA }); await db.SaveChangesAsync(); } - var service = new CompatibilityFilePublicationRecoveryService( - factory, - TimeProvider.System, - NullLogger.Instance); + var service = CreateRecoveryService(factory); await service.ReconcileAsync(); @@ -51,4 +49,334 @@ public async Task ReconcileAsync_PlannedJournalWithTarget_PreservesBothAndMarksA CompatibilityFilePublicationState.NeedsAttention, journal.State); } + + [Fact] + public async Task ReconcileAsync_CompleteManifestedDownloadClientBatch_RestoresDeferredCleanup() + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + var scenario = await CreateCommittedCompanionBatchAsync( + factory, + includeManifest: true); + var service = CreateRecoveryService(factory); + + await service.ReconcileAsync(); + + await using var verification = await factory.CreateDbContextAsync(); + var journals = await verification.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => journal.BatchId == scenario.BatchId) + .OrderBy(journal => journal.SourcePath) + .ToListAsync(); + Assert.Equal(2, journals.Count); + Assert.All(journals, journal => + { + Assert.Equal(CompatibilityFilePublicationState.Completed, journal.State); + Assert.Equal( + CompatibilitySourceDisposition.DeferredToDownloadClient, + journal.SourceDisposition); + }); + Assert.All(scenario.Sources, source => Assert.True(File.Exists(source))); + } + + [Fact] + public async Task LegacyRetainOnlyAttempt_CanRebindToNewStableManifestedBatch() + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + var scenario = await CreateCommittedCompanionBatchAsync( + factory, + includeManifest: false); + var recovery = CreateRecoveryService(factory); + + await recovery.ReconcileAsync(); + + CompatibilityFilePublicationJournal legacy; + await using (var db = await factory.CreateDbContextAsync()) + { + legacy = await db.CompatibilityFilePublicationJournals + .AsNoTracking() + .OrderBy(journal => journal.SourcePath) + .FirstAsync(journal => journal.BatchId == scenario.BatchId); + } + Assert.Equal(CompatibilityFilePublicationState.Completed, legacy.State); + Assert.Equal(CompatibilitySourceDisposition.Retained, legacy.SourceDisposition); + Assert.Null(legacy.ExpectedBatchMemberCount); + + var manifest = CompatibilityBatchManifest.Create(scenario.Sources); + var stableBatchId = Guid.NewGuid(); + var store = new CompatibilityFilePublicationJournalStore( + factory, + TimeProvider.System); + var rebound = await store.GetOrCreateAsync( + new CompatibilityFilePublicationClaim( + legacy.OperationId, + legacy.RequestedAction, + legacy.SourcePath, + legacy.DestinationPath, + legacy.SourceLength, + legacy.SourceSha256, + legacy.IsCompanionFile, + stableBatchId, + legacy.CleanupOwner, + legacy.SourceRootFolderId, + legacy.SourcePolicyRevision, + legacy.DestinationRootFolderId, + legacy.DestinationPolicyRevision, + legacy.SourceStorageContractRevision, + legacy.DestinationStorageContractRevision, + manifest.ExpectedMemberCount, + manifest.SourceManifestSha256), + CancellationToken.None); + + Assert.Equal(stableBatchId, rebound.BatchId); + Assert.Equal( + CompatibilityFilePublicationState.RegistrationCommitted, + rebound.State); + Assert.Equal(CompatibilitySourceDisposition.Retained, rebound.SourceDisposition); + Assert.Equal(manifest.ExpectedMemberCount, rebound.ExpectedBatchMemberCount); + Assert.Equal( + manifest.SourceManifestSha256, + rebound.ExpectedBatchSourceManifestSha256); + Assert.Equal(legacy.SourcePath, rebound.SourcePath); + Assert.Equal(legacy.DestinationPath, rebound.DestinationPath); + } + + [Fact] + public async Task ReconcileAsync_IncompleteManifestedBatch_RemainsPendingUntilRetryCompletesManifest() + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + var scenario = await CreateCommittedCompanionBatchAsync( + factory, + includeManifest: true); + var missingSource = Path.Join( + Path.GetDirectoryName(scenario.Sources[0])!, + "source-3.nfo"); + await File.WriteAllTextAsync(missingSource, "metadata"); + + string missingDestination; + CompatibilityBatchManifest manifest; + await using (var db = await factory.CreateDbContextAsync()) + { + var journals = await db.CompatibilityFilePublicationJournals + .Where(journal => journal.BatchId == scenario.BatchId) + .ToListAsync(); + var rootId = Assert.IsType(journals[0].DestinationRootFolderId); + var root = await db.RootFolders.SingleAsync(candidate => candidate.Id == rootId); + missingDestination = Path.Join(root.Path, "book", "book.nfo"); + await File.WriteAllTextAsync(missingDestination, "metadata"); + manifest = CompatibilityBatchManifest.Create( + scenario.Sources.Append(missingSource)); + foreach (var journal in journals) + { + journal.ExpectedBatchMemberCount = manifest.ExpectedMemberCount; + journal.ExpectedBatchSourceManifestSha256 = manifest.SourceManifestSha256; + } + await db.SaveChangesAsync(); + } + + var service = CreateRecoveryService(factory); + await service.ReconcileAsync(); + + await using (var pending = await factory.CreateDbContextAsync()) + { + var journals = await pending.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => journal.BatchId == scenario.BatchId) + .ToListAsync(); + Assert.Equal(2, journals.Count); + Assert.All(journals, journal => + { + Assert.Equal( + CompatibilityFilePublicationState.RegistrationCommitted, + journal.State); + Assert.Equal( + CompatibilitySourceDisposition.Retained, + journal.SourceDisposition); + }); + + var template = journals[0]; + var bytes = await File.ReadAllBytesAsync(missingSource); + var sha256 = Convert.ToHexString(SHA256.HashData(bytes)); + pending.CompatibilityFilePublicationJournals.Add( + new CompatibilityFilePublicationJournal + { + OperationId = Guid.NewGuid(), + BatchId = scenario.BatchId, + ProtocolVersion = CompatibilityFilePublicationProtocol.Current, + RequestedAction = FileAction.Move, + EffectiveAction = FileAction.Copy, + SourceDisposition = CompatibilitySourceDisposition.Retained, + CleanupOwner = CompatibilityCleanupOwner.DownloadClient, + DestinationRootFolderId = template.DestinationRootFolderId, + DestinationPolicyRevision = template.DestinationPolicyRevision, + DestinationStorageContractRevision = + template.DestinationStorageContractRevision, + SourcePath = missingSource, + DestinationPath = missingDestination, + SourceLength = bytes.Length, + SourceSha256 = sha256, + TargetLength = bytes.Length, + TargetSha256 = sha256, + IsCompanionFile = true, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchSourceManifestSha256 = manifest.SourceManifestSha256, + State = CompatibilityFilePublicationState.RegistrationCommitted + }); + await pending.SaveChangesAsync(); + } + + await service.ReconcileAsync(); + + await using var verification = await factory.CreateDbContextAsync(); + var recovered = await verification.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => journal.BatchId == scenario.BatchId) + .ToListAsync(); + Assert.Equal(3, recovered.Count); + Assert.All(recovered, journal => + { + Assert.Equal(CompatibilityFilePublicationState.Completed, journal.State); + Assert.Equal( + CompatibilitySourceDisposition.DeferredToDownloadClient, + journal.SourceDisposition); + }); + } + + [Fact] + public async Task ReconcileAsync_LegacyBatchWithoutManifest_RecoversRetainOnly() + { + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + var scenario = await CreateCommittedCompanionBatchAsync( + factory, + includeManifest: false); + var service = CreateRecoveryService(factory); + + await service.ReconcileAsync(); + + await using var verification = await factory.CreateDbContextAsync(); + var journals = await verification.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => journal.BatchId == scenario.BatchId) + .ToListAsync(); + Assert.Equal(2, journals.Count); + Assert.All(journals, journal => + { + Assert.Equal(CompatibilityFilePublicationState.Completed, journal.State); + Assert.Equal(CompatibilitySourceDisposition.Retained, journal.SourceDisposition); + Assert.Equal( + "Interrupted compatibility batch recovered retain-only.", + journal.Error); + }); + } + + private CompatibilityFilePublicationRecoveryService CreateRecoveryService( + IDbContextFactory factory) + { + var health = new Mock(MockBehavior.Strict); + health.Setup(resolver => resolver.ResolveAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RootFolderStorageObservation( + RootFolderStorageState.Healthy, + RootFolderStorageReason.None, + Message: null, + CanConfirmCurrentFolder: false, + CanChangePath: true, + CanMutateFilesystem: true, + ConfirmationToken: null)); + var cleanup = new CompatibilitySourceCleanupCoordinator( + factory, + health.Object, + TimeProvider.System, + NullLogger.Instance); + return new CompatibilityFilePublicationRecoveryService( + factory, + cleanup, + TimeProvider.System, + NullLogger.Instance); + } + + private async Task CreateCommittedCompanionBatchAsync( + IDbContextFactory factory, + bool includeManifest) + { + var sourceDirectory = FileService.GetTempDirectory( + "compatibility-recovery-manifest-source"); + var destinationRoot = FileService.GetTempDirectory( + "compatibility-recovery-manifest-destination"); + var sources = new[] + { + Path.Join(sourceDirectory, "source-1.jpg"), + Path.Join(sourceDirectory, "source-2.cue") + }; + var destinations = new[] + { + Path.Join(destinationRoot, "book", "cover.jpg"), + Path.Join(destinationRoot, "book", "book.cue") + }; + Directory.CreateDirectory(Path.GetDirectoryName(destinations[0])!); + await File.WriteAllTextAsync(sources[0], "cover"); + await File.WriteAllTextAsync(sources[1], "chapters"); + await File.WriteAllTextAsync(destinations[0], "cover"); + await File.WriteAllTextAsync(destinations[1], "chapters"); + var batchId = Guid.NewGuid(); + var manifest = CompatibilityBatchManifest.Create(sources); + + await using var db = await factory.CreateDbContextAsync(); + var root = new RootFolder + { + Name = "Weak destination", + Path = destinationRoot, + WeakStorageSourceCleanupPolicy = + WeakStorageSourceCleanupPolicy.DeleteSourceAfterVerifiedCopy, + WeakStoragePolicyRevision = 7, + StorageContractRevision = 11 + }; + db.RootFolders.Add(root); + await db.SaveChangesAsync(); + + for (var index = 0; index < sources.Length; index++) + { + var bytes = await File.ReadAllBytesAsync(sources[index]); + var sha256 = Convert.ToHexString(SHA256.HashData(bytes)); + db.CompatibilityFilePublicationJournals.Add( + new CompatibilityFilePublicationJournal + { + OperationId = Guid.NewGuid(), + BatchId = batchId, + ProtocolVersion = CompatibilityFilePublicationProtocol.Current, + RequestedAction = FileAction.Move, + EffectiveAction = FileAction.Copy, + SourceDisposition = CompatibilitySourceDisposition.Retained, + CleanupOwner = CompatibilityCleanupOwner.DownloadClient, + DestinationRootFolderId = root.Id, + DestinationPolicyRevision = root.WeakStoragePolicyRevision, + DestinationStorageContractRevision = root.StorageContractRevision, + SourcePath = sources[index], + DestinationPath = destinations[index], + SourceLength = bytes.Length, + SourceSha256 = sha256, + TargetLength = bytes.Length, + TargetSha256 = sha256, + IsCompanionFile = true, + ExpectedBatchMemberCount = includeManifest + ? manifest.ExpectedMemberCount + : null, + ExpectedBatchSourceManifestSha256 = includeManifest + ? manifest.SourceManifestSha256 + : null, + State = CompatibilityFilePublicationState.RegistrationCommitted + }); + } + await db.SaveChangesAsync(); + + return new CommittedBatchScenario(batchId, sources); + } + + private sealed record CommittedBatchScenario( + Guid BatchId, + IReadOnlyList Sources); } diff --git a/tests/Features/Infrastructure/FileSystem/CompatibilitySourceCleanupCoordinatorTests.cs b/tests/Features/Infrastructure/FileSystem/CompatibilitySourceCleanupCoordinatorTests.cs index 711a2bd85..a88792113 100644 --- a/tests/Features/Infrastructure/FileSystem/CompatibilitySourceCleanupCoordinatorTests.cs +++ b/tests/Features/Infrastructure/FileSystem/CompatibilitySourceCleanupCoordinatorTests.cs @@ -52,6 +52,36 @@ public async Task CompleteBatchAsync_DownloadClientOwner_DefersWithoutDeletingSo journal.SourceDisposition); } + [Fact] + public async Task CompleteBatchAsync_ManifestExpectsMissingMember_RetainsExistingSource() + { + var scenario = await CreateScenarioAsync(CompatibilityCleanupOwner.DownloadClient); + var missingSource = Path.Join( + Path.GetDirectoryName(scenario.Source)!, + "source-never-published.m4b"); + var manifest = CompatibilityBatchManifest.Create( + [scenario.Source, missingSource]); + await using (var db = await scenario.Factory.CreateDbContextAsync()) + { + var journal = await db.CompatibilityFilePublicationJournals + .SingleAsync(candidate => candidate.OperationId == scenario.OperationId); + journal.ExpectedBatchMemberCount = manifest.ExpectedMemberCount; + journal.ExpectedBatchSourceManifestSha256 = manifest.SourceManifestSha256; + await db.SaveChangesAsync(); + } + var service = CreateService(scenario.Factory); + + var result = await service.CompleteBatchAsync( + scenario.BatchId, + batchSucceeded: true); + + Assert.Equal(CompatibilityBatchCleanupDisposition.Retained, result.Disposition); + Assert.True(File.Exists(scenario.Source)); + var retained = await LoadJournalAsync(scenario); + Assert.Equal(CompatibilityFilePublicationState.Completed, retained.State); + Assert.Equal(CompatibilitySourceDisposition.Retained, retained.SourceDisposition); + } + [Fact] public async Task CompleteBatchAsync_ChangedPolicyRevision_RetainsSource() { diff --git a/tests/Features/Infrastructure/FileSystem/DockerWeakStorageImportContractTests.cs b/tests/Features/Infrastructure/FileSystem/DockerWeakStorageImportContractTests.cs new file mode 100644 index 000000000..4a2a1d23f --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/DockerWeakStorageImportContractTests.cs @@ -0,0 +1,316 @@ +using System.Security.Cryptography; +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "DockerWeakStorageImportContractTests")] +[Trait("Category", "Infrastructure")] +public sealed class DockerWeakStorageImportContractTests : BaseTests +{ + [NativeWeakStorageRemountFact] + public async Task ManifestedDownloadClientCleanup_RecoversAfterWeakCifsRemount() + { + var mountPath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PathEnvironmentVariable)!; + var databasePath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.StatePathEnvironmentVariable)!; + var phase = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PhaseEnvironmentVariable)!; + + switch (phase.Trim().ToLowerInvariant()) + { + case "capture": + await CaptureManifestedRecoveryStateAsync(mountPath, databasePath); + break; + case "verify": + await VerifyManifestedRecoveryStateAsync(databasePath); + break; + default: + throw new InvalidOperationException( + $"Unknown native storage recovery phase '{phase}'."); + } + } + + [NativeWeakStorageFact] + public async Task VerifiedDownloadImport_MultiFileAndCompanion_SucceedsOnWeakCifs() + { + var mountPath = Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.PathEnvironmentVariable)!; + var rootPath = Path.Join( + mountPath, + "listenarr-native-import-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var root = new RootFolderBuilder() + .WithName("Native Weak CIFS") + .WithPath(rootPath) + .WithCaseSensitivityMode(FileSystemCaseSensitivityMode.Sensitive) + .Build(); + root.ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive; + root.PathIdentityState = PathIdentityState.Valid; + root.PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + rootPath, + semantics); + root.WeakStorageSourceCleanupPolicy = + WeakStorageSourceCleanupPolicy.DeleteSourceAfterVerifiedCopy; + root.WeakStoragePolicyRevision = 4; + root.StorageContractRevision = 6; + await _rootFolderRepository.AddAsync(root); + + var health = await _provider + .GetRequiredService() + .ResolveAsync(root); + Assert.Equal(RootFolderStorageState.Limited, health.State); + Assert.Equal(RootFolderStorageReason.IdentityUnsupported, health.Reason); + Assert.True(health.CanPublishAdditively); + Assert.False(health.CanMutateFilesystem); + + var sourceDirectory = Path.Join( + mountPath, + "download-native-source-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(sourceDirectory); + var part1 = await FileService.GetFileAsync( + sourceDirectory, + "Part 1.mp3", + "chapter-one"); + var part2 = await FileService.GetFileAsync( + sourceDirectory, + "Part 2.mp3", + "chapter-two"); + var companion = await FileService.GetFileAsync( + sourceDirectory, + "cover.jpg", + "cover-bytes"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Native Weak CIFS") + .WithBasePath(rootPath) + .Build()); + await _applicationSettingsRepository.SaveAsync( + new ApplicationSettingsBuilder() + .WithOutputPath(rootPath) + .WithMoveFileOnCompleted() + .WithoutMetadataProcessing() + .WithFolderNamingPattern("") + .WithFileNamingPattern("{Title}") + .WithMultiFileNamingPattern("{Title}-{DiskNumber:00}") + .Build()); + var batchId = Guid.NewGuid(); + + var results = await _provider + .GetRequiredService() + .ImportDownloadFilesAsync( + audiobook, + [part1, part2, companion], + options: new DownloadImportOptions( + CompatibilityBatchId: batchId)); + + Assert.Equal(3, results.Count); + Assert.All(results, result => Assert.True(result.Success, result.Message)); + Assert.All(results, result => Assert.Equal(FileAction.Move, result.RequestedAction)); + Assert.All(results, result => Assert.Equal(FileAction.Copy, result.EffectiveAction)); + var factory = _provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + var journals = await db.CompatibilityFilePublicationJournals + .AsNoTracking() + .Where(journal => journal.BatchId == batchId) + .OrderBy(journal => journal.SourcePath) + .ToListAsync(); + Assert.Equal(3, journals.Count); + var manifest = CompatibilityBatchManifest.Create([part1, part2, companion]); + Assert.All(journals, journal => + { + Assert.Equal( + CompatibilityFilePublicationState.Completed, + journal.State); + Assert.True( + journal.SourceDisposition + == CompatibilitySourceDisposition.DeferredToDownloadClient, + $"Expected download-client-owned cleanup, got {journal.SourceDisposition}: {journal.Error}"); + Assert.Equal( + manifest.ExpectedMemberCount, + journal.ExpectedBatchMemberCount); + Assert.Equal( + manifest.SourceManifestSha256, + journal.ExpectedBatchSourceManifestSha256); + Assert.True(File.Exists(journal.DestinationPath)); + }); + Assert.All(results, result => + Assert.Equal(ImportSourceDisposition.Retired, result.SourceDisposition)); + Assert.All(results, result => Assert.Equal( + "source_cleanup_deferred_to_download_client", + result.WarningCode)); + Assert.True(File.Exists(part1)); + Assert.True(File.Exists(part2)); + Assert.True(File.Exists(companion)); + } + + private static async Task CaptureManifestedRecoveryStateAsync( + string mountPath, + string databasePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); + if (File.Exists(databasePath)) + { + File.Delete(databasePath); + } + + await using var provider = BuildSqliteProvider(databasePath); + var factory = provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await db.Database.MigrateAsync(); + + var token = Guid.NewGuid().ToString("N"); + var sourceDirectory = Path.Join(mountPath, "compat-remount-source-" + token); + var destinationRoot = Path.Join(mountPath, "library-remount-" + token); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(destinationRoot); + var source1 = Path.Join(sourceDirectory, "Part 1.mp3"); + var source2 = Path.Join(sourceDirectory, "Part 2.mp3"); + var destination1 = Path.Join(destinationRoot, "Part 1.mp3"); + var destination2 = Path.Join(destinationRoot, "Part 2.mp3"); + await File.WriteAllTextAsync(source1, "remount-one"); + await File.WriteAllTextAsync(source2, "remount-two"); + File.Copy(source1, destination1); + File.Copy(source2, destination2); + + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var root = new RootFolder + { + Name = "Native Remount Weak CIFS", + Path = destinationRoot, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Sensitive, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + destinationRoot, + semantics), + WeakStorageSourceCleanupPolicy = + WeakStorageSourceCleanupPolicy.DeleteSourceAfterVerifiedCopy, + WeakStoragePolicyRevision = 7, + StorageContractRevision = 9 + }; + db.RootFolders.Add(root); + var audiobook = new AudiobookBuilder() + .WithTitle("Native Remount Weak CIFS") + .WithBasePath(destinationRoot) + .Build(); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var batchId = Guid.NewGuid(); + var manifest = CompatibilityBatchManifest.Create([source1, source2]); + foreach (var pair in new[] + { + (Source: source1, Destination: destination1), + (Source: source2, Destination: destination2) + }) + { + var bytes = await File.ReadAllBytesAsync(pair.Source); + var sha256 = Convert.ToHexString(SHA256.HashData(bytes)); + db.AudiobookFiles.Add( + new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(pair.Destination) + .WithSize(bytes.Length) + .Build()); + db.CompatibilityFilePublicationJournals.Add( + new CompatibilityFilePublicationJournal + { + OperationId = Guid.NewGuid(), + BatchId = batchId, + ProtocolVersion = CompatibilityFilePublicationProtocol.Current, + RequestedAction = FileAction.Move, + EffectiveAction = FileAction.Copy, + SourceDisposition = CompatibilitySourceDisposition.Retained, + CleanupOwner = CompatibilityCleanupOwner.DownloadClient, + DestinationRootFolderId = root.Id, + DestinationPolicyRevision = root.WeakStoragePolicyRevision, + DestinationStorageContractRevision = root.StorageContractRevision, + SourcePath = pair.Source, + DestinationPath = pair.Destination, + SourceLength = bytes.Length, + SourceSha256 = sha256, + TargetLength = bytes.Length, + TargetSha256 = sha256, + AudiobookId = audiobook.Id, + IsCompanionFile = false, + State = CompatibilityFilePublicationState.RegistrationCommitted, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchSourceManifestSha256 = manifest.SourceManifestSha256 + }); + } + await db.SaveChangesAsync(); + + var health = await CreateNativeStorageHealthResolver().ResolveAsync(root); + Assert.Equal(RootFolderStorageState.Limited, health.State); + Assert.Equal(RootFolderStorageReason.IdentityUnsupported, health.Reason); + Assert.All( + db.CompatibilityFilePublicationJournals.Where(journal => journal.BatchId == batchId), + journal => Assert.True(File.Exists(journal.SourcePath))); + } + + private static async Task VerifyManifestedRecoveryStateAsync(string databasePath) + { + Assert.True(File.Exists(databasePath), "The capture phase did not persist its SQLite state."); + await using var provider = BuildSqliteProvider(databasePath); + var factory = provider.GetRequiredService>(); + var healthResolver = CreateNativeStorageHealthResolver(); + var cleanupCoordinator = new CompatibilitySourceCleanupCoordinator( + factory, + healthResolver, + TimeProvider.System, + NullLogger.Instance); + var recovery = new CompatibilityFilePublicationRecoveryService( + factory, + cleanupCoordinator, + TimeProvider.System, + NullLogger.Instance); + + await recovery.ReconcileAsync(); + + await using var db = await factory.CreateDbContextAsync(); + var journals = await db.CompatibilityFilePublicationJournals + .AsNoTracking() + .OrderBy(journal => journal.SourcePath) + .ToListAsync(); + Assert.NotEmpty(journals); + Assert.All(journals, journal => + { + Assert.True( + journal.State == CompatibilityFilePublicationState.Completed, + $"Expected completed recovery, got {journal.State}: {journal.Error}"); + Assert.True( + journal.SourceDisposition + == CompatibilitySourceDisposition.DeferredToDownloadClient, + $"Expected recovered download-client cleanup deferral, got {journal.SourceDisposition}: {journal.Error}"); + Assert.True(File.Exists(journal.SourcePath)); + Assert.True(File.Exists(journal.DestinationPath)); + }); + } + + private static ServiceProvider BuildSqliteProvider(string databasePath) + { + var services = new ServiceCollection(); + services.AddDbContextFactory(options => + options.UseSqlite( + $"Data Source={databasePath}", + sqlite => sqlite.MigrationsAssembly( + typeof(ListenArrDbContext).Assembly.GetName().Name))); + return services.BuildServiceProvider(); + } + + private static IRootFolderStorageHealthResolver CreateNativeStorageHealthResolver() => + new RootFolderStorageHealthResolver( + new DirectoryObjectIdentityResolver(), + new FileSystemSemanticsResolver()); +} diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index 50268318d..6e51b6923 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -25,6 +25,13 @@ public void AddWeakStorageVerifiedCleanupMigration_IsDiscoverableByEf() "20260825021432_AddWeakStorageVerifiedCleanup"); } + [Fact] + public void AddCompatibilityBatchManifestMigration_IsDiscoverableByEf() + { + AssertMigrationId( + "20260830025709_AddCompatibilityBatchManifest"); + } + [Fact] public void AddImportBlacklistExtensionsMigration_IsDiscoverableByEf() { diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index e7ee53b65..3dcf89a96 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -42,6 +42,8 @@ public class SqliteMigrationSchemaTests : BaseTests "20260821141235_AddCompatibilityFilePublication"; private const string WeakStorageVerifiedCleanupMigrationId = "20260825021432_AddWeakStorageVerifiedCleanup"; + private const string CompatibilityBatchManifestMigrationId = + "20260830025709_AddCompatibilityBatchManifest"; private static (SqliteConnection Connection, ListenArrDbContext Context) CreateMigratedSqliteContext() @@ -173,6 +175,14 @@ public async Task WeakStorageMigration_AddsFailClosedMovePolicySnapshot() connection, "CompatibilityFilePublicationJournals", "DestinationStorageContractRevision")); + Assert.True(await ColumnExistsAsync( + connection, + "CompatibilityFilePublicationJournals", + "ExpectedBatchMemberCount")); + Assert.True(await ColumnExistsAsync( + connection, + "CompatibilityFilePublicationJournals", + "ExpectedBatchSourceManifestSha256")); Assert.Equal( "'RetainSource'", await ColumnDefaultAsync(connection, "MoveJobs", "SourceCleanupMode")); @@ -199,7 +209,8 @@ public async Task MigrationHistory_ContainsOnlyRetainedRepairsAndConsolidatedPrM MoveJobRelocationForeignKeyMigrationId, FileMutationParentGenerationProofsMigrationId, CompatibilityFilePublicationMigrationId, - WeakStorageVerifiedCleanupMigrationId + WeakStorageVerifiedCleanupMigrationId, + CompatibilityBatchManifestMigrationId ], postCanary); Assert.Contains("20251124102000_AddMoveJobSourcePath", applied); From 99285582414c43256e793336272626ddc20f6673 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Tue, 1 Sep 2026 23:13:08 -0400 Subject: [PATCH 4/6] fix: organize files on weak storage --- ...erifiedFileRenameTransactionCoordinator.cs | 94 + .../Audiobooks/Renaming/RenameModels.cs | 1 + .../Renaming/RenameService.Execution.cs | 44 +- .../Renaming/RenameService.Rollback.cs | 145 +- .../RenameService.VerifiedExecution.cs | 189 ++ .../Audiobooks/Renaming/RenameService.cs | 34 + .../Downloads/VerifiedFileRenameJournal.cs | 81 + .../Library/LibraryRegistrationExtensions.cs | 4 + ...ileRenameTransactionCoordinator.Helpers.cs | 397 +++ ...dFileRenameTransactionCoordinator.Lease.cs | 364 +++ ...erifiedFileRenameTransactionCoordinator.cs | 243 ++ .../VerifiedFileRenameJournalConfiguration.cs | 37 + .../Persistence/FileRenameCommitStore.cs | 401 ++- .../Persistence/FileRenameRecoveryProbe.cs | 14 +- ...yFilesystemStartupReconciliationService.cs | 6 + .../Persistence/ListenArrDbContext.cs | 1 + ...7_AddVerifiedFileRenameJournal.Designer.cs | 2852 +++++++++++++++++ ...0901142347_AddVerifiedFileRenameJournal.cs | 68 + .../ListenArrDbContextModelSnapshot.cs | 90 + ...erifiedFileRenameRecoveryService.Probes.cs | 144 + .../VerifiedFileRenameRecoveryService.cs | 436 +++ .../Audiobooks/Renaming/RenameServiceTests.cs | 547 +++- .../Architecture/BackendArchitectureTests.cs | 16 +- .../DockerWeakStorageOrganiseContractTests.cs | 504 +++ ...edFileRenameTransactionCoordinatorTests.cs | 278 ++ .../Migrations/MigrationMetadataTests.cs | 7 + .../Persistence/FileRenameCommitStoreTests.cs | 253 ++ ...systemStartupReconciliationServiceTests.cs | 22 +- .../Persistence/SqliteMigrationSchemaTests.cs | 21 +- .../VerifiedFileRenameRecoveryServiceTests.cs | 350 ++ 30 files changed, 7526 insertions(+), 117 deletions(-) create mode 100644 listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs create mode 100644 listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs create mode 100644 listenarr.domain/Downloads/VerifiedFileRenameJournal.cs create mode 100644 listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs create mode 100644 listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs create mode 100644 listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs create mode 100644 listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.Designer.cs create mode 100644 listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.cs create mode 100644 listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.Probes.cs create mode 100644 listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.cs create mode 100644 tests/Features/Infrastructure/FileSystem/DockerWeakStorageOrganiseContractTests.cs create mode 100644 tests/Features/Infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinatorTests.cs create mode 100644 tests/Features/Infrastructure/Persistence/VerifiedFileRenameRecoveryServiceTests.cs diff --git a/listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs b/listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs new file mode 100644 index 000000000..80248be6f --- /dev/null +++ b/listenarr.application/Audiobooks/Contracts/IVerifiedFileRenameTransactionCoordinator.cs @@ -0,0 +1,94 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Listenarr.Application.Audiobooks.Contracts; + +public readonly record struct VerifiedFileRenameBatchMember( + int AudiobookFileId, + string SourcePath, + string DestinationPath); + +public readonly record struct VerifiedFileRenameBatchManifest( + int ExpectedMemberCount, + string ManifestSha256) +{ + public static VerifiedFileRenameBatchManifest Create( + IEnumerable members) + { + ArgumentNullException.ThrowIfNull(members); + var normalized = members + .Select(member => new VerifiedFileRenameBatchMember( + member.AudiobookFileId, + Path.GetFullPath(member.SourcePath), + Path.GetFullPath(member.DestinationPath))) + .Distinct() + .OrderBy(member => member.AudiobookFileId) + .ThenBy(member => member.SourcePath, StringComparer.Ordinal) + .ThenBy(member => member.DestinationPath, StringComparer.Ordinal) + .ToArray(); + if (normalized.Length == 0) + { + throw new ArgumentException( + "A verified file-rename batch requires at least one member.", + nameof(members)); + } + + var payload = string.Join( + '\0', + normalized.Select(member => + $"{member.AudiobookFileId}\0{member.SourcePath}\0{member.DestinationPath}")); + return new VerifiedFileRenameBatchManifest( + normalized.Length, + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload)))); + } + + public void Validate() + { + if (ExpectedMemberCount <= 0) + { + throw new InvalidOperationException( + "A verified file-rename batch must contain at least one member."); + } + if (ManifestSha256.Length != 64 || !ManifestSha256.All(Uri.IsHexDigit)) + { + throw new InvalidOperationException( + "A verified file-rename manifest must contain a SHA-256 digest."); + } + } +} + +public sealed record VerifiedFileRenamePreparationResult( + bool Success, + IVerifiedFileRenameLease? Lease = null, + string? Error = null); + +public enum VerifiedFileRenameRetirementOutcome +{ + Completed, + SourceRetained, + NeedsAttention +} + +public interface IVerifiedFileRenameLease : IAsyncDisposable +{ + Guid OperationId { get; } + + Task RollBackAsync(CancellationToken cancellationToken = default); + + Task CompleteSourceRetirementAsync( + CancellationToken cancellationToken = default); +} + +public interface IVerifiedFileRenameTransactionCoordinator +{ + Task PrepareAsync( + string source, + string destination, + Guid operationId, + Guid batchId, + VerifiedFileRenameBatchManifest batchManifest, + int audiobookId, + int audiobookFileId, + FilePublicationSourceProof sourceProof, + CancellationToken cancellationToken = default); +} diff --git a/listenarr.application/Audiobooks/Renaming/RenameModels.cs b/listenarr.application/Audiobooks/Renaming/RenameModels.cs index 6270f0edb..7008d151b 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameModels.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameModels.cs @@ -92,6 +92,7 @@ public class FileRenameResultItem public bool Success { get; set; } internal Guid? OperationId { get; set; } internal Guid? RollbackOperationId { get; set; } + internal IVerifiedFileRenameLease? VerifiedRenameLease { get; set; } public bool RolledBack { get; set; } public string? Error { get; set; } } diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs index 1990bde4d..73b97483f 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.Execution.cs @@ -10,6 +10,7 @@ private async Task ExecuteFileRenameAsync( FileRenameOperation fileOperation, IReadOnlyCollection allowedRoots, FileSystemPathSemantics semantics, + RenameExecutionPlan executionPlan, CancellationToken cancellationToken) { var source = NormalizePath(fileOperation.CurrentPath); @@ -98,7 +99,8 @@ private async Task ExecuteFileRenameAsync( } var targetDirectory = Path.GetDirectoryName(destination); - if (!string.IsNullOrWhiteSpace(targetDirectory)) + if (!string.IsNullOrWhiteSpace(targetDirectory) + && !executionPlan.UseVerifiedProtocol) { await EnsureOwnedRenameHierarchyAsync( targetDirectory, @@ -118,7 +120,40 @@ await EnsureOwnedRenameHierarchyAsync( var operationId = Guid.NewGuid(); item.OperationId = operationId; bool moved; - if (databaseFile != null) + if (executionPlan.UseVerifiedProtocol) + { + var sourceProof = FindSourceProof( + executionPlan, + fileOperation.FileId); + if (!sourceProof.HasValue + || !executionPlan.BatchManifest.HasValue) + { + item.Error = + "Verified organize source proof is unavailable."; + return item; + } + + var preparation = await _verifiedFileRenameTransactionCoordinator + .PrepareAsync( + source, + destination, + operationId, + executionPlan.BatchId, + executionPlan.BatchManifest.Value, + audiobook.Id, + databaseFile?.Id ?? 0, + sourceProof.Value, + cancellationToken); + moved = preparation.Success && preparation.Lease != null; + item.VerifiedRenameLease = preparation.Lease; + if (!moved) + { + item.Error = preparation.Error + ?? "Verified file organize operation failed."; + return item; + } + } + else if (databaseFile != null) { if (string.IsNullOrWhiteSpace( databaseFile.PhysicalObjectIdentity)) @@ -158,6 +193,11 @@ await EnsureOwnedRenameHierarchyAsync( if (databaseFile != null) { databaseFile.ApplyPathIdentity(destination, destinationIdentity); + if (executionPlan.UseVerifiedProtocol + && !PathsEqual(source, destination, semantics)) + { + databaseFile.ClearPhysicalObjectIdentity(); + } } else if (fileOperation.FileId == 0 && !string.IsNullOrWhiteSpace(audiobook.FilePath)) diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs b/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs index 39d51d5c9..3068e7cff 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.Rollback.cs @@ -53,83 +53,100 @@ private async Task RollBackFileRenamesAsync( { if (!PathsEqual(item.PreviousPath, item.NewPath, semantics)) { - if (!_fileSystem.FileExists(item.NewPath)) + if (item.VerifiedRenameLease != null) { - rollbackSucceeded = false; - item.Error = "Rollback failed because the moved file could not be found."; - continue; - } - - if (!_fileSystem.TryValidateMutationTarget( - item.NewPath, - allowedRoots, - out var rollbackSource, - out _) - || !_fileSystem.TryValidateMutationTarget( - item.PreviousPath, - allowedRoots, - out var rollbackDestination, - out _)) - { - rollbackSucceeded = false; - item.Error = "Rollback paths could not be resolved safely within the allowed library roots."; - continue; - } - - var parent = Path.GetDirectoryName(rollbackDestination); - if (!string.IsNullOrWhiteSpace(parent)) - { - await EnsureOwnedRenameHierarchyAsync( - parent, - allowedRoots, - semantics, - audiobook.Id, - Guid.NewGuid(), - cancellationToken); - } - - // Compensation is also owner-bound and startup-discoverable. A fresh ID - // keeps completed compensation history from colliding with a later retry. - var rollbackOperationId = Guid.NewGuid(); - item.RollbackOperationId = rollbackOperationId; - bool moved; - if (item.FileId == 0) - { - moved = await _fileMover.PerformActionOn( - FileAction.Move, - rollbackSource, - rollbackDestination, - rollbackOperationId, - audiobook.Id, - audiobookFileId: 0); + var verifiedRollback = await item.VerifiedRenameLease + .RollBackAsync(cancellationToken); + await item.VerifiedRenameLease.DisposeAsync(); + item.VerifiedRenameLease = null; + if (!verifiedRollback) + { + rollbackSucceeded = false; + item.Error = + "Verified organize rollback could not restore the source safely."; + continue; + } } else { - var trackedFile = audiobook.Files?.FirstOrDefault( - candidate => candidate.Id == item.FileId); - if (string.IsNullOrWhiteSpace( - trackedFile?.PhysicalObjectIdentity)) + if (!_fileSystem.FileExists(item.NewPath)) { rollbackSucceeded = false; - item.Error = - "Rollback could not prove the tracked file generation."; + item.Error = "Rollback failed because the moved file could not be found."; continue; } - moved = await _fileMover - .MoveFilePreservingPhysicalIdentityAsync( + if (!_fileSystem.TryValidateMutationTarget( + item.NewPath, + allowedRoots, + out var rollbackSource, + out _) + || !_fileSystem.TryValidateMutationTarget( + item.PreviousPath, + allowedRoots, + out var rollbackDestination, + out _)) + { + rollbackSucceeded = false; + item.Error = "Rollback paths could not be resolved safely within the allowed library roots."; + continue; + } + + var parent = Path.GetDirectoryName(rollbackDestination); + if (!string.IsNullOrWhiteSpace(parent)) + { + await EnsureOwnedRenameHierarchyAsync( + parent, + allowedRoots, + semantics, + audiobook.Id, + Guid.NewGuid(), + cancellationToken); + } + + // Compensation is also owner-bound and startup-discoverable. A fresh ID + // keeps completed compensation history from colliding with a later retry. + var rollbackOperationId = Guid.NewGuid(); + item.RollbackOperationId = rollbackOperationId; + bool moved; + if (item.FileId == 0) + { + moved = await _fileMover.PerformActionOn( + FileAction.Move, rollbackSource, rollbackDestination, - trackedFile.PhysicalObjectIdentity, rollbackOperationId, audiobook.Id, - item.FileId); - } - if (!moved) - { - rollbackSucceeded = false; - item.Error = "Rollback file move failed."; - continue; + audiobookFileId: 0); + } + else + { + var trackedFile = audiobook.Files?.FirstOrDefault( + candidate => candidate.Id == item.FileId); + if (string.IsNullOrWhiteSpace( + trackedFile?.PhysicalObjectIdentity)) + { + rollbackSucceeded = false; + item.Error = + "Rollback could not prove the tracked file generation."; + continue; + } + + moved = await _fileMover + .MoveFilePreservingPhysicalIdentityAsync( + rollbackSource, + rollbackDestination, + trackedFile.PhysicalObjectIdentity, + rollbackOperationId, + audiobook.Id, + item.FileId); + } + if (!moved) + { + rollbackSucceeded = false; + item.Error = "Rollback file move failed."; + continue; + } } } diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs b/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs new file mode 100644 index 000000000..6238ff2b2 --- /dev/null +++ b/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs @@ -0,0 +1,189 @@ +using Listenarr.Domain.Common; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Application.Audiobooks.Renaming; + +public partial class RenameService +{ + private sealed record RenameExecutionPlan( + bool UseVerifiedProtocol, + Guid BatchId, + VerifiedFileRenameBatchManifest? BatchManifest, + IReadOnlyDictionary SourceProofs) + { + public static RenameExecutionPlan Durable( + IReadOnlyDictionary sourceProofs) => + new(false, Guid.Empty, null, sourceProofs); + } + + private sealed record RenameExecutionPlanningResult( + RenameExecutionPlan? Plan, + string? Error = null); + + private async Task BuildRenameExecutionPlanAsync( + Audiobook audiobook, + RenameOperation operation, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken) + { + var changed = (operation.FileRenames ?? []) + .Where(file => !PathsEqual( + NormalizePath(file.CurrentPath), + NormalizePath(file.NewPath), + semantics)) + .ToArray(); + if (changed.Length == 0) + { + return new RenameExecutionPlanningResult( + RenameExecutionPlan.Durable( + new Dictionary())); + } + + var proofs = new Dictionary(); + var members = new List(changed.Length); + var allMembersHaveDurableAuthority = true; + foreach (var fileOperation in changed) + { + cancellationToken.ThrowIfCancellationRequested(); + var source = ResolveTrackedSourcePath( + audiobook, + fileOperation, + semantics, + out var databaseFile, + out var trackedPathError); + if (trackedPathError != null) + { + return new RenameExecutionPlanningResult(null, trackedPathError); + } + + var destination = NormalizePath(fileOperation.NewPath); + var capability = await _filePublicationSourceCapability.CheckAsync( + source, + cancellationToken); + if (!capability.IsSupported || !capability.SourceProof.HasValue) + { + return new RenameExecutionPlanningResult( + null, + capability.Reason + ?? "The organize source cannot be verified safely."); + } + + var proof = capability.SourceProof.Value; + proof.Validate(); + proofs.Add(fileOperation.FileId, proof); + members.Add(new VerifiedFileRenameBatchMember( + fileOperation.FileId, + source, + destination)); + allMembersHaveDurableAuthority &= + proof.HasDurablePhysicalObjectIdentity + && (databaseFile == null + || !string.IsNullOrWhiteSpace( + databaseFile.PhysicalObjectIdentity)); + } + + if (allMembersHaveDurableAuthority) + { + return new RenameExecutionPlanningResult( + RenameExecutionPlan.Durable(proofs)); + } + + var manifest = VerifiedFileRenameBatchManifest.Create(members); + manifest.Validate(); + return new RenameExecutionPlanningResult( + new RenameExecutionPlan( + true, + Guid.NewGuid(), + manifest, + proofs)); + } + + private static FilePublicationSourceProof? FindSourceProof( + RenameExecutionPlan executionPlan, + int fileId) => + executionPlan.SourceProofs.TryGetValue(fileId, out var proof) + ? proof + : null; + + private static async Task DisposeVerifiedRenameLeasesAsync( + IEnumerable items) + { + foreach (var item in items) + { + if (item.VerifiedRenameLease == null) + { + continue; + } + + await item.VerifiedRenameLease.DisposeAsync(); + item.VerifiedRenameLease = null; + } + } + + private async Task CompleteVerifiedRenameSourceRetirementAsync( + IEnumerable items, + CancellationToken cancellationToken) + { + var itemList = items.ToList(); + var requiresAttention = false; + try + { + foreach (var item in itemList) + { + var lease = item.VerifiedRenameLease; + if (lease == null) + { + continue; + } + + try + { + var outcome = await lease.CompleteSourceRetirementAsync( + cancellationToken); + switch (outcome) + { + case VerifiedFileRenameRetirementOutcome.Completed: + break; + case VerifiedFileRenameRetirementOutcome.SourceRetained: + _logger.LogWarning( + "Verified organize operation {OperationId} committed owner metadata but retained the old source for file {FileId}", + lease.OperationId, + item.FileId); + break; + case VerifiedFileRenameRetirementOutcome.NeedsAttention: + requiresAttention = true; + item.Success = false; + item.Error = + "The organized destination changed after owner metadata committed and requires repair."; + _logger.LogError( + "Verified organize operation {OperationId} requires repair after owner metadata committed for file {FileId}", + lease.OperationId, + item.FileId); + break; + default: + throw new InvalidOperationException( + $"Unknown verified organize retirement outcome '{outcome}'."); + } + } + finally + { + await lease.DisposeAsync(); + item.VerifiedRenameLease = null; + } + + if (requiresAttention) + { + break; + } + } + + return !requiresAttention; + } + finally + { + // The batch may stop on NeedsAttention or an unexpected lease failure. + // Never leave later pinned handles alive after ExecuteRenameAsync returns. + await DisposeVerifiedRenameLeasesAsync(itemList); + } + } +} diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.cs b/listenarr.application/Audiobooks/Renaming/RenameService.cs index 0ae1bae47..8fd6b8bfc 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.cs @@ -40,6 +40,8 @@ public partial class RenameService : IRenameService private readonly IMoveQueueService _moveQueueService; private readonly ILibraryDirectoryOwnershipStore _directoryOwnershipStore; private readonly IFileRenameCommitStore _fileRenameCommitStore; + private readonly IFilePublicationSourceCapability _filePublicationSourceCapability; + private readonly IVerifiedFileRenameTransactionCoordinator _verifiedFileRenameTransactionCoordinator; public RenameService( IConfigurationService configService, @@ -56,6 +58,8 @@ public RenameService( IMoveQueueService moveQueueService, ILibraryDirectoryOwnershipStore directoryOwnershipStore, IFileRenameCommitStore fileRenameCommitStore, + IFilePublicationSourceCapability filePublicationSourceCapability, + IVerifiedFileRenameTransactionCoordinator verifiedFileRenameTransactionCoordinator, IRootFolderService? rootFolderService = null, IHistoryRepository? historyRepository = null) { @@ -75,6 +79,8 @@ public RenameService( _moveQueueService = moveQueueService ?? throw new ArgumentNullException(nameof(moveQueueService)); _directoryOwnershipStore = directoryOwnershipStore ?? throw new ArgumentNullException(nameof(directoryOwnershipStore)); _fileRenameCommitStore = fileRenameCommitStore ?? throw new ArgumentNullException(nameof(fileRenameCommitStore)); + _filePublicationSourceCapability = filePublicationSourceCapability ?? throw new ArgumentNullException(nameof(filePublicationSourceCapability)); + _verifiedFileRenameTransactionCoordinator = verifiedFileRenameTransactionCoordinator ?? throw new ArgumentNullException(nameof(verifiedFileRenameTransactionCoordinator)); } public async Task> PreviewRenameAsync(int[] audiobookIds, CancellationToken ct = default) @@ -265,6 +271,23 @@ private async Task ExecuteSingleAsync( return validationFailure; } + var executionPlanning = await BuildRenameExecutionPlanAsync( + audiobook, + operation, + semantics, + ct); + if (executionPlanning.Plan == null) + { + return new RenameResult + { + AudiobookId = audiobook.Id, + Success = false, + Error = executionPlanning.Error + ?? "The organize source cannot be verified safely." + }; + } + var executionPlan = executionPlanning.Plan; + // Honor cancellation through complete preflight. Once filesystem mutation // can begin, complete or roll back to a stable persisted state. var mutationToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); @@ -277,6 +300,7 @@ private async Task ExecuteSingleAsync( fileOperation, allowedRoots, semantics, + executionPlan, mutationToken); result.RenamedFiles.Add(fileResult); if (!fileResult.Success) @@ -368,6 +392,16 @@ await CommitRollbackStateAsync( return result; } + if (!await CompleteVerifiedRenameSourceRetirementAsync( + result.RenamedFiles, + CancellationToken.None)) + { + result.Success = false; + result.Error = + "The organize operation committed its path metadata, but a verified destination changed during source retirement and requires repair."; + return result; + } + await AddHistoryAsync(audiobook, result); } diff --git a/listenarr.domain/Downloads/VerifiedFileRenameJournal.cs b/listenarr.domain/Downloads/VerifiedFileRenameJournal.cs new file mode 100644 index 000000000..283041a25 --- /dev/null +++ b/listenarr.domain/Downloads/VerifiedFileRenameJournal.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; + +namespace Listenarr.Domain.Downloads; + +public static class VerifiedFileRenameProtocol +{ + public const int Current = 1; +} + +public enum VerifiedFileRenameState +{ + Planned, + TargetVerified, + OwnerMetadataReconciled, + SourceQuarantined, + SourceDeleted, + Completed, + CompletedSourceRetained, + RolledBack, + NeedsAttention +} + +/// +/// Durable owner-bound organize transaction for storage where persistent physical +/// generation identity is unavailable. Content hashes prove byte equality only; +/// destructive source retirement is permitted only while the original process still +/// holds the pinned source entry that was verified before publication. +/// +public sealed class VerifiedFileRenameJournal +{ + [Key] + public Guid OperationId { get; set; } + + public Guid BatchId { get; set; } + + public int ProtocolVersion { get; set; } = VerifiedFileRenameProtocol.Current; + + public int AudiobookId { get; set; } + + public int AudiobookFileId { get; set; } + + public int ExpectedBatchMemberCount { get; set; } + + [Required, MaxLength(64)] + public string ExpectedBatchManifestSha256 { get; set; } = string.Empty; + + [Required, MaxLength(4096)] + public string SourcePath { get; set; } = string.Empty; + + [Required, MaxLength(4096)] + public string DestinationPath { get; set; } = string.Empty; + + [Required, MaxLength(4096)] + public string StagingPath { get; set; } = string.Empty; + + [Required, MaxLength(4096)] + public string RetirementPath { get; set; } = string.Empty; + + public long SourceLength { get; set; } + + [Required, MaxLength(64)] + public string SourceSha256 { get; set; } = string.Empty; + + public int SourceRootFolderId { get; set; } + + public int SourceStorageContractRevision { get; set; } + + public int DestinationRootFolderId { get; set; } + + public int DestinationStorageContractRevision { get; set; } + + public VerifiedFileRenameState State { get; set; } = + VerifiedFileRenameState.Planned; + + [MaxLength(2048)] + public string? Error { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs index e61e7a548..ea53ad795 100644 --- a/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs +++ b/listenarr.infrastructure/DependencyInjection/Library/LibraryRegistrationExtensions.cs @@ -44,10 +44,14 @@ public static IServiceCollection AddLibraryServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs new file mode 100644 index 000000000..72d898a54 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Helpers.cs @@ -0,0 +1,397 @@ +using System.ComponentModel; +using Listenarr.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.FileSystem; + +public sealed partial class VerifiedFileRenameTransactionCoordinator +{ + internal enum RootContractValidation + { + Valid, + Unavailable, + Mismatch + } + + private async Task PersistNewJournalAsync( + VerifiedFileRenameJournal journal, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + if (await db.VerifiedFileRenameJournals + .AsNoTracking() + .AnyAsync( + candidate => candidate.OperationId == journal.OperationId, + cancellationToken)) + { + throw new InvalidOperationException( + "The verified organize operation ID is already in use."); + } + + db.VerifiedFileRenameJournals.Add(journal); + await db.SaveChangesAsync(cancellationToken); + } + + internal async Task GetJournalAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + return await db.VerifiedFileRenameJournals + .AsNoTracking() + .SingleOrDefaultAsync( + journal => journal.OperationId == operationId, + cancellationToken); + } + + internal async Task AdvanceAsync( + Guid operationId, + VerifiedFileRenameState state, + string? error, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var journal = await db.VerifiedFileRenameJournals + .SingleAsync( + candidate => candidate.OperationId == operationId, + cancellationToken); + if (!CanAdvance(journal.State, state)) + { + throw new InvalidOperationException( + $"Verified organize journal {operationId} cannot advance from {journal.State} to {state}."); + } + + journal.State = state; + journal.Error = error; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(cancellationToken); + } + + internal async Task MarkNeedsAttentionAsync( + Guid operationId, + string error, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var journal = await db.VerifiedFileRenameJournals + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken); + if (journal == null || IsTerminal(journal.State)) + { + return; + } + + journal.State = VerifiedFileRenameState.NeedsAttention; + journal.Error = error; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(cancellationToken); + } + + internal async Task MarkSourceRetainedAsync( + Guid operationId, + string reason, + CancellationToken cancellationToken) + { + await AdvanceAsync( + operationId, + VerifiedFileRenameState.CompletedSourceRetained, + reason, + cancellationToken); + } + + internal static bool IsTerminal(VerifiedFileRenameState state) => + state is VerifiedFileRenameState.Completed + or VerifiedFileRenameState.CompletedSourceRetained + or VerifiedFileRenameState.RolledBack + or VerifiedFileRenameState.NeedsAttention; + + private static bool CanAdvance( + VerifiedFileRenameState current, + VerifiedFileRenameState next) + { + if (current == next) + { + return true; + } + if (next == VerifiedFileRenameState.NeedsAttention) + { + return !IsTerminal(current); + } + + return current switch + { + VerifiedFileRenameState.Planned => next is + VerifiedFileRenameState.TargetVerified or + VerifiedFileRenameState.RolledBack, + VerifiedFileRenameState.TargetVerified => next is + VerifiedFileRenameState.OwnerMetadataReconciled or + VerifiedFileRenameState.RolledBack, + VerifiedFileRenameState.OwnerMetadataReconciled => next is + VerifiedFileRenameState.SourceQuarantined or + VerifiedFileRenameState.CompletedSourceRetained, + VerifiedFileRenameState.SourceQuarantined => next is + VerifiedFileRenameState.SourceDeleted or + VerifiedFileRenameState.CompletedSourceRetained, + VerifiedFileRenameState.SourceDeleted => next is + VerifiedFileRenameState.Completed or + VerifiedFileRenameState.CompletedSourceRetained, + _ => false + }; + } + + private static async Task CopyAndVerifyAsync( + PinnedDirectoryCreation.PinnedFileEntry source, + PinnedDirectoryCreation.PinnedFileEntry target, + FilePublicationSourceProof sourceProof, + CancellationToken cancellationToken) + { + await using var input = source.OpenReadStream( + bufferSize: 128 * 1024, + asynchronous: false); + await using var output = target.OpenWriteStream( + bufferSize: 128 * 1024, + asynchronous: false); + await input.CopyToAsync(output, 128 * 1024, cancellationToken); + await output.FlushAsync(cancellationToken); + output.Flush(flushToDisk: true); + if (!await target.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken)) + { + throw new InvalidOperationException( + "The verified organize staging file failed SHA-256 verification."); + } + } + + private async Task TryRollbackPreparedTargetAsync( + Guid operationId, + PinnedDirectoryCreation.PinnedDirectoryAnchor? destinationParent, + PinnedDirectoryCreation.PinnedFileEntry? targetEntry, + CancellationToken cancellationToken) + { + try + { + if (targetEntry != null && destinationParent != null) + { + var visibility = targetEntry.ProbeVisiblePathMatch(); + if (visibility == RegistrationPublicationMatchOutcome.Unavailable) + { + throw new IOException( + "The verified organize staging/target is temporarily unavailable during rollback."); + } + if (visibility == RegistrationPublicationMatchOutcome.Match) + { + targetEntry.Delete(immediateWindows: true); + destinationParent.FlushDirectoryEntry(); + } + } + + await AdvanceAsync( + operationId, + VerifiedFileRenameState.RolledBack, + error: null, + cancellationToken); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + await MarkNeedsAttentionAsync( + operationId, + "Verified organize preparation rollback requires attention: " + + exception.Message, + CancellationToken.None); + } + } + + internal async Task ValidateRootContractsAsync( + VerifiedFileRenameJournal journal, + CancellationToken cancellationToken) + { + var roots = await rootFolderRepository.GetAllAsync(); + var sourceRoot = roots.SingleOrDefault( + root => root.Id == journal.SourceRootFolderId); + var destinationRoot = roots.SingleOrDefault( + root => root.Id == journal.DestinationRootFolderId); + if (sourceRoot == null || destinationRoot == null + || sourceRoot.StorageContractRevision + != journal.SourceStorageContractRevision + || destinationRoot.StorageContractRevision + != journal.DestinationStorageContractRevision) + { + return RootContractValidation.Mismatch; + } + + try + { + var sourceHealth = await storageHealthResolver.ResolveAsync( + sourceRoot, + cancellationToken); + var destinationHealth = await storageHealthResolver.ResolveAsync( + destinationRoot, + cancellationToken); + if (!sourceHealth.CanRetireVerifiedSource + || !destinationHealth.CanPublishAdditively) + { + return sourceHealth.State is RootFolderStorageState.Missing + or RootFolderStorageState.Changed + || destinationHealth.State is RootFolderStorageState.Missing + or RootFolderStorageState.Changed + ? RootContractValidation.Mismatch + : RootContractValidation.Unavailable; + } + return RootContractValidation.Valid; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException) + { + return RootContractValidation.Unavailable; + } + } + + private static PinnedDirectoryCreation.PinnedDirectoryAnchor + OpenOrCreateVerifiedDestinationParent( + RootFolder destinationRoot, + string destinationParentPath) + { + var persisted = RootFolderPathSemantics.ResolvePersisted(destinationRoot) + ?? throw new InvalidOperationException( + "The verified organize destination root has no persisted path semantics."); + if (persisted.DetectAmbiguousCaseMatches + || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + destinationRoot.Path, + out var rootPath, + out _) + || !FileSystemPathIdentity.IsSameOrInside( + destinationParentPath, + rootPath, + persisted.Semantics)) + { + throw new InvalidOperationException( + "The verified organize destination parent is outside its configured root."); + } + + var current = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow(rootPath); + try + { + var segments = ResolveDestinationHierarchySegments( + rootPath, + destinationParentPath, + persisted.Semantics); + if (segments.Count == 0) + { + return current; + } + + foreach (var segment in segments) + { + PinnedDirectoryCreation.PinnedDirectoryAnchor next; + try + { + next = current.OpenExistingChild(segment); + } + catch (Win32Exception exception) when ( + exception.NativeErrorCode is 2 or 3) + { + using var creation = current.TryCreateChild(segment); + next = creation.Created + ? creation.OpenCreatedDirectoryAnchor() + : current.OpenExistingChild(segment); + } + + if (!next.VisiblePathMatches()) + { + next.Dispose(); + throw new InvalidOperationException( + "The verified organize destination hierarchy changed during additive creation."); + } + + current.Dispose(); + current = next; + } + + return current; + } + catch + { + current.Dispose(); + throw; + } + } + + internal static IReadOnlyList ResolveDestinationHierarchySegments( + string rootPath, + string destinationParentPath, + FileSystemPathSemantics semantics) + { + if (!FileSystemPathIdentity.TryGetRelativePathWithinBase( + rootPath, + destinationParentPath, + semantics, + out var relative)) + { + throw new InvalidOperationException( + "The verified organize destination parent could not be resolved relative to its configured root semantics."); + } + if (string.IsNullOrEmpty(relative)) + { + return []; + } + + var separators = semantics.Syntax == FileSystemPathSyntax.Windows + ? new[] { '\\', '/' } + : new[] { '/' }; + var segments = relative.Split( + separators, + StringSplitOptions.RemoveEmptyEntries); + if (segments.Any(segment => segment is "." or "..")) + { + throw new InvalidOperationException( + "The verified organize destination hierarchy contains a traversal segment."); + } + + return segments; + } + + private static RootFolder? FindContainingRoot( + string path, + IReadOnlyCollection roots) + { + var fullPath = Path.GetFullPath(path); + RootFolder? best = null; + var bestLength = -1; + foreach (var root in roots) + { + var persisted = RootFolderPathSemantics.ResolvePersisted(root); + if (!persisted.HasValue + || persisted.Value.DetectAmbiguousCaseMatches + || !FileSystemPathIdentity.TryCanonicalizeUnambiguousStoredAbsolutePathForHost( + root.Path, + out var rootPath, + out _) + || string.IsNullOrWhiteSpace(rootPath) + || !FileSystemPathIdentity.IsSameOrInside( + fullPath, + rootPath, + persisted.Value.Semantics)) + { + continue; + } + + if (rootPath.Length > bestLength) + { + best = root; + bestLength = rootPath.Length; + } + } + + return best; + } +} diff --git a/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs new file mode 100644 index 000000000..d032e9395 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.Lease.cs @@ -0,0 +1,364 @@ +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public sealed partial class VerifiedFileRenameTransactionCoordinator +{ + private sealed class VerifiedFileRenameLease( + VerifiedFileRenameTransactionCoordinator owner, + VerifiedFileRenameJournal journal, + PinnedDirectoryCreation.PinnedDirectoryAnchor sourceParent, + PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent, + PinnedDirectoryCreation.PinnedFileEntry sourceEntry, + PinnedDirectoryCreation.PinnedFileEntry targetEntry, + FilePublicationSourceProof sourceProof, + ILogger logger) + : IVerifiedFileRenameLease + { + private bool _disposed; + + public Guid OperationId => journal.OperationId; + + public async Task RollBackAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var current = await owner.GetJournalAsync( + journal.OperationId, + cancellationToken); + if (current == null) + { + return false; + } + if (current.State == VerifiedFileRenameState.RolledBack) + { + return true; + } + if (current.State != VerifiedFileRenameState.TargetVerified) + { + return false; + } + + try + { + var targetVisibility = targetEntry.ProbeVisiblePathMatch(); + if (targetVisibility != RegistrationPublicationMatchOutcome.Match + || !await targetEntry.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken)) + { + throw new InvalidOperationException( + "The verified organize target changed before rollback."); + } + if (sourceEntry.ProbeVisiblePathMatch() + != RegistrationPublicationMatchOutcome.Match + || !await sourceEntry.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken)) + { + throw new InvalidOperationException( + "The verified organize source changed before rollback."); + } + + targetEntry.Delete(immediateWindows: true); + destinationParent.FlushDirectoryEntry(); + await owner.AdvanceAsync( + journal.OperationId, + VerifiedFileRenameState.RolledBack, + error: null, + CancellationToken.None); + return true; + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + logger.LogWarning( + exception, + "Verified organize rollback for {OperationId} requires attention", + journal.OperationId); + await owner.MarkNeedsAttentionAsync( + journal.OperationId, + "Verified organize rollback could not prove the original source and published target remained unchanged.", + CancellationToken.None); + return false; + } + } + + public async Task CompleteSourceRetirementAsync( + CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var current = await owner.GetJournalAsync( + journal.OperationId, + cancellationToken); + if (current == null) + { + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + if (current.State == VerifiedFileRenameState.Completed) + { + return VerifiedFileRenameRetirementOutcome.Completed; + } + if (current.State == VerifiedFileRenameState.CompletedSourceRetained) + { + return VerifiedFileRenameRetirementOutcome.SourceRetained; + } + if (current.State == VerifiedFileRenameState.NeedsAttention) + { + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + if (current.State == VerifiedFileRenameState.SourceDeleted) + { + await owner.AdvanceAsync( + journal.OperationId, + VerifiedFileRenameState.Completed, + error: null, + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.Completed; + } + if (current.State is not ( + VerifiedFileRenameState.OwnerMetadataReconciled + or VerifiedFileRenameState.SourceQuarantined)) + { + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + + var originalSourceName = Path.GetFileName(journal.SourcePath); + var retirementName = Path.GetFileName(journal.RetirementPath); + try + { + if (current.State == VerifiedFileRenameState.OwnerMetadataReconciled) + { + var contracts = await owner.ValidateRootContractsAsync( + current, + cancellationToken); + if (contracts != RootContractValidation.Valid) + { + await owner.MarkSourceRetainedAsync( + journal.OperationId, + "Owner metadata was committed, but current storage contracts no longer authorize live source retirement. The old source was retained.", + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.SourceRetained; + } + + if (!await TargetStillMatchesAsync(cancellationToken)) + { + await owner.MarkNeedsAttentionAsync( + journal.OperationId, + "Owner metadata was committed, but the verified organize target changed before source retirement. The old source was retained and the tracked destination requires repair.", + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + if (!await SourceStillMatchesAsync(cancellationToken)) + { + await owner.MarkSourceRetainedAsync( + journal.OperationId, + "Owner metadata was committed, but the original pinned source changed before retirement. The source path was retained.", + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.SourceRetained; + } + + var quarantine = sourceEntry.TryMoveToNoReplace( + sourceParent, + retirementName); + if (!quarantine.Published) + { + await owner.MarkSourceRetainedAsync( + journal.OperationId, + "Owner metadata was committed, but the source could not enter the operation-owned retirement namespace without replacement. The old source was retained.", + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.SourceRetained; + } + sourceParent.FlushDirectoryEntry(); + await owner.AdvanceAsync( + journal.OperationId, + VerifiedFileRenameState.SourceQuarantined, + error: null, + CancellationToken.None); + owner.AfterSourceQuarantinedForTest?.Invoke(); + } + + if (!await TargetStillMatchesAsync(cancellationToken)) + { + return await RestoreQuarantinedSourceAsync( + originalSourceName, + "The verified organize target changed after source quarantine. The exact pinned source was restored, but the tracked destination requires repair.", + requiresAttention: true); + } + + owner.BeforeRetirementDeleteForTest?.Invoke(); + if (!await TargetStillMatchesAsync(cancellationToken)) + { + return await RestoreQuarantinedSourceAsync( + originalSourceName, + "The verified organize target changed immediately before source deletion. The exact pinned source was restored, but the tracked destination requires repair.", + requiresAttention: true); + } + if (!await SourceStillMatchesAsync(cancellationToken)) + { + await owner.MarkNeedsAttentionAsync( + journal.OperationId, + "The operation-owned retirement source changed before deletion. It was preserved for operator review.", + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + + sourceEntry.Delete(immediateWindows: true); + sourceParent.FlushDirectoryEntry(); + await owner.AdvanceAsync( + journal.OperationId, + VerifiedFileRenameState.SourceDeleted, + error: null, + CancellationToken.None); + await owner.AdvanceAsync( + journal.OperationId, + VerifiedFileRenameState.Completed, + error: null, + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.Completed; + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + logger.LogWarning( + exception, + "Verified organize source retirement for {OperationId} did not complete", + journal.OperationId); + var latest = await owner.GetJournalAsync( + journal.OperationId, + CancellationToken.None); + if (latest?.State == VerifiedFileRenameState.OwnerMetadataReconciled) + { + if (string.Equals( + Path.GetFullPath(sourceEntry.FullPath), + Path.GetFullPath(journal.RetirementPath), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)) + { + return await RestoreQuarantinedSourceAsync( + originalSourceName, + "Live source retirement failed after quarantine. The exact pinned source was restored and retained."); + } + + await owner.MarkSourceRetainedAsync( + journal.OperationId, + "Owner metadata was committed, but live source retirement failed. The old source may remain and will not be deleted by restart recovery.", + CancellationToken.None); + } + else if (latest?.State == VerifiedFileRenameState.SourceQuarantined) + { + return await RestoreQuarantinedSourceAsync( + originalSourceName, + "Live source retirement failed after quarantine. The exact pinned source was restored and retained when possible."); + } + return latest?.State switch + { + VerifiedFileRenameState.SourceDeleted or + VerifiedFileRenameState.Completed => + VerifiedFileRenameRetirementOutcome.Completed, + VerifiedFileRenameState.CompletedSourceRetained => + VerifiedFileRenameRetirementOutcome.SourceRetained, + _ => VerifiedFileRenameRetirementOutcome.NeedsAttention + }; + } + } + + private async Task TargetStillMatchesAsync( + CancellationToken cancellationToken) => + targetEntry.ProbeVisiblePathMatch() + == RegistrationPublicationMatchOutcome.Match + && await targetEntry.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken); + + private async Task SourceStillMatchesAsync( + CancellationToken cancellationToken) => + sourceEntry.ProbeVisiblePathMatch() + == RegistrationPublicationMatchOutcome.Match + && await sourceEntry.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken) + && (!sourceProof.HasDurablePhysicalObjectIdentity + || sourceEntry.MatchesObjectIdentity( + sourceProof.PhysicalObjectIdentity)); + + private async Task RestoreQuarantinedSourceAsync( + string originalSourceName, + string retainedReason, + bool requiresAttention = false) + { + try + { + if (!await SourceStillMatchesAsync(CancellationToken.None)) + { + throw new InvalidOperationException( + "The operation-owned retirement source changed before restoration."); + } + + var restore = sourceEntry.TryMoveToNoReplace( + sourceParent, + originalSourceName); + if (!restore.Published) + { + throw new InvalidOperationException( + $"The original source path could not be restored without replacement (native error {restore.NativeErrorCode})."); + } + sourceParent.FlushDirectoryEntry(); + if (requiresAttention) + { + await owner.MarkNeedsAttentionAsync( + journal.OperationId, + retainedReason, + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + + await owner.MarkSourceRetainedAsync( + journal.OperationId, + retainedReason, + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.SourceRetained; + } + catch (Exception exception) when (exception is not ( + OutOfMemoryException or StackOverflowException)) + { + logger.LogWarning( + exception, + "Verified organize source quarantine for {OperationId} could not be restored", + journal.OperationId); + await owner.MarkNeedsAttentionAsync( + journal.OperationId, + "The verified organize source remains in its operation-owned retirement namespace and requires operator repair.", + CancellationToken.None); + return VerifiedFileRenameRetirementOutcome.NeedsAttention; + } + } + + public ValueTask DisposeAsync() + { + if (_disposed) + { + return ValueTask.CompletedTask; + } + + _disposed = true; + targetEntry.Dispose(); + sourceEntry.Dispose(); + destinationParent.Dispose(); + sourceParent.Dispose(); + return ValueTask.CompletedTask; + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } + } +} diff --git a/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs new file mode 100644 index 000000000..abddca7b2 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs @@ -0,0 +1,243 @@ +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.FileSystem; + +public sealed partial class VerifiedFileRenameTransactionCoordinator( + IDbContextFactory dbContextFactory, + IRootFolderRepository rootFolderRepository, + IRootFolderStorageHealthResolver storageHealthResolver, + TimeProvider timeProvider, + ILogger logger) + : IVerifiedFileRenameTransactionCoordinator +{ + private const string StagingPrefix = ".listenarr-organize-"; + + internal Action? AfterJournalPlannedForTest { get; set; } + internal Action? AfterTargetPublicationForTest { get; set; } + internal Action? AfterSourceQuarantinedForTest { get; set; } + internal Action? BeforeRetirementDeleteForTest { get; set; } + + public async Task PrepareAsync( + string source, + string destination, + Guid operationId, + Guid batchId, + VerifiedFileRenameBatchManifest batchManifest, + int audiobookId, + int audiobookFileId, + FilePublicationSourceProof sourceProof, + CancellationToken cancellationToken = default) + { + if (operationId == Guid.Empty) + { + throw new ArgumentException( + "A verified organize operation ID is required.", + nameof(operationId)); + } + if (batchId == Guid.Empty) + { + throw new ArgumentException( + "A verified organize batch ID is required.", + nameof(batchId)); + } + if (audiobookId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(audiobookId)); + } + if (audiobookFileId < 0) + { + throw new ArgumentOutOfRangeException(nameof(audiobookFileId)); + } + + sourceProof.Validate(); + batchManifest.Validate(); + var sourcePath = Path.GetFullPath(source); + var destinationPath = Path.GetFullPath(destination); + if (string.Equals(sourcePath, destinationPath, StringComparison.Ordinal)) + { + return new VerifiedFileRenamePreparationResult( + false, + Error: "Verified organize requires distinct source and destination paths."); + } + + var sourceParentPath = Path.GetDirectoryName(sourcePath); + var destinationParentPath = Path.GetDirectoryName(destinationPath); + if (string.IsNullOrWhiteSpace(sourceParentPath) + || string.IsNullOrWhiteSpace(destinationParentPath)) + { + return new VerifiedFileRenamePreparationResult( + false, + Error: "Verified organize requires source and destination parent directories."); + } + + var roots = await rootFolderRepository.GetAllAsync(); + var sourceRoot = FindContainingRoot(sourcePath, roots); + var destinationRoot = FindContainingRoot(destinationPath, roots); + if (sourceRoot == null || destinationRoot == null) + { + return new VerifiedFileRenamePreparationResult( + false, + Error: "Verified organize requires configured source and destination roots with persisted path semantics."); + } + + var sourceHealth = await storageHealthResolver.ResolveAsync( + sourceRoot, + cancellationToken); + var destinationHealth = await storageHealthResolver.ResolveAsync( + destinationRoot, + cancellationToken); + if (!sourceHealth.CanRetireVerifiedSource + || !destinationHealth.CanPublishAdditively) + { + return new VerifiedFileRenamePreparationResult( + false, + Error: "Current storage capabilities do not authorize verified organize publication and live source retirement."); + } + + var sourceName = Path.GetFileName(sourcePath); + var destinationName = Path.GetFileName(destinationPath); + var operationName = operationId.ToString("N"); + var stagingName = StagingPrefix + operationName + ".partial"; + var stagingPath = Path.Join(destinationParentPath, stagingName); + var retirementPath = Path.Join( + sourceParentPath, + StagingPrefix + operationName + ".source"); + var now = timeProvider.GetUtcNow().UtcDateTime; + var journal = new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + ProtocolVersion = VerifiedFileRenameProtocol.Current, + AudiobookId = audiobookId, + AudiobookFileId = audiobookFileId, + ExpectedBatchMemberCount = batchManifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = batchManifest.ManifestSha256, + SourcePath = sourcePath, + DestinationPath = destinationPath, + StagingPath = stagingPath, + RetirementPath = retirementPath, + SourceLength = sourceProof.Length, + SourceSha256 = sourceProof.Sha256, + SourceRootFolderId = sourceRoot.Id, + SourceStorageContractRevision = sourceRoot.StorageContractRevision, + DestinationRootFolderId = destinationRoot.Id, + DestinationStorageContractRevision = destinationRoot.StorageContractRevision, + State = VerifiedFileRenameState.Planned, + CreatedAt = now, + UpdatedAt = now + }; + + PinnedDirectoryCreation.PinnedDirectoryAnchor? sourceParent = null; + PinnedDirectoryCreation.PinnedDirectoryAnchor? destinationParent = null; + PinnedDirectoryCreation.PinnedFileEntry? sourceEntry = null; + PinnedDirectoryCreation.PinnedFileEntry? targetEntry = null; + var journalPersisted = false; + try + { + sourceParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + sourceParentPath); + destinationParent = OpenOrCreateVerifiedDestinationParent( + destinationRoot, + destinationParentPath); + sourceEntry = sourceParent.OpenExistingFileForStableDelete(sourceName); + if (!sourceEntry.IsRegularFile() + || !sourceEntry.VisiblePathMatches() + || !await sourceEntry.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken) + || (sourceProof.HasDurablePhysicalObjectIdentity + && !sourceEntry.MatchesObjectIdentity( + sourceProof.PhysicalObjectIdentity))) + { + return new VerifiedFileRenamePreparationResult( + false, + Error: "The organize source changed before verified publication."); + } + + await PersistNewJournalAsync(journal, cancellationToken); + journalPersisted = true; + AfterJournalPlannedForTest?.Invoke(); + + targetEntry = destinationParent.CreateNewFile( + stagingName, + hiddenFile: true); + await CopyAndVerifyAsync( + sourceEntry, + targetEntry, + sourceProof, + cancellationToken); + var publish = targetEntry.TryMoveToNoReplace( + destinationParent, + destinationName); + if (!publish.Published) + { + throw new IOException( + $"The verified organize destination could not be published without replacement (native error {publish.NativeErrorCode})."); + } + destinationParent.FlushDirectoryEntry(); + if (!targetEntry.VisiblePathMatches() + || !await targetEntry.MatchesAsync( + sourceProof.Length, + sourceProof.Sha256, + cancellationToken)) + { + throw new InvalidOperationException( + "The verified organize target changed after publication."); + } + + AfterTargetPublicationForTest?.Invoke(); + await AdvanceAsync( + operationId, + VerifiedFileRenameState.TargetVerified, + error: null, + CancellationToken.None); + + var lease = new VerifiedFileRenameLease( + this, + journal, + sourceParent, + destinationParent, + sourceEntry, + targetEntry, + sourceProof, + logger); + sourceParent = null; + destinationParent = null; + sourceEntry = null; + targetEntry = null; + return new VerifiedFileRenamePreparationResult(true, lease); + } + catch (Exception exception) when (exception is not ( + OperationCanceledException or OutOfMemoryException + or StackOverflowException)) + { + logger.LogWarning( + exception, + "Verified organize operation {OperationId} could not prepare {Source} -> {Destination}", + operationId, + sourcePath, + destinationPath); + if (journalPersisted) + { + await TryRollbackPreparedTargetAsync( + operationId, + destinationParent, + targetEntry, + CancellationToken.None); + } + return new VerifiedFileRenamePreparationResult( + false, + Error: "The verified organize file publication failed safely."); + } + finally + { + targetEntry?.Dispose(); + sourceEntry?.Dispose(); + destinationParent?.Dispose(); + sourceParent?.Dispose(); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs b/listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs new file mode 100644 index 000000000..215e85815 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Configurations/VerifiedFileRenameJournalConfiguration.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Listenarr.Infrastructure.Persistence.Configurations; + +internal sealed class VerifiedFileRenameJournalConfiguration + : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("VerifiedFileRenameJournals"); + builder.HasKey(journal => journal.OperationId); + builder.Property(journal => journal.SourcePath) + .IsRequired() + .HasMaxLength(4096); + builder.Property(journal => journal.DestinationPath) + .IsRequired() + .HasMaxLength(4096); + builder.Property(journal => journal.StagingPath) + .IsRequired() + .HasMaxLength(4096); + builder.Property(journal => journal.RetirementPath) + .IsRequired() + .HasMaxLength(4096); + builder.Property(journal => journal.SourceSha256) + .IsRequired() + .HasMaxLength(64); + builder.Property(journal => journal.ExpectedBatchManifestSha256) + .IsRequired() + .HasMaxLength(64); + builder.Property(journal => journal.Error) + .HasMaxLength(2048); + builder.HasIndex(journal => journal.BatchId); + builder.HasIndex(journal => journal.AudiobookId); + builder.HasIndex(journal => journal.State); + } +} diff --git a/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs b/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs index 7b010f12a..cd8045e8e 100644 --- a/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs +++ b/listenarr.infrastructure/Persistence/FileRenameCommitStore.cs @@ -5,8 +5,8 @@ namespace Listenarr.Infrastructure.Persistence; /// -/// Commits tracked audiobook path changes and the terminal state of their -/// owner-bound rename journals through the same scoped DbContext. +/// Commits tracked audiobook path changes and the terminal/owner-commit state of +/// their owner-bound rename journals through the same scoped DbContext. /// public sealed class FileRenameCommitStore( ListenArrDbContext dbContext, @@ -35,8 +35,17 @@ public async Task CommitOwnerMetadataAsync( } var journals = new List(); + var verifiedJournals = new List(); var targetLeases = new List(); - var originalJournalState = new Dictionary(); + var verifiedLeases = new List(); + var originalJournalState = new Dictionary(); + var originalVerifiedState = new Dictionary(); IDbContextTransaction? ownedTransaction = null; try { @@ -45,7 +54,7 @@ public async Task CommitOwnerMetadataAsync( if (dbContext.Database.CurrentTransaction != null) { throw new InvalidOperationException( - "Rename owner-metadata commit must own its database transaction so filesystem generation proof cannot outlive the commit boundary."); + "Rename owner-metadata commit must own its database transaction so filesystem proof cannot outlive the commit boundary."); } ownedTransaction = await dbContext.Database.BeginTransactionAsync( @@ -57,55 +66,61 @@ public async Task CommitOwnerMetadataAsync( journals = await dbContext.FileMutationJournals .Where(journal => distinctIds.Contains(journal.OperationId)) .ToListAsync(cancellationToken); - if (journals.Count != distinctIds.Length) + verifiedJournals = await dbContext.VerifiedFileRenameJournals + .Where(journal => distinctIds.Contains(journal.OperationId)) + .ToListAsync(cancellationToken); + foreach (var verifiedJournal in verifiedJournals) + { + // The live verified-rename lease advances rollback/attention state + // through its own DbContext. This scoped commit context may still + // be tracking the journal from an earlier failed owner commit, so + // refresh before deciding which durable state is authoritative. + await dbContext.Entry(verifiedJournal).ReloadAsync(cancellationToken); + } + if (journals.Count + verifiedJournals.Count != distinctIds.Length + || journals.Select(journal => journal.OperationId) + .Intersect(verifiedJournals.Select(journal => journal.OperationId)) + .Any()) + { + throw new InvalidOperationException( + "One or more owner-bound rename journals are missing or ambiguous before metadata commit."); + } + if (journals.Count > 0 && verifiedJournals.Count > 0) { throw new InvalidOperationException( - "One or more owner-bound rename journals are missing before metadata commit."); + "One organize owner commit cannot mix durable-generation and verified weak-storage rename protocols."); } - foreach (var journal in journals) + if (journals.Count > 0) { - if (journal.Action != FileAction.Move - || journal.AudiobookId != audiobookId - || !journal.AudiobookFileId.HasValue) - { - throw new InvalidOperationException( - "A rename journal is not a move bound to the audiobook whose metadata is being committed."); - } - if (journal.State != FileMutationJournalState.Completed) - { - throw new InvalidOperationException( - "A rename journal has not completed its filesystem mutation before metadata commit."); - } - if (string.IsNullOrWhiteSpace(journal.TargetPhysicalObjectIdentity)) - { - throw new InvalidOperationException( - "A completed rename journal has no persisted target physical generation."); - } - - originalJournalState[journal.OperationId] = - (journal.State, journal.Error, journal.UpdatedAt); - var targetLease = PinnedAudiobookFileRegistrationLease.Open( - journal.DestinationPath, - journal.TargetPhysicalObjectIdentity); - targetLeases.Add(targetLease); - if (targetLease.ProbeCurrentPublication() - != RegistrationPublicationMatchOutcome.Match) - { - throw new InvalidOperationException( - "A completed rename target is not currently the journaled physical generation."); - } - - journal.State = FileMutationJournalState.OwnerMetadataReconciled; - journal.Error = null; - journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + PrepareDurableRenameCommit( + audiobookId, + journals, + targetLeases, + originalJournalState); + } + else + { + await PrepareVerifiedRenameCommitAsync( + audiobookId, + distinctIds, + verifiedJournals, + verifiedLeases, + originalVerifiedState, + cancellationToken); } } EnsureTargetsStillMatch(targetLeases); + await EnsureVerifiedEntriesStillMatchAsync( + verifiedLeases, + cancellationToken); await dbContext.SaveChangesAsync(cancellationToken); AfterSaveBeforeTargetRevalidationForTest?.Invoke(); EnsureTargetsStillMatch(targetLeases); + await EnsureVerifiedEntriesStillMatchAsync( + verifiedLeases, + cancellationToken); if (ownedTransaction != null) { await ownedTransaction.CommitAsync(cancellationToken); @@ -128,6 +143,19 @@ public async Task CommitOwnerMetadataAsync( journal.Error = original.Error; journal.UpdatedAt = original.UpdatedAt; } + foreach (var journal in verifiedJournals) + { + if (!originalVerifiedState.TryGetValue( + journal.OperationId, + out var original)) + { + continue; + } + + journal.State = original.State; + journal.Error = original.Error; + journal.UpdatedAt = original.UpdatedAt; + } throw; } finally @@ -140,6 +168,181 @@ public async Task CommitOwnerMetadataAsync( { targetLease.Dispose(); } + foreach (var verifiedLease in verifiedLeases) + { + verifiedLease.Dispose(); + } + } + } + + private void PrepareDurableRenameCommit( + int audiobookId, + IReadOnlyCollection journals, + ICollection targetLeases, + IDictionary originalJournalState) + { + foreach (var journal in journals) + { + if (journal.Action != FileAction.Move + || journal.AudiobookId != audiobookId + || !journal.AudiobookFileId.HasValue) + { + throw new InvalidOperationException( + "A rename journal is not a move bound to the audiobook whose metadata is being committed."); + } + if (journal.State != FileMutationJournalState.Completed) + { + throw new InvalidOperationException( + "A rename journal has not completed its filesystem mutation before metadata commit."); + } + if (string.IsNullOrWhiteSpace(journal.TargetPhysicalObjectIdentity)) + { + throw new InvalidOperationException( + "A completed rename journal has no persisted target physical generation."); + } + + originalJournalState[journal.OperationId] = + (journal.State, journal.Error, journal.UpdatedAt); + var targetLease = PinnedAudiobookFileRegistrationLease.Open( + journal.DestinationPath, + journal.TargetPhysicalObjectIdentity); + targetLeases.Add(targetLease); + if (targetLease.ProbeCurrentPublication() + != RegistrationPublicationMatchOutcome.Match) + { + throw new InvalidOperationException( + "A completed rename target is not currently the journaled physical generation."); + } + + journal.State = FileMutationJournalState.OwnerMetadataReconciled; + journal.Error = null; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + } + } + + private async Task PrepareVerifiedRenameCommitAsync( + int audiobookId, + IReadOnlyCollection distinctIds, + IReadOnlyList journals, + ICollection leases, + IDictionary originalState, + CancellationToken cancellationToken) + { + if (journals.Count == 0) + { + return; + } + if (journals.All(journal => journal.State == VerifiedFileRenameState.RolledBack)) + { + if (journals.Any(journal => + journal.ProtocolVersion != VerifiedFileRenameProtocol.Current + || journal.AudiobookId != audiobookId)) + { + throw new InvalidOperationException( + "A rolled-back verified organize journal is not bound to the expected audiobook/protocol."); + } + return; + } + if (journals.Any(journal => + journal.ProtocolVersion != VerifiedFileRenameProtocol.Current + || journal.AudiobookId != audiobookId + || journal.AudiobookFileId < 0 + || journal.State != VerifiedFileRenameState.TargetVerified)) + { + throw new InvalidOperationException( + "Every verified organize journal must be target-verified and owner-bound before metadata commit."); + } + + var batchIds = journals.Select(journal => journal.BatchId).Distinct().ToArray(); + if (batchIds.Length != 1 || batchIds[0] == Guid.Empty) + { + throw new InvalidOperationException( + "A verified organize owner commit must contain exactly one sealed batch."); + } + + var fullBatch = await dbContext.VerifiedFileRenameJournals + .Where(journal => journal.BatchId == batchIds[0]) + .OrderBy(journal => journal.AudiobookFileId) + .ThenBy(journal => journal.SourcePath) + .ToListAsync(cancellationToken); + if (fullBatch.Count != journals[0].ExpectedBatchMemberCount + || fullBatch.Count != distinctIds.Count + || !fullBatch.Select(journal => journal.OperationId) + .ToHashSet() + .SetEquals(distinctIds) + || fullBatch.Any(journal => + journal.AudiobookId != audiobookId + || journal.State != VerifiedFileRenameState.TargetVerified + || journal.ExpectedBatchMemberCount != fullBatch.Count + || !string.Equals( + journal.ExpectedBatchManifestSha256, + journals[0].ExpectedBatchManifestSha256, + StringComparison.OrdinalIgnoreCase))) + { + throw new InvalidOperationException( + "The verified organize batch is incomplete or inconsistent before owner metadata commit."); + } + + var manifest = VerifiedFileRenameBatchManifest.Create( + fullBatch.Select(journal => new VerifiedFileRenameBatchMember( + journal.AudiobookFileId, + journal.SourcePath, + journal.DestinationPath))); + manifest.Validate(); + if (manifest.ExpectedMemberCount != fullBatch.Count + || !string.Equals( + manifest.ManifestSha256, + journals[0].ExpectedBatchManifestSha256, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "The persisted verified organize batch manifest does not match its journal members."); + } + + var rootIds = fullBatch + .SelectMany(journal => new[] + { + journal.SourceRootFolderId, + journal.DestinationRootFolderId + }) + .Distinct() + .ToArray(); + var roots = await dbContext.RootFolders + .AsNoTracking() + .Where(root => rootIds.Contains(root.Id)) + .ToDictionaryAsync(root => root.Id, cancellationToken); + foreach (var journal in fullBatch) + { + if (!roots.TryGetValue(journal.SourceRootFolderId, out var sourceRoot) + || !roots.TryGetValue( + journal.DestinationRootFolderId, + out var destinationRoot) + || sourceRoot.StorageContractRevision + != journal.SourceStorageContractRevision + || destinationRoot.StorageContractRevision + != journal.DestinationStorageContractRevision) + { + throw new InvalidOperationException( + "A verified organize root storage contract changed before owner metadata commit."); + } + } + + foreach (var journal in journals) + { + originalState[journal.OperationId] = + (journal.State, journal.Error, journal.UpdatedAt); + var lease = VerifiedRenameCommitLease.Open(journal); + await lease.EnsureMatchesAsync(cancellationToken); + leases.Add(lease); + journal.State = VerifiedFileRenameState.OwnerMetadataReconciled; + journal.Error = null; + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; } } @@ -161,4 +364,120 @@ private static void EnsureTargetsStillMatch( } } } + + private static async Task EnsureVerifiedEntriesStillMatchAsync( + IReadOnlyCollection leases, + CancellationToken cancellationToken) + { + foreach (var lease in leases) + { + await lease.EnsureMatchesAsync(cancellationToken); + } + } + + private sealed class VerifiedRenameCommitLease : IDisposable + { + private readonly PinnedDirectoryCreation.PinnedDirectoryAnchor _sourceParent; + private readonly PinnedDirectoryCreation.PinnedDirectoryAnchor _destinationParent; + private readonly PinnedDirectoryCreation.PinnedFileEntry _source; + private readonly PinnedDirectoryCreation.PinnedFileEntry _target; + private readonly long _length; + private readonly string _sha256; + private bool _disposed; + + private VerifiedRenameCommitLease( + PinnedDirectoryCreation.PinnedDirectoryAnchor sourceParent, + PinnedDirectoryCreation.PinnedDirectoryAnchor destinationParent, + PinnedDirectoryCreation.PinnedFileEntry source, + PinnedDirectoryCreation.PinnedFileEntry target, + long length, + string sha256) + { + _sourceParent = sourceParent; + _destinationParent = destinationParent; + _source = source; + _target = target; + _length = length; + _sha256 = sha256; + } + + public static VerifiedRenameCommitLease Open( + VerifiedFileRenameJournal journal) + { + var sourceParentPath = Path.GetDirectoryName(journal.SourcePath) + ?? throw new InvalidOperationException( + "The verified organize source has no parent directory."); + var destinationParentPath = Path.GetDirectoryName(journal.DestinationPath) + ?? throw new InvalidOperationException( + "The verified organize destination has no parent directory."); + var sourceParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + sourceParentPath); + PinnedDirectoryCreation.PinnedDirectoryAnchor? destinationParent = null; + PinnedDirectoryCreation.PinnedFileEntry? source = null; + PinnedDirectoryCreation.PinnedFileEntry? target = null; + try + { + destinationParent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + destinationParentPath); + source = sourceParent.OpenExistingFileForStableRead( + Path.GetFileName(journal.SourcePath)); + target = destinationParent.OpenExistingFileForStableRead( + Path.GetFileName(journal.DestinationPath)); + var lease = new VerifiedRenameCommitLease( + sourceParent, + destinationParent, + source, + target, + journal.SourceLength, + journal.SourceSha256); + sourceParent = null!; + destinationParent = null; + source = null; + target = null; + return lease; + } + finally + { + target?.Dispose(); + source?.Dispose(); + destinationParent?.Dispose(); + sourceParent?.Dispose(); + } + } + + public async Task EnsureMatchesAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_source.ProbeVisiblePathMatch() + != RegistrationPublicationMatchOutcome.Match + || _target.ProbeVisiblePathMatch() + != RegistrationPublicationMatchOutcome.Match + || !await _source.MatchesAsync( + _length, + _sha256, + cancellationToken) + || !await _target.MatchesAsync( + _length, + _sha256, + cancellationToken)) + { + throw new InvalidOperationException( + "A verified organize source or target changed during owner-metadata commit."); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _target.Dispose(); + _source.Dispose(); + _destinationParent.Dispose(); + _sourceParent.Dispose(); + } + } } diff --git a/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs b/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs index 81c438176..5679a19c4 100644 --- a/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs +++ b/listenarr.infrastructure/Persistence/FileRenameRecoveryProbe.cs @@ -16,7 +16,7 @@ public async Task HasBlockingAsync( } await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - return await db.FileMutationJournals + if (await db.FileMutationJournals .AsNoTracking() .AnyAsync(journal => journal.AudiobookId == audiobookId @@ -26,6 +26,18 @@ public async Task HasBlockingAsync( == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed : journal.State != FileMutationJournalState.OwnerMetadataReconciled), + cancellationToken)) + { + return true; + } + + return await db.VerifiedFileRenameJournals + .AsNoTracking() + .AnyAsync(journal => + journal.AudiobookId == audiobookId + && journal.State != VerifiedFileRenameState.Completed + && journal.State != VerifiedFileRenameState.CompletedSourceRetained + && journal.State != VerifiedFileRenameState.RolledBack, cancellationToken); } } diff --git a/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs b/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs index f6d6f70f2..4295676a3 100644 --- a/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs +++ b/listenarr.infrastructure/Persistence/LibraryFilesystemStartupReconciliationService.cs @@ -70,6 +70,12 @@ await RunScopedAsync( static (service, token) => service.ReconcileAsync(token), stoppingToken); + phase = "VerifiedFileRenameRecovery"; + readiness.MarkRunning(phase); + await RunScopedAsync( + static (service, token) => service.ReconcileAsync(token), + stoppingToken); + phase = "FileRenameRecovery"; readiness.MarkRunning(phase); await RunScopedAsync( diff --git a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs index 9c99a38fa..93b5ea653 100644 --- a/listenarr.infrastructure/Persistence/ListenArrDbContext.cs +++ b/listenarr.infrastructure/Persistence/ListenArrDbContext.cs @@ -42,6 +42,7 @@ public class ListenArrDbContext : DbContext public DbSet Downloads { get; set; } = null!; public DbSet DownloadProcessingJobs { get; set; } = null!; public DbSet FileMutationJournals { get; set; } = null!; + public DbSet VerifiedFileRenameJournals { get; set; } = null!; public DbSet CompatibilityFilePublicationJournals { get; set; } = null!; public DbSet WeakStorageScanCandidates { get; set; } = null!; public DbSet DownloadHistories { get; set; } = null!; diff --git a/listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.Designer.cs b/listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.Designer.cs new file mode 100644 index 000000000..a5ec8ebb9 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.Designer.cs @@ -0,0 +1,2852 @@ +// +using System; +using Listenarr.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ListenArrDbContext))] + [Migration("20260901142347_AddVerifiedFileRenameJournal")] + partial class AddVerifiedFileRenameJournal + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.8"); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.DownloadHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClient") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EventDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("ImportedAt") + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("WasImported") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventDate"); + + b.HasIndex("DownloadId", "EventType"); + + b.ToTable("DownloadHistories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.History", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookExternalId") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookTitle") + .HasColumnType("TEXT"); + + b.Property("CorrelationId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("NotificationSent") + .HasColumnType("INTEGER"); + + b.Property("Outcome") + .HasColumnType("INTEGER"); + + b.Property("ParentEventId") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SourceTitle") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookExternalId"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("DownloadId"); + + b.HasIndex("EventType"); + + b.HasIndex("IdempotencyKey") + .IsUnique() + .HasFilter("\"IdempotencyKey\" IS NOT NULL"); + + b.HasIndex("Outcome"); + + b.HasIndex("Timestamp"); + + b.ToTable("History"); + }); + + modelBuilder.Entity("Listenarr.Domain.ActivityHistory.ProcessExecutionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Arguments") + .HasColumnType("TEXT"); + + b.Property("DurationMs") + .HasColumnType("INTEGER"); + + b.Property("ExitCode") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Stderr") + .HasColumnType("TEXT"); + + b.Property("Stdout") + .HasColumnType("TEXT"); + + b.Property("TimedOut") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ProcessExecutionLogs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Abridged") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AuthorAsins") + .HasColumnType("TEXT"); + + b.Property("Authors") + .HasColumnType("TEXT"); + + b.Property("BasePath") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Edition") + .HasColumnType("TEXT"); + + b.Property("Explicit") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastSearchTime") + .HasColumnType("TEXT"); + + b.Property("Monitored") + .HasColumnType("INTEGER"); + + b.Property("Narrators") + .HasColumnType("TEXT"); + + b.Property("OpenLibraryId") + .HasColumnType("TEXT"); + + b.Property("PublishYear") + .HasColumnType("TEXT"); + + b.Property("PublishedDate") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Quality") + .HasColumnType("TEXT"); + + b.Property("QualityProfileId") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastSearchTime"); + + b.HasIndex("Monitored"); + + b.HasIndex("QualityProfileId"); + + b.ToTable("Audiobooks"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookDeletionIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteFolder") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId") + .IsUnique() + .HasFilter("\"State\" <> 'Completed'"); + + b.HasIndex("UpdatedAt"); + + b.HasIndex("AudiobookId", "State"); + + b.ToTable("AudiobookDeletionIntents", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("Region") + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("ValueNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ValueRaw") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("Type", "ValueNormalized"); + + b.HasIndex("AudiobookId", "Type", "IsPrimary"); + + b.HasIndex("Type", "ValueNormalized", "Region"); + + b.ToTable("AudiobookExternalIdentifiers", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("Bitrate") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("Container") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DurationSeconds") + .HasColumnType("REAL"); + + b.Property("Format") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("PathIdentityBoundary") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathIdentityReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("PathIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityObservedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PhysicalIdentityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("PhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.ToTable("AudiobookFiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("SeriesAsin") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("AudiobookId", "IsPrimary"); + + b.HasIndex("AudiobookId", "SortOrder"); + + b.ToTable("AudiobookSeriesMemberships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AuthorCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SimilarAuthors") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorAsin", "Region"); + + b.HasIndex("AuthorNameNormalized", "Region") + .IsUnique(); + + b.ToTable("AuthorCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreationOperationId") + .HasColumnType("TEXT"); + + b.Property("CreationWorkflow") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("ManagedRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PathIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("PathIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathOwnershipKey") + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("PathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StateReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ManagedRootFolderId"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("PathIdentityLookupKey"); + + b.HasIndex("PathOwnershipKey") + .IsUnique() + .HasFilter("\"PathOwnershipKey\" IS NOT NULL"); + + b.HasIndex("CreationOperationId", "State"); + + b.ToTable("LibraryDirectoryOwnerships", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("OwnershipId") + .HasColumnType("INTEGER"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("SourceCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourceOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityLookupKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetOwnershipKey") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId"); + + b.HasIndex("TargetOwnershipKey") + .IsUnique(); + + b.HasIndex("OwnershipId", "RelocationId") + .IsUnique(); + + b.ToTable("LibraryDirectoryOwnershipPathMigrations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredAuthor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AuthorAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("AuthorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("AuthorNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("AuthorNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredAuthors"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MonitoredSeries", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LastCheckedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastSuccessfulSyncAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LastCheckedAt"); + + b.HasIndex("SeriesNameNormalized", "Region", "Language") + .IsUnique(); + + b.ToTable("MonitoredSeries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("EnqueuedAt") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("ExecutionProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("FailureKind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("ForceCopyAndRetainSource") + .HasColumnType("INTEGER"); + + b.Property("IdentityKeyVersion") + .HasColumnType("INTEGER"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("None"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("RequestedPath") + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourceCleanupMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("RetainSource"); + + b.Property("SourceDirectoryCleanupState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Pending"); + + b.Property("SourceDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("SourcePathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourcePolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivity") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetIdentityBoundary") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TargetPathSyntax") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetPolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("TargetRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("TargetStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("RelocationId"); + + b.HasIndex("AudiobookId", "Status"); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "Path") + .IsUnique(); + + b.ToTable("MoveJobCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CleanupProtectionVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("CleanupState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CopyState") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("LastWriteTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("RelativePath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Sha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId", "RelativePath") + .IsUnique(); + + b.ToTable("MoveJobEntries", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveScanJobId") + .HasColumnType("TEXT"); + + b.Property("AttemptGeneration") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("LeaseExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LeaseGeneration") + .HasColumnType("INTEGER"); + + b.Property("LeaseOwner") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("MoveJobId") + .HasColumnType("TEXT"); + + b.Property("NextAttemptAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MoveJobId") + .IsUnique(); + + b.HasIndex("Status", "NextAttemptAt", "LeaseExpiresAt"); + + b.ToTable("MoveScanHandoffs", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.QualityProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CustomGroupNames") + .HasColumnType("TEXT") + .HasColumnName("CustomGroupNames"); + + b.Property("CutoffQuality") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("MaximumAge") + .HasColumnType("INTEGER"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumScore") + .HasColumnType("INTEGER"); + + b.Property("MinimumSeeders") + .HasColumnType("INTEGER"); + + b.Property("MinimumSize") + .HasColumnType("INTEGER"); + + b.Property("MustContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustContain"); + + b.Property("MustNotContain") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("MustNotContain"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PreferNewerReleases") + .HasColumnType("INTEGER"); + + b.Property("PreferredFormats") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredFormats"); + + b.Property("PreferredLanguages") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("PreferredLanguages"); + + b.PrimitiveCollection("PreferredWords") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Qualities") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Qualities"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("QualityProfiles"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("PathIdentityKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PathIdentityState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unavailable"); + + b.Property("ResolvedCaseSensitivity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Unknown"); + + b.Property("StorageContractRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("WeakStoragePolicyRevision") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("WeakStorageSourceCleanupPolicy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(40) + .HasColumnType("TEXT") + .HasDefaultValue("RetainSource"); + + b.HasKey("Id"); + + b.HasIndex("IsDefault") + .IsUnique() + .HasDatabaseName("IX_RootFolders_SingleDefault") + .HasFilter("\"IsDefault\" = 1"); + + b.HasIndex("Name"); + + b.HasIndex("Path") + .IsUnique(); + + b.HasIndex("PathIdentityKey") + .IsUnique() + .HasFilter("\"PathIdentityKey\" IS NOT NULL"); + + b.ToTable("RootFolders", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ActiveRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CompletedJobs") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeleteEmptySource") + .HasColumnType("INTEGER"); + + b.Property("DesiredIsDefault") + .HasColumnType("INTEGER"); + + b.Property("DesiredName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("RootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceCaseSensitivityMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(16) + .HasColumnType("TEXT") + .HasDefaultValue("Auto"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("TargetCaseSensitivityMode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityUnavailableReason") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("TargetDirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("TargetIdentityEnrollmentState") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(24) + .HasColumnType("TEXT") + .HasDefaultValue("Authorized"); + + b.Property("TargetPath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("TotalJobs") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRootFolderId") + .IsUnique() + .HasFilter("\"ActiveRootFolderId\" IS NOT NULL"); + + b.HasIndex("RootFolderId"); + + b.ToTable("RootFolderRelocations", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentity") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DirectoryObjectIdentityVersion") + .HasColumnType("INTEGER"); + + b.Property("OwnershipToken") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnershipToken") + .IsUnique(); + + b.HasIndex("RelocationId", "CanonicalPath") + .IsUnique(); + + b.ToTable("RootFolderRelocationCreatedDirectories", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("RelocationId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RelocationId", "AudiobookId") + .IsUnique(); + + b.ToTable("RootFolderRelocationSkippedItems", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.SeriesCacheEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CatalogBooks") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("LastFetchedAt") + .HasColumnType("TEXT"); + + b.Property("Region") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesAsin") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("SeriesNameNormalized") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesAsin", "Region"); + + b.HasIndex("SeriesNameNormalized", "Region") + .IsUnique(); + + b.ToTable("SeriesCacheEntries"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.WeakStorageScanCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("ConfirmedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpectedPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ExpectedResolvedPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("ExpectedStoredPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ScanToken") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ScanToken"); + + b.HasIndex("AudiobookId", "ConfirmedAt", "ExpiresAt"); + + b.ToTable("WeakStorageScanCandidates", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApiConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BaseUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Headers") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("HeadersJson"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Parameters") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("ParametersJson"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RateLimitPerMinute") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApiConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Configuration.ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowedFileExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AudnexusApiUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CompletedFileAction") + .HasColumnType("INTEGER"); + + b.Property("DefaultSearchLanguage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DefaultSearchRegion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DiscordApplicationId") + .HasColumnType("TEXT"); + + b.Property("DiscordBotAvatar") + .HasColumnType("TEXT"); + + b.Property("DiscordBotEnabled") + .HasColumnType("INTEGER"); + + b.Property("DiscordBotToken") + .HasColumnType("TEXT"); + + b.Property("DiscordBotUsername") + .HasColumnType("TEXT"); + + b.Property("DiscordChannelId") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandGroupName") + .HasColumnType("TEXT"); + + b.Property("DiscordCommandSubcommandName") + .HasColumnType("TEXT"); + + b.Property("DiscordGuildId") + .HasColumnType("TEXT"); + + b.Property("DownloadCompletionStabilitySeconds") + .HasColumnType("INTEGER"); + + b.Property("EnableAmazonSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAudibleSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverArtDownload") + .HasColumnType("INTEGER"); + + b.Property("EnableMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableNotifications") + .HasColumnType("INTEGER"); + + b.Property("EnableOpenLibrarySearch") + .HasColumnType("INTEGER"); + + b.Property("EnabledNotificationTriggers") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExtractArchives") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadAutoSearch") + .HasColumnType("INTEGER"); + + b.Property("FailedDownloadHandlingEnabled") + .HasColumnType("INTEGER"); + + b.Property("FileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FolderNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryRetentionDays") + .HasColumnType("INTEGER"); + + b.Property("ImportBlacklistExtensions") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("MaxConcurrentDownloads") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceMaxRetries") + .HasColumnType("INTEGER"); + + b.Property("MissingSourceRetryInitialDelaySeconds") + .HasColumnType("INTEGER"); + + b.Property("MultiFileNamingPattern") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OutputPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PollingIntervalSeconds") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrApiKeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("ProwlarrPort") + .HasColumnType("INTEGER"); + + b.Property("ProwlarrTagFilter") + .HasColumnType("TEXT"); + + b.Property("ProwlarrUrl") + .HasColumnType("TEXT"); + + b.Property("ShowCompletedExternalDownloads") + .HasColumnType("INTEGER"); + + b.Property("UnmatchedScanConcurrency") + .HasColumnType("INTEGER"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("WebhookUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Webhooks") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.CompatibilityFilePublicationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("BatchId") + .HasColumnType("TEXT"); + + b.Property("CleanupOwner") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("DestinationPolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("DestinationRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("DestinationStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("EffectiveAction") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ExpectedBatchSourceManifestSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsCompanionFile") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("QuarantinePath") + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("RequestedAction") + .HasColumnType("INTEGER"); + + b.Property("SourceDisposition") + .HasColumnType("INTEGER"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePolicyRevision") + .HasColumnType("INTEGER"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("TargetLength") + .HasColumnType("INTEGER"); + + b.Property("TargetSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("BatchId"); + + b.HasIndex("State"); + + b.ToTable("CompatibilityFilePublicationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.Download", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveAudiobookDeduplicationKey") + .HasColumnType("INTEGER"); + + b.Property("Album") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Artist") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DownloadedSize") + .HasColumnType("INTEGER"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("ExpectedFileSize") + .HasColumnType("INTEGER"); + + b.Property("FinalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HistoryId") + .HasColumnType("INTEGER"); + + b.Property("ImportAttempts") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ImportBlockMessages") + .HasColumnType("TEXT"); + + b.Property("ImportBlockReason") + .HasColumnType("TEXT"); + + b.Property("Isbn") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastImportedAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Metadata"); + + b.Property("OriginalUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Progress") + .HasColumnType("TEXT"); + + b.Property("Publisher") + .HasColumnType("TEXT"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("Series") + .HasColumnType("TEXT"); + + b.Property("SeriesNumber") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TotalSize") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAudiobookDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveAudiobookDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("CompletedAt"); + + b.HasIndex("DownloadClientId"); + + b.HasIndex("Status"); + + b.ToTable("Downloads"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadClientConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Password") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("RemoveCompletedDownloads") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Settings") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("SettingsJson"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UseSSL") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DownloadClientConfigurations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.DownloadProcessingJob", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveDeduplicationKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .HasColumnType("TEXT"); + + b.Property("DownloadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("JobData") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("JobData"); + + b.Property("JobType") + .HasColumnType("INTEGER"); + + b.Property("MaxRetries") + .HasColumnType("INTEGER"); + + b.Property("NextRetryAt") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("ProcessingLog") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RetryCount") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ActiveDeduplicationKey") + .IsUnique() + .HasFilter("\"ActiveDeduplicationKey\" IS NOT NULL"); + + b.HasIndex("Status"); + + b.HasIndex("DownloadId", "Status"); + + b.ToTable("DownloadProcessingJobs"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.FileMutationJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ProtocolVersion") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourceParentDirectoryObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourcePhysicalObjectIdentity") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SourceSha256") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("TargetPhysicalObjectIdentity") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("State"); + + b.HasIndex("UpdatedAt"); + + b.ToTable("FileMutationJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.RemotePathMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DownloadClientId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LocalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RemotePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("RemotePathMappings"); + }); + + modelBuilder.Entity("Listenarr.Domain.Downloads.VerifiedFileRenameJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("BatchId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("DestinationRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("DestinationStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchManifestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("RetirementPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("StagingPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("BatchId"); + + b.HasIndex("State"); + + b.ToTable("VerifiedFileRenameJournals", (string)null); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Listenarr.Domain.Identity.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("LastAccessed") + .HasColumnType("TEXT"); + + b.Property("RememberMe") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Listenarr.Domain.Search.Indexer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AdditionalSettings") + .HasColumnType("TEXT"); + + b.Property("AnimeCategories") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("Categories") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EnableAnimeStandardSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableAutomaticSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableInteractiveSearch") + .HasColumnType("INTEGER"); + + b.Property("EnableRss") + .HasColumnType("INTEGER"); + + b.Property("Implementation") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsEnabled") + .HasColumnType("INTEGER"); + + b.Property("LastTestError") + .HasColumnType("TEXT"); + + b.Property("LastTestSuccessful") + .HasColumnType("INTEGER"); + + b.Property("LastTestedAt") + .HasColumnType("TEXT"); + + b.Property("MaximumSize") + .HasColumnType("INTEGER"); + + b.Property("MinimumAge") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("Retention") + .HasColumnType("INTEGER"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Indexers"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.QualityProfile", "QualityProfile") + .WithMany() + .HasForeignKey("QualityProfileId"); + + b.Navigation("QualityProfile"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookExternalIdentifier", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", null) + .WithMany("ExternalIdentifiers") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookFile", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("Files") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.AudiobookSeriesMembership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.Audiobook", "Audiobook") + .WithMany("SeriesMemberships") + .HasForeignKey("AudiobookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Audiobook"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", null) + .WithMany() + .HasForeignKey("ManagedRootFolderId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnershipPathMigration", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", "Ownership") + .WithMany("PathMigrations") + .HasForeignKey("OwnershipId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("OwnershipPathMigrations") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Ownership"); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("MoveJobs") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("CreatedDirectories") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJobEntry", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithMany("Entries") + .HasForeignKey("MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveScanHandoff", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.MoveJob", "MoveJob") + .WithOne("ScanHandoff") + .HasForeignKey("Listenarr.Domain.Audiobooks.MoveScanHandoff", "MoveJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MoveJob"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolder", "RootFolder") + .WithMany("Relocations") + .HasForeignKey("RootFolderId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("RootFolder"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationCreatedDirectory", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("CreatedDirectories") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocationSkippedItem", b => + { + b.HasOne("Listenarr.Domain.Audiobooks.RootFolderRelocation", "Relocation") + .WithMany("SkippedItems") + .HasForeignKey("RelocationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Relocation"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.Audiobook", b => + { + b.Navigation("ExternalIdentifiers"); + + b.Navigation("Files"); + + b.Navigation("SeriesMemberships"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.LibraryDirectoryOwnership", b => + { + b.Navigation("PathMigrations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.MoveJob", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("Entries"); + + b.Navigation("ScanHandoff"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolder", b => + { + b.Navigation("Relocations"); + }); + + modelBuilder.Entity("Listenarr.Domain.Audiobooks.RootFolderRelocation", b => + { + b.Navigation("CreatedDirectories"); + + b.Navigation("MoveJobs"); + + b.Navigation("OwnershipPathMigrations"); + + b.Navigation("SkippedItems"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.cs b/listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.cs new file mode 100644 index 000000000..4f33ad6ac --- /dev/null +++ b/listenarr.infrastructure/Persistence/Migrations/20260901142347_AddVerifiedFileRenameJournal.cs @@ -0,0 +1,68 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Listenarr.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddVerifiedFileRenameJournal : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "VerifiedFileRenameJournals", + columns: table => new + { + OperationId = table.Column(type: "TEXT", nullable: false), + BatchId = table.Column(type: "TEXT", nullable: false), + ProtocolVersion = table.Column(type: "INTEGER", nullable: false), + AudiobookId = table.Column(type: "INTEGER", nullable: false), + AudiobookFileId = table.Column(type: "INTEGER", nullable: false), + ExpectedBatchMemberCount = table.Column(type: "INTEGER", nullable: false), + ExpectedBatchManifestSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: false), + SourcePath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + DestinationPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + StagingPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + RetirementPath = table.Column(type: "TEXT", maxLength: 4096, nullable: false), + SourceLength = table.Column(type: "INTEGER", nullable: false), + SourceSha256 = table.Column(type: "TEXT", maxLength: 64, nullable: false), + SourceRootFolderId = table.Column(type: "INTEGER", nullable: false), + SourceStorageContractRevision = table.Column(type: "INTEGER", nullable: false), + DestinationRootFolderId = table.Column(type: "INTEGER", nullable: false), + DestinationStorageContractRevision = table.Column(type: "INTEGER", nullable: false), + State = table.Column(type: "INTEGER", nullable: false), + Error = table.Column(type: "TEXT", maxLength: 2048, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VerifiedFileRenameJournals", x => x.OperationId); + }); + + migrationBuilder.CreateIndex( + name: "IX_VerifiedFileRenameJournals_AudiobookId", + table: "VerifiedFileRenameJournals", + column: "AudiobookId"); + + migrationBuilder.CreateIndex( + name: "IX_VerifiedFileRenameJournals_BatchId", + table: "VerifiedFileRenameJournals", + column: "BatchId"); + + migrationBuilder.CreateIndex( + name: "IX_VerifiedFileRenameJournals_State", + table: "VerifiedFileRenameJournals", + column: "State"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "VerifiedFileRenameJournals"); + } + } +} diff --git a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs index 0a1ad4343..189012f51 100644 --- a/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs +++ b/listenarr.infrastructure/Persistence/Migrations/ListenArrDbContextModelSnapshot.cs @@ -2418,6 +2418,96 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("RemotePathMappings"); }); + modelBuilder.Entity("Listenarr.Domain.Downloads.VerifiedFileRenameJournal", b => + { + b.Property("OperationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudiobookFileId") + .HasColumnType("INTEGER"); + + b.Property("AudiobookId") + .HasColumnType("INTEGER"); + + b.Property("BatchId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DestinationPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("DestinationRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("DestinationStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("Error") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchManifestSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ExpectedBatchMemberCount") + .HasColumnType("INTEGER"); + + b.Property("ProtocolVersion") + .HasColumnType("INTEGER"); + + b.Property("RetirementPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceLength") + .HasColumnType("INTEGER"); + + b.Property("SourcePath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("SourceRootFolderId") + .HasColumnType("INTEGER"); + + b.Property("SourceSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SourceStorageContractRevision") + .HasColumnType("INTEGER"); + + b.Property("StagingPath") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.Property("State") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("OperationId"); + + b.HasIndex("AudiobookId"); + + b.HasIndex("BatchId"); + + b.HasIndex("State"); + + b.ToTable("VerifiedFileRenameJournals", (string)null); + }); + modelBuilder.Entity("Listenarr.Domain.Identity.User", b => { b.Property("Id") diff --git a/listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.Probes.cs b/listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.Probes.cs new file mode 100644 index 000000000..5e1fc5ad1 --- /dev/null +++ b/listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.Probes.cs @@ -0,0 +1,144 @@ +using Listenarr.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Persistence; + +internal sealed partial class VerifiedFileRenameRecoveryService +{ + private async Task ValidateCommittedBatchAsync( + ListenArrDbContext db, + VerifiedFileRenameJournal journal, + CancellationToken cancellationToken) + { + var batch = await db.VerifiedFileRenameJournals + .AsNoTracking() + .Where(candidate => candidate.BatchId == journal.BatchId) + .ToListAsync(cancellationToken); + if (batch.Count == 0 + || batch.Count != journal.ExpectedBatchMemberCount + || batch.Any(candidate => + candidate.ProtocolVersion != VerifiedFileRenameProtocol.Current + || candidate.AudiobookId != journal.AudiobookId + || candidate.ExpectedBatchMemberCount != batch.Count + || !string.Equals( + candidate.ExpectedBatchManifestSha256, + journal.ExpectedBatchManifestSha256, + StringComparison.OrdinalIgnoreCase) + || candidate.State is VerifiedFileRenameState.Planned + or VerifiedFileRenameState.TargetVerified + or VerifiedFileRenameState.RolledBack + or VerifiedFileRenameState.NeedsAttention)) + { + return false; + } + + var manifest = VerifiedFileRenameBatchManifest.Create( + batch.Select(candidate => new VerifiedFileRenameBatchMember( + candidate.AudiobookFileId, + candidate.SourcePath, + candidate.DestinationPath))); + return manifest.ExpectedMemberCount == batch.Count + && string.Equals( + manifest.ManifestSha256, + journal.ExpectedBatchManifestSha256, + StringComparison.OrdinalIgnoreCase); + } + + private async Task OwnerPointsToAsync( + ListenArrDbContext db, + VerifiedFileRenameJournal journal, + string expectedPath, + CancellationToken cancellationToken) + { + var audiobook = await db.Audiobooks + .AsNoTracking() + .Include(candidate => candidate.Files) + .SingleOrDefaultAsync( + candidate => candidate.Id == journal.AudiobookId, + cancellationToken); + if (audiobook == null) + { + return false; + } + + if (journal.AudiobookFileId == 0) + { + if (string.IsNullOrWhiteSpace(audiobook.FilePath)) + { + return false; + } + + try + { + var currentIdentity = await identityResolver.ResolveAsync( + audiobook, + audiobook.FilePath, + cancellationToken); + var legacyExpectedIdentity = await identityResolver.ResolveAsync( + audiobook, + expectedPath, + cancellationToken); + if (currentIdentity.State == PathIdentityState.Unavailable + || legacyExpectedIdentity.State == PathIdentityState.Unavailable) + { + return null; + } + if (currentIdentity.State != PathIdentityState.Valid + || legacyExpectedIdentity.State != PathIdentityState.Valid + || string.IsNullOrWhiteSpace(currentIdentity.OwnershipKey) + || string.IsNullOrWhiteSpace(legacyExpectedIdentity.OwnershipKey)) + { + return false; + } + + return string.Equals( + currentIdentity.OwnershipKey, + legacyExpectedIdentity.OwnershipKey, + StringComparison.Ordinal); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException) + { + return null; + } + } + + var trackedFile = audiobook.Files?.SingleOrDefault( + file => file.Id == journal.AudiobookFileId); + if (trackedFile == null + || string.IsNullOrWhiteSpace(trackedFile.PathOwnershipKey)) + { + return false; + } + + AudiobookFilePathIdentity expectedIdentity; + try + { + expectedIdentity = await identityResolver.ResolveAsync( + audiobook, + expectedPath, + cancellationToken); + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException) + { + return null; + } + if (expectedIdentity.State == PathIdentityState.Unavailable) + { + return null; + } + if (expectedIdentity.State != PathIdentityState.Valid + || string.IsNullOrWhiteSpace(expectedIdentity.OwnershipKey)) + { + return false; + } + + return string.Equals( + trackedFile.PathOwnershipKey, + expectedIdentity.OwnershipKey, + StringComparison.Ordinal); + } +} diff --git a/listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.cs b/listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.cs new file mode 100644 index 000000000..0dc4d07bf --- /dev/null +++ b/listenarr.infrastructure/Persistence/VerifiedFileRenameRecoveryService.cs @@ -0,0 +1,436 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Persistence; + +internal interface IVerifiedFileRenameRecoveryService +{ + Task ReconcileAsync(CancellationToken cancellationToken = default); +} + +internal sealed partial class VerifiedFileRenameRecoveryService( + IDbContextFactory dbContextFactory, + IAudiobookFilePathIdentityResolver identityResolver, + TimeProvider timeProvider, + ILogger logger) + : IVerifiedFileRenameRecoveryService +{ + private enum ContentProbeOutcome + { + Match, + Missing, + Unavailable, + Mismatch + } + + public async Task ReconcileAsync( + CancellationToken cancellationToken = default) + { + await ThrowIfNeedsAttentionAsync(cancellationToken); + + await using var readDb = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var operationIds = await readDb.VerifiedFileRenameJournals + .AsNoTracking() + .Where(journal => + journal.State != VerifiedFileRenameState.Completed + && journal.State != VerifiedFileRenameState.CompletedSourceRetained + && journal.State != VerifiedFileRenameState.RolledBack + && journal.State != VerifiedFileRenameState.NeedsAttention) + .OrderBy(journal => journal.CreatedAt) + .ThenBy(journal => journal.OperationId) + .Select(journal => journal.OperationId) + .ToListAsync(cancellationToken); + + foreach (var operationId in operationIds) + { + cancellationToken.ThrowIfCancellationRequested(); + await ReconcileOperationAsync(operationId, cancellationToken); + } + + await ThrowIfNeedsAttentionAsync(cancellationToken); + } + + private async Task ReconcileOperationAsync( + Guid operationId, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var journal = await db.VerifiedFileRenameJournals + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken); + if (journal == null || IsTerminal(journal.State)) + { + return; + } + if (journal.ProtocolVersion != VerifiedFileRenameProtocol.Current) + { + MarkNeedsAttention( + journal, + "The interrupted verified organize journal uses an unsupported recovery protocol."); + await db.SaveChangesAsync(cancellationToken); + return; + } + + var sourceProbe = await ProbeContentAsync( + journal.SourcePath, + journal.SourceLength, + journal.SourceSha256, + cancellationToken); + var targetProbe = await ProbeContentAsync( + journal.DestinationPath, + journal.SourceLength, + journal.SourceSha256, + cancellationToken); + var stagingProbe = await ProbePathExistsAsync( + journal.StagingPath, + cancellationToken); + var retirementProbe = await ProbePathExistsAsync( + journal.RetirementPath, + cancellationToken); + + switch (journal.State) + { + case VerifiedFileRenameState.Planned: + { + var ownerAtSource = await OwnerPointsToAsync( + db, + journal, + journal.SourcePath, + cancellationToken); + if (ownerAtSource == true + && sourceProbe == ContentProbeOutcome.Match + && targetProbe == ContentProbeOutcome.Missing + && stagingProbe == ContentProbeOutcome.Missing + && retirementProbe == ContentProbeOutcome.Missing) + { + journal.State = VerifiedFileRenameState.RolledBack; + journal.Error = + "Interrupted verified organize recovered before target publication; the original source remained authoritative."; + } + else + { + MarkNeedsAttention( + journal, + "Verified organize was interrupted before target verification. Source/target artifacts were preserved because restart recovery cannot prove weak-storage physical generations by path or content alone."); + } + break; + } + case VerifiedFileRenameState.TargetVerified: + { + var ownerAtSource = await OwnerPointsToAsync( + db, + journal, + journal.SourcePath, + cancellationToken); + if (ownerAtSource == true + && sourceProbe == ContentProbeOutcome.Match + && targetProbe == ContentProbeOutcome.Missing + && stagingProbe == ContentProbeOutcome.Missing + && retirementProbe == ContentProbeOutcome.Missing) + { + journal.State = VerifiedFileRenameState.RolledBack; + journal.Error = + "The verified target was no longer present after restart; the still-authoritative source was retained."; + } + else + { + MarkNeedsAttention( + journal, + "A verified organize target was published before owner metadata committed. The original source and published artifacts were preserved for operator review; restart recovery will not delete either path by hash equality alone."); + } + break; + } + case VerifiedFileRenameState.OwnerMetadataReconciled: + { + if (!await ValidateCommittedBatchAsync( + db, + journal, + cancellationToken)) + { + MarkNeedsAttention( + journal, + "The committed verified organize batch manifest is incomplete or inconsistent."); + break; + } + + var ownerAtDestination = await OwnerPointsToAsync( + db, + journal, + journal.DestinationPath, + cancellationToken); + if (ownerAtDestination != true + || targetProbe != ContentProbeOutcome.Match) + { + MarkNeedsAttention( + journal, + "Verified organize owner metadata committed, but the tracked destination can no longer be proven by path identity and content."); + break; + } + + if (retirementProbe != ContentProbeOutcome.Missing) + { + MarkNeedsAttention( + journal, + "Verified organize restart found an operation-owned retirement artifact before its quarantine state was durably recorded. The artifact was preserved for operator review."); + break; + } + + if (sourceProbe == ContentProbeOutcome.Missing) + { + journal.State = VerifiedFileRenameState.Completed; + journal.Error = null; + } + else + { + journal.State = VerifiedFileRenameState.CompletedSourceRetained; + journal.Error = + "Owner metadata committed before restart; the old weak-storage source was retained because restart recovery has no durable physical-generation authority to delete it."; + } + break; + } + case VerifiedFileRenameState.SourceQuarantined: + { + if (!await ValidateCommittedBatchAsync( + db, + journal, + cancellationToken)) + { + MarkNeedsAttention( + journal, + "The quarantined verified organize batch manifest is incomplete or inconsistent."); + break; + } + + var ownerAtDestination = await OwnerPointsToAsync( + db, + journal, + journal.DestinationPath, + cancellationToken); + if (ownerAtDestination != true + || targetProbe != ContentProbeOutcome.Match) + { + MarkNeedsAttention( + journal, + "Verified organize source quarantine was recorded, but its committed destination can no longer be proven. The retirement artifact was preserved."); + break; + } + + if (retirementProbe != ContentProbeOutcome.Missing) + { + MarkNeedsAttention( + journal, + "Verified organize source retirement was interrupted while the exact source was in the operation-owned retirement namespace. Restart recovery preserved it for operator repair."); + break; + } + + if (sourceProbe == ContentProbeOutcome.Missing) + { + journal.State = VerifiedFileRenameState.Completed; + journal.Error = null; + } + else + { + journal.State = VerifiedFileRenameState.CompletedSourceRetained; + journal.Error = + "The original source path is present after an interrupted retirement. It was treated as retained/new content and was not deleted during restart recovery."; + } + break; + } + case VerifiedFileRenameState.SourceDeleted: + { + if (!await ValidateCommittedBatchAsync( + db, + journal, + cancellationToken)) + { + MarkNeedsAttention( + journal, + "The source-deleted verified organize batch manifest is incomplete or inconsistent."); + break; + } + + var ownerAtDestination = await OwnerPointsToAsync( + db, + journal, + journal.DestinationPath, + cancellationToken); + if (ownerAtDestination != true + || targetProbe != ContentProbeOutcome.Match) + { + MarkNeedsAttention( + journal, + "Verified organize source retirement was recorded, but its committed destination can no longer be proven."); + break; + } + if (retirementProbe != ContentProbeOutcome.Missing) + { + MarkNeedsAttention( + journal, + "Verified organize source deletion was recorded, but an operation-owned retirement artifact is still visible. It was preserved for operator review."); + break; + } + + if (sourceProbe == ContentProbeOutcome.Missing) + { + journal.State = VerifiedFileRenameState.Completed; + journal.Error = null; + } + else + { + journal.State = VerifiedFileRenameState.CompletedSourceRetained; + journal.Error = + "The old source path is present after source retirement was recorded. It was treated as a new/unowned path and was not deleted during restart recovery."; + } + break; + } + } + + journal.UpdatedAt = timeProvider.GetUtcNow().UtcDateTime; + await db.SaveChangesAsync(cancellationToken); + if (journal.State == VerifiedFileRenameState.CompletedSourceRetained) + { + logger.LogWarning( + "Verified organize recovery {OperationId} completed with the old weak-storage source retained: {Reason}", + journal.OperationId, + journal.Error); + } + else if (journal.State == VerifiedFileRenameState.NeedsAttention) + { + logger.LogWarning( + "Verified organize recovery {OperationId} requires attention: {Reason}", + journal.OperationId, + journal.Error); + } + } + + private static async Task ProbeContentAsync( + string path, + long expectedLength, + string expectedSha256, + CancellationToken cancellationToken) + { + try + { + var fullPath = Path.GetFullPath(path); + var parentPath = Path.GetDirectoryName(fullPath); + var fileName = Path.GetFileName(fullPath); + if (string.IsNullOrWhiteSpace(parentPath) + || string.IsNullOrWhiteSpace(fileName)) + { + return ContentProbeOutcome.Mismatch; + } + + using var parent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + parentPath); + var outcome = parent.TryOpenExistingFileWithOutcome( + fileName, + requireDeleteAccess: false, + out var openedEntry); + using var entry = openedEntry; + return outcome switch + { + PinnedFileOpenOutcome.NotFound => ContentProbeOutcome.Missing, + PinnedFileOpenOutcome.Unavailable => ContentProbeOutcome.Unavailable, + _ when entry == null || !entry.IsRegularFile() => + ContentProbeOutcome.Mismatch, + _ when await entry.MatchesAsync( + expectedLength, + expectedSha256, + cancellationToken) => ContentProbeOutcome.Match, + _ => ContentProbeOutcome.Mismatch + }; + } + catch (Exception exception) when ( + FileSystemSafety.IsProvenMissingPathException(exception)) + { + return ContentProbeOutcome.Missing; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or PathTooLongException or System.Security.SecurityException) + { + return ContentProbeOutcome.Unavailable; + } + } + + private static async Task ProbePathExistsAsync( + string path, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var fullPath = Path.GetFullPath(path); + var parentPath = Path.GetDirectoryName(fullPath); + var fileName = Path.GetFileName(fullPath); + if (string.IsNullOrWhiteSpace(parentPath) + || string.IsNullOrWhiteSpace(fileName)) + { + return ContentProbeOutcome.Mismatch; + } + + using var parent = PinnedDirectoryCreation.OpenPinnedDirectoryNoFollow( + parentPath); + var outcome = parent.TryOpenExistingFileWithOutcome( + fileName, + requireDeleteAccess: false, + out var openedEntry); + using var entry = openedEntry; + return outcome switch + { + PinnedFileOpenOutcome.NotFound => ContentProbeOutcome.Missing, + PinnedFileOpenOutcome.Unavailable => ContentProbeOutcome.Unavailable, + _ => ContentProbeOutcome.Match + }; + } + catch (Exception exception) when ( + FileSystemSafety.IsProvenMissingPathException(exception)) + { + return ContentProbeOutcome.Missing; + } + catch (Exception exception) when (exception is + IOException or UnauthorizedAccessException + or InvalidOperationException or NotSupportedException + or PathTooLongException or System.Security.SecurityException) + { + return ContentProbeOutcome.Unavailable; + } + } + + private async Task ThrowIfNeedsAttentionAsync( + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync( + cancellationToken); + var attentionId = await db.VerifiedFileRenameJournals + .AsNoTracking() + .Where(journal => journal.State == VerifiedFileRenameState.NeedsAttention) + .OrderBy(journal => journal.CreatedAt) + .ThenBy(journal => journal.OperationId) + .Select(journal => (Guid?)journal.OperationId) + .FirstOrDefaultAsync(cancellationToken); + if (attentionId.HasValue) + { + throw new InvalidOperationException( + $"Verified organize journal {attentionId.Value} requires operator repair before filesystem mutations can resume."); + } + } + + private static bool IsTerminal(VerifiedFileRenameState state) => + state is VerifiedFileRenameState.Completed + or VerifiedFileRenameState.CompletedSourceRetained + or VerifiedFileRenameState.RolledBack + or VerifiedFileRenameState.NeedsAttention; + + private static void MarkNeedsAttention( + VerifiedFileRenameJournal journal, + string reason) + { + journal.State = VerifiedFileRenameState.NeedsAttention; + journal.Error = reason; + } +} diff --git a/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs b/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs index 6f4ffc33c..380f5c1b6 100644 --- a/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs @@ -1143,6 +1143,469 @@ public async Task ExecuteRename_MovesFileAndUpdatesDatabasePaths() Assert.Equal(NormalizePath(targetPath), NormalizePath(saved.Files!.Single().Path)); } + [Fact] + public async Task ExecuteRename_ContentOnlySource_UsesVerifiedProtocolAndClearsPhysicalIdentity() + { + var libraryRoot = Path.Join(_tempRoot, "verified-organize-success"); + var sourceFolder = Path.Join(libraryRoot, "Old"); + var targetFolder = Path.Join(libraryRoot, "Author", "Book"); + var sourcePath = Path.Join(sourceFolder, "old-name.m4b"); + var targetPath = Path.Join(targetFolder, "Book.m4b"); + Directory.CreateDirectory(sourceFolder); + await File.WriteAllTextAsync(sourcePath, "verified-audio"); + + var capability = BuildContentOnlySourceCapability(); + var coordinator = new Mock( + MockBehavior.Strict); + VerifiedFileRenameBatchManifest? capturedManifest = null; + coordinator.Setup(candidate => candidate.PrepareAsync( + sourcePath, + targetPath, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 40, + 401, + It.IsAny(), + It.IsAny())) + .Returns( + async (_, _, operationId, _, manifest, _, _, proof, _) => + { + capturedManifest = manifest; + Directory.CreateDirectory(targetFolder); + File.Copy(sourcePath, targetPath, overwrite: false); + return new VerifiedFileRenamePreparationResult( + true, + new TestVerifiedRenameLease( + operationId, + rollBack: () => + { + File.Delete(targetPath); + return true; + }, + complete: () => + { + File.Delete(sourcePath); + return VerifiedFileRenameRetirementOutcome.Completed; + })); + }); + + var (service, db, dbName) = BuildService( + new ApplicationSettings + { + OutputPath = libraryRoot, + FolderNamingPattern = "{Author}/{Title}", + FileNamingPattern = "{Title}" + }, + filePublicationSourceCapabilityOverride: capability, + verifiedFileRenameTransactionCoordinatorOverride: coordinator.Object); + db.Audiobooks.Add(new Audiobook + { + Id = 40, + Title = "Book", + Authors = ["Author"], + BasePath = sourceFolder, + FilePath = sourcePath, + Files = [CreateTrackedFile(401, 40, sourcePath)] + }); + await db.SaveChangesAsync(); + + var result = Assert.Single(await service.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = 40, + CurrentFolderPath = sourceFolder, + CurrentFolderSemantics = ExpectedSemantics(sourceFolder), + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 401, + CurrentPath = sourcePath, + NewPath = targetPath + } + ] + } + ])); + + Assert.True(result.Success, result.Error); + Assert.False(File.Exists(sourcePath)); + Assert.True(File.Exists(targetPath)); + Assert.True(capturedManifest.HasValue); + Assert.Equal(1, capturedManifest.Value.ExpectedMemberCount); + await using var verifyDb = CreateContext(dbName); + var saved = await verifyDb.Audiobooks + .Include(candidate => candidate.Files) + .SingleAsync(candidate => candidate.Id == 40); + var savedFile = Assert.Single(saved.Files!); + Assert.Equal(NormalizePath(targetFolder), NormalizePath(saved.BasePath)); + Assert.Equal(NormalizePath(targetPath), NormalizePath(savedFile.Path)); + Assert.Null(savedFile.PhysicalObjectIdentity); + coordinator.VerifyAll(); + } + + [Fact] + public async Task ExecuteRename_VerifiedRetirementNeedsAttention_ReportsFailureAfterOwnerCommit() + { + var libraryRoot = Path.Join(_tempRoot, "verified-organize-attention"); + var sourceFolder = Path.Join(libraryRoot, "Old"); + var targetFolder = Path.Join(libraryRoot, "New"); + var sourcePath = Path.Join(sourceFolder, "old-name.m4b"); + var targetPath = Path.Join(targetFolder, "new-name.m4b"); + Directory.CreateDirectory(sourceFolder); + await File.WriteAllTextAsync(sourcePath, "verified-attention-audio"); + + var coordinator = new Mock( + MockBehavior.Strict); + coordinator.Setup(candidate => candidate.PrepareAsync( + sourcePath, + targetPath, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 42, + 421, + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, operationId, _, _, _, _, _, _) => + { + Directory.CreateDirectory(targetFolder); + File.Copy(sourcePath, targetPath, overwrite: false); + return Task.FromResult(new VerifiedFileRenamePreparationResult( + true, + new TestVerifiedRenameLease( + operationId, + rollBack: () => + { + File.Delete(targetPath); + return true; + }, + complete: () => + VerifiedFileRenameRetirementOutcome.NeedsAttention))); + }); + + var (service, db, dbName) = BuildService( + new ApplicationSettings + { + OutputPath = libraryRoot, + FolderNamingPattern = "{Title}", + FileNamingPattern = "{Title}" + }, + filePublicationSourceCapabilityOverride: BuildContentOnlySourceCapability(), + verifiedFileRenameTransactionCoordinatorOverride: coordinator.Object); + db.Audiobooks.Add(new Audiobook + { + Id = 42, + Title = "Book", + BasePath = sourceFolder, + FilePath = sourcePath, + Files = [CreateTrackedFile(421, 42, sourcePath)] + }); + await db.SaveChangesAsync(); + + var result = Assert.Single(await service.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = 42, + CurrentFolderPath = sourceFolder, + CurrentFolderSemantics = ExpectedSemantics(sourceFolder), + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 421, + CurrentPath = sourcePath, + NewPath = targetPath + } + ] + } + ])); + + Assert.False(result.Success); + Assert.Contains("requires repair", result.Error, StringComparison.OrdinalIgnoreCase); + var fileResult = Assert.Single(result.RenamedFiles); + Assert.False(fileResult.Success); + Assert.Contains("requires repair", fileResult.Error, StringComparison.OrdinalIgnoreCase); + Assert.True(File.Exists(sourcePath)); + Assert.True(File.Exists(targetPath)); + + await using var verifyDb = CreateContext(dbName); + var saved = await verifyDb.Audiobooks + .Include(candidate => candidate.Files) + .SingleAsync(candidate => candidate.Id == 42); + Assert.Equal(NormalizePath(targetPath), NormalizePath(saved.Files!.Single().Path)); + coordinator.VerifyAll(); + } + + [Fact] + public async Task ExecuteRename_VerifiedRetirementNeedsAttention_StopsLaterSourceRetirement() + { + var libraryRoot = Path.Join(_tempRoot, "verified-organize-attention-batch"); + var sourceFolder = Path.Join(libraryRoot, "Old"); + var targetFolder = Path.Join(libraryRoot, "New"); + var firstSource = Path.Join(sourceFolder, "first.m4b"); + var secondSource = Path.Join(sourceFolder, "second.m4b"); + var firstTarget = Path.Join(targetFolder, "first-new.m4b"); + var secondTarget = Path.Join(targetFolder, "second-new.m4b"); + Directory.CreateDirectory(sourceFolder); + await File.WriteAllTextAsync(firstSource, "first-attention-audio"); + await File.WriteAllTextAsync(secondSource, "second-attention-audio"); + + var secondRetirementCalled = false; + var secondDisposed = false; + var coordinator = new Mock( + MockBehavior.Strict); + coordinator.Setup(candidate => candidate.PrepareAsync( + firstSource, + firstTarget, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 43, + 431, + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, operationId, _, manifest, _, _, _, _) => + { + Assert.Equal(2, manifest.ExpectedMemberCount); + Directory.CreateDirectory(targetFolder); + File.Copy(firstSource, firstTarget, overwrite: false); + return Task.FromResult(new VerifiedFileRenamePreparationResult( + true, + new TestVerifiedRenameLease( + operationId, + rollBack: () => true, + complete: () => + VerifiedFileRenameRetirementOutcome.NeedsAttention))); + }); + coordinator.Setup(candidate => candidate.PrepareAsync( + secondSource, + secondTarget, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 43, + 432, + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, operationId, _, manifest, _, _, _, _) => + { + Assert.Equal(2, manifest.ExpectedMemberCount); + Directory.CreateDirectory(targetFolder); + File.Copy(secondSource, secondTarget, overwrite: false); + return Task.FromResult(new VerifiedFileRenamePreparationResult( + true, + new TestVerifiedRenameLease( + operationId, + rollBack: () => true, + complete: () => + { + secondRetirementCalled = true; + File.Delete(secondSource); + return VerifiedFileRenameRetirementOutcome.Completed; + }, + dispose: () => secondDisposed = true))); + }); + + var (service, db, _) = BuildService( + new ApplicationSettings + { + OutputPath = libraryRoot, + FolderNamingPattern = "{Title}", + FileNamingPattern = "{Title}" + }, + filePublicationSourceCapabilityOverride: BuildContentOnlySourceCapability(), + verifiedFileRenameTransactionCoordinatorOverride: coordinator.Object); + db.Audiobooks.Add(new Audiobook + { + Id = 43, + Title = "Book", + BasePath = sourceFolder, + Files = + [ + CreateTrackedFile(431, 43, firstSource), + CreateTrackedFile(432, 43, secondSource) + ] + }); + await db.SaveChangesAsync(); + + var result = Assert.Single(await service.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = 43, + CurrentFolderPath = sourceFolder, + CurrentFolderSemantics = ExpectedSemantics(sourceFolder), + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 431, + CurrentPath = firstSource, + NewPath = firstTarget + }, + new FileRenameOperation + { + FileId = 432, + CurrentPath = secondSource, + NewPath = secondTarget + } + ] + } + ])); + + Assert.False(result.Success); + Assert.False(secondRetirementCalled); + Assert.True(secondDisposed); + Assert.True(File.Exists(firstSource)); + Assert.True(File.Exists(secondSource)); + Assert.True(File.Exists(firstTarget)); + Assert.True(File.Exists(secondTarget)); + coordinator.VerifyAll(); + } + + [Fact] + public async Task ExecuteRename_VerifiedBatchLaterMemberFails_RollsBackPublishedMembers() + { + var libraryRoot = Path.Join(_tempRoot, "verified-organize-rollback"); + var sourceFolder = Path.Join(libraryRoot, "Old"); + var targetFolder = Path.Join(libraryRoot, "New"); + var firstSource = Path.Join(sourceFolder, "first.m4b"); + var secondSource = Path.Join(sourceFolder, "second.m4b"); + var firstTarget = Path.Join(targetFolder, "first.m4b"); + var secondTarget = Path.Join(targetFolder, "second.m4b"); + Directory.CreateDirectory(sourceFolder); + await File.WriteAllTextAsync(firstSource, "first-audio"); + await File.WriteAllTextAsync(secondSource, "second-audio"); + + var capability = BuildContentOnlySourceCapability(); + var coordinator = new Mock( + MockBehavior.Strict); + Guid? batchId = null; + coordinator.Setup(candidate => candidate.PrepareAsync( + firstSource, + firstTarget, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 41, + 411, + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, operationId, currentBatchId, manifest, _, _, _, _) => + { + batchId = currentBatchId; + Assert.Equal(2, manifest.ExpectedMemberCount); + Directory.CreateDirectory(targetFolder); + File.Copy(firstSource, firstTarget, overwrite: false); + return Task.FromResult(new VerifiedFileRenamePreparationResult( + true, + new TestVerifiedRenameLease( + operationId, + rollBack: () => + { + File.Delete(firstTarget); + return true; + }, + complete: () => throw new InvalidOperationException( + "Source retirement must not run after batch rollback.")))); + }); + coordinator.Setup(candidate => candidate.PrepareAsync( + secondSource, + secondTarget, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 41, + 412, + It.IsAny(), + It.IsAny())) + .Returns( + (_, _, _, currentBatchId, manifest, _, _, _, _) => + { + Assert.Equal(batchId, currentBatchId); + Assert.Equal(2, manifest.ExpectedMemberCount); + return Task.FromResult(new VerifiedFileRenamePreparationResult( + false, + Error: "Injected second verified member failure.")); + }); + + var (service, db, dbName) = BuildService( + new ApplicationSettings + { + OutputPath = libraryRoot, + FolderNamingPattern = "{Title}", + FileNamingPattern = "{Title}" + }, + filePublicationSourceCapabilityOverride: capability, + verifiedFileRenameTransactionCoordinatorOverride: coordinator.Object); + db.Audiobooks.Add(new Audiobook + { + Id = 41, + Title = "Book", + BasePath = sourceFolder, + Files = + [ + CreateTrackedFile(411, 41, firstSource), + CreateTrackedFile(412, 41, secondSource) + ] + }); + await db.SaveChangesAsync(); + + var result = Assert.Single(await service.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = 41, + CurrentFolderPath = sourceFolder, + CurrentFolderSemantics = ExpectedSemantics(sourceFolder), + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 411, + CurrentPath = firstSource, + NewPath = firstTarget + }, + new FileRenameOperation + { + FileId = 412, + CurrentPath = secondSource, + NewPath = secondTarget + } + ] + } + ])); + + Assert.False(result.Success); + Assert.True(File.Exists(firstSource)); + Assert.True(File.Exists(secondSource)); + Assert.False(File.Exists(firstTarget)); + Assert.False(File.Exists(secondTarget)); + await using var verifyDb = CreateContext(dbName); + var saved = await verifyDb.Audiobooks + .Include(candidate => candidate.Files) + .SingleAsync(candidate => candidate.Id == 41); + Assert.Equal(NormalizePath(sourceFolder), NormalizePath(saved.BasePath)); + Assert.Contains(saved.Files!, file => + file.Id == 411 + && NormalizePath(file.Path) == NormalizePath(firstSource)); + Assert.Contains(saved.Files!, file => + file.Id == 412 + && NormalizePath(file.Path) == NormalizePath(secondSource)); + coordinator.VerifyAll(); + } + [Fact] public async Task PreviewRename_RelativeStoredFilePath_UsesAudiobookBasePath() { @@ -1908,7 +2371,9 @@ public override Task SaveChangesAsync( IFileSystemSemanticsResolver? semanticsResolverOverride = null, IRootFolderService? rootFolderServiceOverride = null, IAudiobookFilePathIdentityResolver? identityResolverOverride = null, - IMoveQueueService? moveQueueServiceOverride = null) + IMoveQueueService? moveQueueServiceOverride = null, + IFilePublicationSourceCapability? filePublicationSourceCapabilityOverride = null, + IVerifiedFileRenameTransactionCoordinator? verifiedFileRenameTransactionCoordinatorOverride = null) { var dbName = Guid.NewGuid().ToString(); var options = new DbContextOptionsBuilder() @@ -2051,6 +2516,33 @@ Task MovePreservingIdentity( It.IsAny())) .Returns, CancellationToken>( (_, _, cancellationToken) => repo.SaveChangesAsync(cancellationToken)); + var sourceCapability = filePublicationSourceCapabilityOverride; + if (sourceCapability == null) + { + var sourceCapabilityMock = new Mock( + MockBehavior.Strict); + sourceCapabilityMock.Setup(capability => capability.CheckAsync( + It.IsAny(), + It.IsAny())) + .Returns((path, _) => + { + var bytes = File.ReadAllBytes(path); + var proof = new FilePublicationSourceProof( + GetPhysicalObjectIdentity(path), + bytes.LongLength, + Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(bytes))); + return Task.FromResult( + FilePublicationSourceCapabilityResult.SupportedForProof( + proof)); + }); + sourceCapability = sourceCapabilityMock.Object; + } + var verifiedRenameCoordinator = + verifiedFileRenameTransactionCoordinatorOverride + ?? new Mock( + MockBehavior.Strict).Object; + var service = new RenameService( config.Object, fileNaming, @@ -2066,6 +2558,8 @@ Task MovePreservingIdentity( moveQueueServiceOverride ?? moveQueueService.Object, directoryOwnershipStore.Object, renameCommitStore.Object, + sourceCapability, + verifiedRenameCoordinator, rootFolderServiceOverride); return (service, db, dbName); @@ -2099,6 +2593,57 @@ private static string GetPhysicalObjectIdentity(string path) return lease.PhysicalObjectIdentity; } + private static IFilePublicationSourceCapability BuildContentOnlySourceCapability() + { + var capability = new Mock(MockBehavior.Strict); + capability.Setup(candidate => candidate.CheckAsync( + It.IsAny(), + It.IsAny())) + .Returns((path, _) => + { + var bytes = File.ReadAllBytes(path); + var hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(bytes)); + return Task.FromResult( + FilePublicationSourceCapabilityResult.SupportedForProof( + new FilePublicationSourceProof( + $"content-only:{hash}", + bytes.LongLength, + hash, + FilePublicationSourceAuthority.ContentOnly))); + }); + return capability.Object; + } + + private sealed class TestVerifiedRenameLease( + Guid operationId, + Func rollBack, + Func complete, + Action? dispose = null) : IVerifiedFileRenameLease + { + public Guid OperationId { get; } = operationId; + + public Task RollBackAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(rollBack()); + } + + public Task CompleteSourceRetirementAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(complete()); + } + + public ValueTask DisposeAsync() + { + dispose?.Invoke(); + return ValueTask.CompletedTask; + } + } + private static IFileSystemSemanticsResolver BuildSemanticsResolver(FileSystemCaseSensitivity? caseSensitivity) { var resolver = new Mock(); diff --git a/tests/Features/Architecture/BackendArchitectureTests.cs b/tests/Features/Architecture/BackendArchitectureTests.cs index e4d773454..9e19213a9 100644 --- a/tests/Features/Architecture/BackendArchitectureTests.cs +++ b/tests/Features/Architecture/BackendArchitectureTests.cs @@ -1098,10 +1098,14 @@ public void LibraryFilesystem_OnlyUsesAuditedCompatibilityQuarantineNamespace() "entry.claim" }; - var allowedQuarantineFiles = new HashSet(StringComparer.Ordinal) + var allowedOperationNamespaceFiles = new Dictionary(StringComparer.Ordinal) { - "listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs", - "listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Enumeration.cs" + ["listenarr.infrastructure/FileSystem/CompatibilitySourceCleanupCoordinator.cs"] = + ".listenarr-quarantine-", + ["listenarr.infrastructure/Library/Scanning/ScanFileDiscovery.Enumeration.cs"] = + ".listenarr-quarantine-", + ["listenarr.infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinator.cs"] = + ".listenarr-organize-" }; var violations = productionRoots .SelectMany(root => Directory.EnumerateFiles( @@ -1117,9 +1121,11 @@ public void LibraryFilesystem_OnlyUsesAuditedCompatibilityQuarantineNamespace() .SelectMany(candidate => forbidden .Where(token => candidate.Source.Contains(token, StringComparison.Ordinal) && !(token == ".listenarr-" - && allowedQuarantineFiles.Contains(candidate.File) + && allowedOperationNamespaceFiles.TryGetValue( + candidate.File, + out var auditedPrefix) && candidate.Source.Contains( - ".listenarr-quarantine-", + auditedPrefix, StringComparison.Ordinal))) .Select(token => $"{candidate.File}: {token}")) .ToList(); diff --git a/tests/Features/Infrastructure/FileSystem/DockerWeakStorageOrganiseContractTests.cs b/tests/Features/Infrastructure/FileSystem/DockerWeakStorageOrganiseContractTests.cs new file mode 100644 index 000000000..f5f323eb5 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/DockerWeakStorageOrganiseContractTests.cs @@ -0,0 +1,504 @@ +using System.Security.Cryptography; +using Listenarr.Infrastructure.Library.Files; +using Listenarr.Infrastructure.Persistence.Repositories; +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "DockerWeakStorageOrganiseContractTests")] +[Trait("Category", "Infrastructure")] +public sealed class DockerWeakStorageOrganiseContractTests : BaseTests +{ + [NativeWeakStorageRemountFact] + public async Task OwnerCommittedVerifiedOrganise_AfterWeakCifsRemount_RetainsOldSourceSafely() + { + var mountPath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PathEnvironmentVariable)!; + var databasePath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.StatePathEnvironmentVariable)!; + var phase = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PhaseEnvironmentVariable)!; + + switch (phase.Trim().ToLowerInvariant()) + { + case "capture": + await CaptureOwnerCommittedRecoveryStateAsync(mountPath, databasePath); + break; + case "verify": + await VerifyOwnerCommittedRecoveryStateAsync(databasePath); + break; + default: + throw new InvalidOperationException( + $"Unknown native verified-organise recovery phase '{phase}'."); + } + } + + [NativeWeakStorageRemountFact] + public async Task SourceQuarantinedVerifiedOrganise_AfterWeakCifsRemount_PreservesRetirementArtifact() + { + var mountPath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PathEnvironmentVariable)!; + var databasePath = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.StatePathEnvironmentVariable)!; + var phase = Environment.GetEnvironmentVariable( + NativeStorageRemountFactAttribute.PhaseEnvironmentVariable)!; + + switch (phase.Trim().ToLowerInvariant()) + { + case "capture": + await CaptureSourceQuarantinedRecoveryStateAsync(mountPath, databasePath); + break; + case "verify": + await VerifySourceQuarantinedRecoveryStateAsync(databasePath); + break; + default: + throw new InvalidOperationException( + $"Unknown native verified-organise quarantine recovery phase '{phase}'."); + } + } + + [NativeWeakStorageFact] + public async Task VerifiedOrganise_TrackedFile_SucceedsOnWeakCifs() + { + var mountPath = Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.PathEnvironmentVariable)!; + var rootPath = Path.Join( + mountPath, + "listenarr-native-organise-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootPath); + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var root = new RootFolderBuilder() + .WithName("Native Weak CIFS Organise") + .WithPath(rootPath) + .WithCaseSensitivityMode(FileSystemCaseSensitivityMode.Sensitive) + .Build(); + root.ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive; + root.PathIdentityState = PathIdentityState.Valid; + root.PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + rootPath, + semantics); + root.StorageContractRevision = 12; + await _rootFolderRepository.AddAsync(root); + + var health = await _provider + .GetRequiredService() + .ResolveAsync(root); + Assert.Equal(RootFolderStorageState.Limited, health.State); + Assert.Equal(RootFolderStorageReason.IdentityUnsupported, health.Reason); + Assert.True(health.CanPublishAdditively); + Assert.True(health.CanRetireVerifiedSource); + Assert.False(health.CanMutateFilesystem); + + var sourceFolder = Path.Join(rootPath, "Old"); + Directory.CreateDirectory(sourceFolder); + var sourcePath = Path.Join(sourceFolder, "old-name.m4b"); + await File.WriteAllTextAsync(sourcePath, "native-verified-organise-audio"); + var sourceCapability = await _provider + .GetRequiredService() + .CheckAsync(sourcePath); + Assert.True(sourceCapability.IsSupported, sourceCapability.Reason); + Assert.True(sourceCapability.SourceProof.HasValue); + Assert.False(sourceCapability.SourceProof.Value.HasDurablePhysicalObjectIdentity); + + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Native Organise Book", + Authors = ["Native Author"], + BasePath = sourceFolder + }); + var identityResolver = _provider + .GetRequiredService(); + var sourceIdentity = await identityResolver.ResolveAsync( + audiobook, + sourcePath); + Assert.Equal(PathIdentityState.Valid, sourceIdentity.State); + var trackedFile = new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = sourcePath, + Format = "m4b", + Size = new FileInfo(sourcePath).Length + }; + trackedFile.ApplyPathIdentity(sourcePath, sourceIdentity); + trackedFile.ClearPhysicalObjectIdentity(); + trackedFile = await _audiobookFileRepository.AddAsync(trackedFile); + + await _applicationSettingsRepository.SaveAsync( + new ApplicationSettingsBuilder() + .WithOutputPath(rootPath) + .WithFolderNamingPattern("{Author}/{Title}") + .WithFileNamingPattern("{Title}") + .Build()); + + var renameService = _provider.GetRequiredService(); + var preview = Assert.Single(await renameService.PreviewRenameAsync([audiobook.Id])); + var filePreview = Assert.Single(preview.FileRenames); + Assert.True(preview.HasChanges); + Assert.NotNull(preview.CurrentFolderSemantics); + Assert.NotNull(preview.NewFolderPath); + Assert.NotNull(filePreview.NewPath); + Assert.False(Directory.Exists(preview.NewFolderPath)); + + var result = Assert.Single(await renameService.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = audiobook.Id, + CurrentFolderPath = preview.CurrentFolderPath, + CurrentFolderSemantics = preview.CurrentFolderSemantics, + NewFolderPath = preview.NewFolderPath, + FileRenames = + [ + new FileRenameOperation + { + FileId = trackedFile.Id, + CurrentPath = filePreview.CurrentPath!, + NewPath = filePreview.NewPath! + } + ] + } + ])); + + Assert.True(result.Success, result.Error); + Assert.False(File.Exists(sourcePath)); + Assert.True(File.Exists(filePreview.NewPath)); + Assert.True(Directory.Exists(preview.NewFolderPath)); + + var factory = _provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.VerifiedFileRenameJournals + .AsNoTracking() + .SingleAsync(candidate => candidate.AudiobookId == audiobook.Id); + Assert.Equal(VerifiedFileRenameState.Completed, journal.State); + Assert.Equal(trackedFile.Id, journal.AudiobookFileId); + Assert.Equal(1, journal.ExpectedBatchMemberCount); + Assert.False(File.Exists(journal.StagingPath)); + + var saved = await db.Audiobooks + .AsNoTracking() + .Include(candidate => candidate.Files) + .SingleAsync(candidate => candidate.Id == audiobook.Id); + var savedFile = Assert.Single(saved.Files!); + Assert.Equal( + Path.GetFullPath(filePreview.NewPath), + Path.GetFullPath(savedFile.Path)); + Assert.Null(savedFile.PhysicalObjectIdentity); + } + + private static async Task CaptureOwnerCommittedRecoveryStateAsync( + string mountPath, + string databasePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); + if (File.Exists(databasePath)) + { + File.Delete(databasePath); + } + + await using var provider = BuildSqliteProvider(databasePath); + var factory = provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await db.Database.MigrateAsync(); + + var token = Guid.NewGuid().ToString("N"); + var rootPath = Path.Join(mountPath, "organise-remount-" + token); + var sourceDirectory = Path.Join(rootPath, "Old"); + var destinationDirectory = Path.Join(rootPath, "New"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(destinationDirectory); + var source = Path.Join(sourceDirectory, "old.m4b"); + var destination = Path.Join(destinationDirectory, "new.m4b"); + await File.WriteAllTextAsync(source, "native-organise-remount-audio"); + File.Copy(source, destination); + + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var root = new RootFolder + { + Name = "Native Organise Remount Weak CIFS", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Sensitive, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey( + "root", + rootPath, + semantics), + StorageContractRevision = 15 + }; + db.RootFolders.Add(root); + var audiobook = new Audiobook + { + Title = "Native Organise Remount", + BasePath = destinationDirectory + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var destinationIdentity = AudiobookFilePathIdentity.CreateValid( + destination, + semantics, + FileSystemCaseSensitivityMode.Sensitive, + rootPath); + var trackedFile = new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = destination, + Format = "m4b", + Size = new FileInfo(destination).Length + }; + trackedFile.ApplyPathIdentity(destination, destinationIdentity); + trackedFile.ClearPhysicalObjectIdentity(); + db.AudiobookFiles.Add(trackedFile); + await db.SaveChangesAsync(); + + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember( + trackedFile.Id, + source, + destination) + ]); + var bytes = await File.ReadAllBytesAsync(source); + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + AudiobookId = audiobook.Id, + AudiobookFileId = trackedFile.Id, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = source, + DestinationPath = destination, + StagingPath = Path.Join( + destinationDirectory, + ".listenarr-organize-" + operationId.ToString("N") + ".partial"), + RetirementPath = Path.Join( + Path.GetDirectoryName(source)!, + ".listenarr-organize-" + operationId.ToString("N") + ".source"), + SourceLength = bytes.LongLength, + SourceSha256 = Convert.ToHexString(SHA256.HashData(bytes)), + SourceRootFolderId = root.Id, + SourceStorageContractRevision = root.StorageContractRevision, + DestinationRootFolderId = root.Id, + DestinationStorageContractRevision = root.StorageContractRevision, + State = VerifiedFileRenameState.OwnerMetadataReconciled + }); + await db.SaveChangesAsync(); + + var health = await CreateNativeStorageHealthResolver().ResolveAsync(root); + Assert.Equal(RootFolderStorageState.Limited, health.State); + Assert.Equal(RootFolderStorageReason.IdentityUnsupported, health.Reason); + Assert.True(File.Exists(source)); + Assert.True(File.Exists(destination)); + } + + private static async Task VerifyOwnerCommittedRecoveryStateAsync( + string databasePath) + { + Assert.True( + File.Exists(databasePath), + "The capture phase did not persist verified-organise SQLite state."); + await using var provider = BuildSqliteProvider(databasePath); + var factory = provider.GetRequiredService>(); + var rootRepository = new EfRootFolderRepository( + factory, + NullLogger.Instance); + var identityResolver = new AudiobookFilePathIdentityResolver( + rootRepository, + new FileSystemSemanticsResolver()); + var recovery = new VerifiedFileRenameRecoveryService( + factory, + identityResolver, + TimeProvider.System, + NullLogger.Instance); + + await recovery.ReconcileAsync(); + + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.VerifiedFileRenameJournals + .AsNoTracking() + .SingleAsync(); + Assert.Equal( + VerifiedFileRenameState.CompletedSourceRetained, + journal.State); + Assert.True(File.Exists(journal.SourcePath)); + Assert.True(File.Exists(journal.DestinationPath)); + Assert.Contains("retained", journal.Error, StringComparison.OrdinalIgnoreCase); + + var trackedFile = await db.AudiobookFiles + .AsNoTracking() + .SingleAsync(candidate => candidate.Id == journal.AudiobookFileId); + Assert.Equal( + Path.GetFullPath(journal.DestinationPath), + Path.GetFullPath(trackedFile.Path)); + Assert.Null(trackedFile.PhysicalObjectIdentity); + var root = await db.RootFolders.AsNoTracking().SingleAsync(); + var health = await CreateNativeStorageHealthResolver().ResolveAsync(root); + Assert.Equal(RootFolderStorageState.Limited, health.State); + Assert.Equal(RootFolderStorageReason.IdentityUnsupported, health.Reason); + } + + private static async Task CaptureSourceQuarantinedRecoveryStateAsync( + string mountPath, + string databasePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!); + if (File.Exists(databasePath)) + { + File.Delete(databasePath); + } + + await using var provider = BuildSqliteProvider(databasePath); + var factory = provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + await db.Database.MigrateAsync(); + + var token = Guid.NewGuid().ToString("N"); + var rootPath = Path.Join(mountPath, "organise-quarantine-remount-" + token); + var sourceDirectory = Path.Join(rootPath, "Old"); + var destinationDirectory = Path.Join(rootPath, "New"); + Directory.CreateDirectory(sourceDirectory); + Directory.CreateDirectory(destinationDirectory); + var source = Path.Join(sourceDirectory, "old.m4b"); + var destination = Path.Join(destinationDirectory, "new.m4b"); + await File.WriteAllTextAsync(source, "native-organise-quarantine-audio"); + File.Copy(source, destination); + + var semantics = new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Sensitive); + var root = new RootFolder + { + Name = "Native Organise Quarantine Remount", + Path = rootPath, + CaseSensitivityMode = FileSystemCaseSensitivityMode.Sensitive, + ResolvedCaseSensitivity = FileSystemCaseSensitivity.Sensitive, + PathIdentityState = PathIdentityState.Valid, + PathIdentityKey = FileSystemPathIdentity.CreateKey("root", rootPath, semantics), + StorageContractRevision = 16 + }; + db.RootFolders.Add(root); + var audiobook = new Audiobook + { + Title = "Native Organise Quarantine Remount", + BasePath = destinationDirectory + }; + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var trackedFile = new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = destination, + Format = "m4b", + Size = new FileInfo(destination).Length + }; + trackedFile.ApplyPathIdentity( + destination, + AudiobookFilePathIdentity.CreateValid( + destination, + semantics, + FileSystemCaseSensitivityMode.Sensitive, + rootPath)); + trackedFile.ClearPhysicalObjectIdentity(); + db.AudiobookFiles.Add(trackedFile); + await db.SaveChangesAsync(); + + var operationId = Guid.NewGuid(); + var retirementPath = Path.Join( + sourceDirectory, + ".listenarr-organize-" + operationId.ToString("N") + ".source"); + var bytes = await File.ReadAllBytesAsync(source); + File.Move(source, retirementPath); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember(trackedFile.Id, source, destination) + ]); + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = Guid.NewGuid(), + AudiobookId = audiobook.Id, + AudiobookFileId = trackedFile.Id, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = source, + DestinationPath = destination, + StagingPath = Path.Join( + destinationDirectory, + ".listenarr-organize-" + operationId.ToString("N") + ".partial"), + RetirementPath = retirementPath, + SourceLength = bytes.LongLength, + SourceSha256 = Convert.ToHexString(SHA256.HashData(bytes)), + SourceRootFolderId = root.Id, + SourceStorageContractRevision = root.StorageContractRevision, + DestinationRootFolderId = root.Id, + DestinationStorageContractRevision = root.StorageContractRevision, + State = VerifiedFileRenameState.SourceQuarantined + }); + await db.SaveChangesAsync(); + + Assert.False(File.Exists(source)); + Assert.True(File.Exists(retirementPath)); + Assert.True(File.Exists(destination)); + } + + private static async Task VerifySourceQuarantinedRecoveryStateAsync( + string databasePath) + { + Assert.True(File.Exists(databasePath)); + await using var provider = BuildSqliteProvider(databasePath); + var factory = provider.GetRequiredService>(); + var rootRepository = new EfRootFolderRepository( + factory, + NullLogger.Instance); + var recovery = new VerifiedFileRenameRecoveryService( + factory, + new AudiobookFilePathIdentityResolver( + rootRepository, + new FileSystemSemanticsResolver()), + TimeProvider.System, + NullLogger.Instance); + + await Assert.ThrowsAsync(() => + recovery.ReconcileAsync()); + + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.VerifiedFileRenameJournals.AsNoTracking().SingleAsync(); + Assert.Equal(VerifiedFileRenameState.NeedsAttention, journal.State); + Assert.False(File.Exists(journal.SourcePath)); + Assert.True(File.Exists(journal.RetirementPath)); + Assert.Equal( + "native-organise-quarantine-audio", + await File.ReadAllTextAsync(journal.RetirementPath)); + Assert.True(File.Exists(journal.DestinationPath)); + Assert.True(await new FileRenameRecoveryProbe(factory) + .HasBlockingAsync(journal.AudiobookId)); + } + + private static ServiceProvider BuildSqliteProvider(string databasePath) + { + var services = new ServiceCollection(); + services.AddDbContextFactory(options => + options.UseSqlite( + $"Data Source={databasePath}", + sqlite => sqlite.MigrationsAssembly( + typeof(ListenArrDbContext).Assembly.GetName().Name))); + return services.BuildServiceProvider(); + } + + private static IRootFolderStorageHealthResolver CreateNativeStorageHealthResolver() => + new RootFolderStorageHealthResolver( + new DirectoryObjectIdentityResolver(), + new FileSystemSemanticsResolver()); +} diff --git a/tests/Features/Infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinatorTests.cs b/tests/Features/Infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinatorTests.cs new file mode 100644 index 000000000..e95ed7229 --- /dev/null +++ b/tests/Features/Infrastructure/FileSystem/VerifiedFileRenameTransactionCoordinatorTests.cs @@ -0,0 +1,278 @@ +using System.Security.Cryptography; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.FileSystem; + +[Trait("Name", "VerifiedFileRenameTransactionCoordinatorTests")] +[Trait("Category", "Infrastructure")] +public sealed class VerifiedFileRenameTransactionCoordinatorTests : BaseTests +{ + [Fact] + public void ResolveDestinationHierarchySegments_CaseInsensitiveUnixAlias_DoesNotTraverseAboveRoot() + { + var segments = VerifiedFileRenameTransactionCoordinator + .ResolveDestinationHierarchySegments( + "/mnt/Library", + "/mnt/library/Author/Book", + new FileSystemPathSemantics( + FileSystemPathSyntax.Unix, + FileSystemCaseSensitivity.Insensitive)); + + Assert.Equal(["Author", "Book"], segments); + Assert.DoesNotContain("..", segments); + } + + [Fact] + public async Task PrepareAsync_ContentOnlySource_PublishesTargetWithoutRetiringSource_AndRollsBackExactly() + { + var scenario = await CreateScenarioAsync(); + var coordinator = CreateCoordinator(); + var preparation = await coordinator.PrepareAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId, + scenario.BatchId, + scenario.Manifest, + scenario.Audiobook.Id, + scenario.AudiobookFile.Id, + scenario.SourceProof); + + Assert.True(preparation.Success, preparation.Error); + Assert.NotNull(preparation.Lease); + Assert.True(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + Assert.False(File.Exists(scenario.StagingPath)); + Assert.Equal( + VerifiedFileRenameState.TargetVerified, + (await GetJournalAsync(scenario.OperationId)).State); + + Assert.True(await preparation.Lease!.RollBackAsync()); + await preparation.Lease.DisposeAsync(); + Assert.True(File.Exists(scenario.Source)); + Assert.False(File.Exists(scenario.Destination)); + Assert.Equal( + VerifiedFileRenameState.RolledBack, + (await GetJournalAsync(scenario.OperationId)).State); + } + + [Fact] + public async Task CompleteSourceRetirementAsync_AfterOwnerCommit_DeletesOnlyLivePinnedSource() + { + var scenario = await CreateScenarioAsync(); + var coordinator = CreateCoordinator(); + var preparation = await coordinator.PrepareAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId, + scenario.BatchId, + scenario.Manifest, + scenario.Audiobook.Id, + scenario.AudiobookFile.Id, + scenario.SourceProof); + Assert.True(preparation.Success, preparation.Error); + + await SetJournalStateAsync( + scenario.OperationId, + VerifiedFileRenameState.OwnerMetadataReconciled); + + Assert.Equal( + VerifiedFileRenameRetirementOutcome.Completed, + await preparation.Lease!.CompleteSourceRetirementAsync()); + await preparation.Lease.DisposeAsync(); + Assert.False(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + Assert.Equal( + VerifiedFileRenameState.Completed, + (await GetJournalAsync(scenario.OperationId)).State); + } + + [LinuxFact] + public async Task CompleteSourceRetirementAsync_TargetReplacedAfterSourceQuarantine_RestoresExactSource() + { + var scenario = await CreateScenarioAsync(); + var coordinator = CreateCoordinator(); + var preparation = await coordinator.PrepareAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId, + scenario.BatchId, + scenario.Manifest, + scenario.Audiobook.Id, + scenario.AudiobookFile.Id, + scenario.SourceProof); + Assert.True(preparation.Success, preparation.Error); + await SetJournalStateAsync( + scenario.OperationId, + VerifiedFileRenameState.OwnerMetadataReconciled); + coordinator.AfterSourceQuarantinedForTest = () => + { + File.Delete(scenario.Destination); + File.WriteAllText(scenario.Destination, "foreign-target"); + }; + + Assert.Equal( + VerifiedFileRenameRetirementOutcome.NeedsAttention, + await preparation.Lease!.CompleteSourceRetirementAsync()); + await preparation.Lease.DisposeAsync(); + + Assert.True(File.Exists(scenario.Source)); + Assert.Equal("verified-organize-audio", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal("foreign-target", await File.ReadAllTextAsync(scenario.Destination)); + Assert.False(File.Exists(scenario.RetirementPath)); + var journal = await GetJournalAsync(scenario.OperationId); + Assert.Equal(VerifiedFileRenameState.NeedsAttention, journal.State); + Assert.Contains("requires repair", journal.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CompleteSourceRetirementAsync_StorageContractChanged_RetainsSourceTerminally() + { + var scenario = await CreateScenarioAsync(); + var coordinator = CreateCoordinator(); + var preparation = await coordinator.PrepareAsync( + scenario.Source, + scenario.Destination, + scenario.OperationId, + scenario.BatchId, + scenario.Manifest, + scenario.Audiobook.Id, + scenario.AudiobookFile.Id, + scenario.SourceProof); + Assert.True(preparation.Success, preparation.Error); + await SetJournalStateAsync( + scenario.OperationId, + VerifiedFileRenameState.OwnerMetadataReconciled); + + scenario.Root.StorageContractRevision++; + await _rootFolderRepository.UpdateAsync(scenario.Root); + + Assert.Equal( + VerifiedFileRenameRetirementOutcome.SourceRetained, + await preparation.Lease!.CompleteSourceRetirementAsync()); + Assert.True(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + var journal = await GetJournalAsync(scenario.OperationId); + Assert.Equal(VerifiedFileRenameState.CompletedSourceRetained, journal.State); + Assert.Contains("source was retained", journal.Error, StringComparison.OrdinalIgnoreCase); + await preparation.Lease.DisposeAsync(); + } + + private VerifiedFileRenameTransactionCoordinator CreateCoordinator() + { + var health = new Mock(MockBehavior.Strict); + health.Setup(resolver => resolver.ResolveAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new RootFolderStorageObservation( + RootFolderStorageState.Limited, + RootFolderStorageReason.IdentityUnsupported, + Message: null, + CanConfirmCurrentFolder: false, + CanChangePath: true, + CanMutateFilesystem: false, + ConfirmationToken: null, + CanPublishNewFiles: true, + CanRetireWithDurableIdentity: false, + CanRetireAfterVerifiedCopy: true)); + return new VerifiedFileRenameTransactionCoordinator( + _provider.GetRequiredService>(), + _rootFolderRepository, + health.Object, + TimeProvider.System, + NullLogger.Instance); + } + + private async Task CreateScenarioAsync() + { + var rootPath = FileService.GetTempDirectory("verified-organize-coordinator"); + var root = await AddAuthorizedRootAsync(rootPath); + root.StorageContractRevision = 11; + await _rootFolderRepository.UpdateAsync(root); + + var source = Path.Join(rootPath, "old.m4b"); + var destination = Path.Join(rootPath, "Author", "Book", "new.m4b"); + await File.WriteAllTextAsync(source, "verified-organize-audio"); + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Verified Organize", + BasePath = rootPath + }); + var audiobookFile = await _audiobookFileRepository.AddAsync(new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = source, + Size = new FileInfo(source).Length, + Format = "m4b" + }); + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember( + audiobookFile.Id, + source, + destination) + ]); + var bytes = await File.ReadAllBytesAsync(source); + var sha256 = Convert.ToHexString(SHA256.HashData(bytes)); + var sourceProof = new FilePublicationSourceProof( + "content-only:" + sha256, + bytes.LongLength, + sha256, + FilePublicationSourceAuthority.ContentOnly); + var stagingPath = Path.Join( + Path.GetDirectoryName(destination)!, + ".listenarr-organize-" + operationId.ToString("N") + ".partial"); + var retirementPath = Path.Join( + Path.GetDirectoryName(source)!, + ".listenarr-organize-" + operationId.ToString("N") + ".source"); + return new Scenario( + root, + audiobook, + audiobookFile, + source, + destination, + stagingPath, + retirementPath, + operationId, + batchId, + manifest, + sourceProof); + } + + private async Task GetJournalAsync(Guid operationId) + { + var factory = _provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + return await db.VerifiedFileRenameJournals + .AsNoTracking() + .SingleAsync(journal => journal.OperationId == operationId); + } + + private async Task SetJournalStateAsync( + Guid operationId, + VerifiedFileRenameState state) + { + var factory = _provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + var journal = await db.VerifiedFileRenameJournals + .SingleAsync(candidate => candidate.OperationId == operationId); + journal.State = state; + await db.SaveChangesAsync(); + } + + private sealed record Scenario( + RootFolder Root, + Audiobook Audiobook, + AudiobookFile AudiobookFile, + string Source, + string Destination, + string StagingPath, + string RetirementPath, + Guid OperationId, + Guid BatchId, + VerifiedFileRenameBatchManifest Manifest, + FilePublicationSourceProof SourceProof); +} diff --git a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs index 6e51b6923..3b7591cf1 100644 --- a/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs +++ b/tests/Features/Infrastructure/Migrations/MigrationMetadataTests.cs @@ -32,6 +32,13 @@ public void AddCompatibilityBatchManifestMigration_IsDiscoverableByEf() "20260830025709_AddCompatibilityBatchManifest"); } + [Fact] + public void AddVerifiedFileRenameJournalMigration_IsDiscoverableByEf() + { + AssertMigrationId( + "20260901142347_AddVerifiedFileRenameJournal"); + } + [Fact] public void AddImportBlacklistExtensionsMigration_IsDiscoverableByEf() { diff --git a/tests/Features/Infrastructure/Persistence/FileRenameCommitStoreTests.cs b/tests/Features/Infrastructure/Persistence/FileRenameCommitStoreTests.cs index 31b501dc7..033a39b34 100644 --- a/tests/Features/Infrastructure/Persistence/FileRenameCommitStoreTests.cs +++ b/tests/Features/Infrastructure/Persistence/FileRenameCommitStoreTests.cs @@ -252,6 +252,259 @@ await Assert.ThrowsAsync(() => .SingleAsync(candidate => candidate.Id == audiobook.Id)).BasePath); } + [Fact] + public async Task CommitOwnerMetadataAsync_VerifiedBatch_CommitsOwnerAndJournalTogether() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ListenArrDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var rootPath = FileService.GetTempDirectory("verified-rename-commit"); + var sourcePath = Path.Join(rootPath, "old.m4b"); + var destinationPath = Path.Join(rootPath, "new.m4b"); + await File.WriteAllTextAsync(sourcePath, "verified-owner-commit"); + File.Copy(sourcePath, destinationPath); + var root = new RootFolder + { + Name = "Verified Commit Root", + Path = rootPath, + StorageContractRevision = 7 + }; + var audiobook = new Audiobook + { + Title = "Verified Commit", + BasePath = rootPath, + FilePath = sourcePath + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember(0, sourcePath, destinationPath) + ]); + var bytes = await File.ReadAllBytesAsync(sourcePath); + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + AudiobookId = audiobook.Id, + AudiobookFileId = 0, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = sourcePath, + DestinationPath = destinationPath, + StagingPath = Path.Join(rootPath, ".listenarr-organize-test.partial"), + RetirementPath = Path.Join( + rootPath, + ".listenarr-organize-" + operationId.ToString("N") + ".source"), + SourceLength = bytes.LongLength, + SourceSha256 = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(bytes)), + SourceRootFolderId = root.Id, + SourceStorageContractRevision = root.StorageContractRevision, + DestinationRootFolderId = root.Id, + DestinationStorageContractRevision = root.StorageContractRevision, + State = VerifiedFileRenameState.TargetVerified + }); + await db.SaveChangesAsync(); + + audiobook.FilePath = destinationPath; + var store = new FileRenameCommitStore(db, TimeProvider.System); + await store.CommitOwnerMetadataAsync(audiobook.Id, [operationId]); + + await using var verification = new ListenArrDbContext(options); + Assert.Equal( + destinationPath, + (await verification.Audiobooks.AsNoTracking() + .SingleAsync(candidate => candidate.Id == audiobook.Id)).FilePath); + Assert.Equal( + VerifiedFileRenameState.OwnerMetadataReconciled, + (await verification.VerifiedFileRenameJournals.AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == operationId)).State); + Assert.True(File.Exists(sourcePath)); + Assert.True(File.Exists(destinationPath)); + } + + [Fact] + public async Task CommitOwnerMetadataAsync_IncompleteVerifiedBatch_RollsBackOwnerChange() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ListenArrDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var rootPath = FileService.GetTempDirectory("verified-rename-incomplete-commit"); + var sourcePath = Path.Join(rootPath, "old.m4b"); + var destinationPath = Path.Join(rootPath, "new.m4b"); + var missingSource = Path.Join(rootPath, "part2-old.m4b"); + var missingDestination = Path.Join(rootPath, "part2-new.m4b"); + await File.WriteAllTextAsync(sourcePath, "verified-owner-incomplete"); + File.Copy(sourcePath, destinationPath); + var root = new RootFolder + { + Name = "Verified Incomplete Root", + Path = rootPath, + StorageContractRevision = 4 + }; + var audiobook = new Audiobook + { + Title = "Verified Incomplete Commit", + BasePath = rootPath, + FilePath = sourcePath + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember(0, sourcePath, destinationPath), + new VerifiedFileRenameBatchMember(2, missingSource, missingDestination) + ]); + var bytes = await File.ReadAllBytesAsync(sourcePath); + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + AudiobookId = audiobook.Id, + AudiobookFileId = 0, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = sourcePath, + DestinationPath = destinationPath, + StagingPath = Path.Join(rootPath, ".listenarr-organize-incomplete.partial"), + RetirementPath = Path.Join( + rootPath, + ".listenarr-organize-" + operationId.ToString("N") + ".source"), + SourceLength = bytes.LongLength, + SourceSha256 = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(bytes)), + SourceRootFolderId = root.Id, + SourceStorageContractRevision = root.StorageContractRevision, + DestinationRootFolderId = root.Id, + DestinationStorageContractRevision = root.StorageContractRevision, + State = VerifiedFileRenameState.TargetVerified + }); + await db.SaveChangesAsync(); + + audiobook.FilePath = destinationPath; + var store = new FileRenameCommitStore(db, TimeProvider.System); + await Assert.ThrowsAsync(() => + store.CommitOwnerMetadataAsync(audiobook.Id, [operationId])); + + await using var verification = new ListenArrDbContext(options); + Assert.Equal( + sourcePath, + (await verification.Audiobooks.AsNoTracking() + .SingleAsync(candidate => candidate.Id == audiobook.Id)).FilePath); + Assert.Equal( + VerifiedFileRenameState.TargetVerified, + (await verification.VerifiedFileRenameJournals.AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == operationId)).State); + } + + [Fact] + public async Task CommitOwnerMetadataAsync_VerifiedRollbackFromSeparateContext_RefreshesTrackedJournalState() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var db = new ListenArrDbContext(options); + await db.Database.EnsureCreatedAsync(); + + var rootPath = FileService.GetTempDirectory("verified-rename-refresh-rollback"); + var sourcePath = Path.Join(rootPath, "old.m4b"); + var destinationPath = Path.Join(rootPath, "new.m4b"); + await File.WriteAllTextAsync(sourcePath, "verified-refresh-rollback"); + File.Copy(sourcePath, destinationPath); + var root = new RootFolder + { + Name = "Verified Refresh Root", + Path = rootPath, + StorageContractRevision = 9 + }; + var audiobook = new Audiobook + { + Title = "Verified Refresh", + BasePath = rootPath, + FilePath = sourcePath + }; + db.RootFolders.Add(root); + db.Audiobooks.Add(audiobook); + await db.SaveChangesAsync(); + + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember(0, sourcePath, destinationPath) + ]); + var bytes = await File.ReadAllBytesAsync(sourcePath); + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + AudiobookId = audiobook.Id, + AudiobookFileId = 0, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = sourcePath, + DestinationPath = destinationPath, + StagingPath = Path.Join(rootPath, ".listenarr-organize-refresh.partial"), + RetirementPath = Path.Join( + rootPath, + ".listenarr-organize-" + operationId.ToString("N") + ".source"), + SourceLength = bytes.LongLength, + SourceSha256 = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(bytes)), + SourceRootFolderId = root.Id, + SourceStorageContractRevision = root.StorageContractRevision, + DestinationRootFolderId = root.Id, + DestinationStorageContractRevision = root.StorageContractRevision, + State = VerifiedFileRenameState.TargetVerified + }); + await db.SaveChangesAsync(); + + // Simulate the live verified lease rolling the filesystem/journal back + // through its independent DbContext after this scoped commit context has + // already tracked the pre-rollback journal state. + await using (var rollbackDb = new ListenArrDbContext(options)) + { + var rolledBack = await rollbackDb.VerifiedFileRenameJournals + .SingleAsync(candidate => candidate.OperationId == operationId); + rolledBack.State = VerifiedFileRenameState.RolledBack; + await rollbackDb.SaveChangesAsync(); + } + + var store = new FileRenameCommitStore(db, TimeProvider.System); + await store.CommitOwnerMetadataAsync(audiobook.Id, [operationId]); + + await using var verification = new ListenArrDbContext(options); + Assert.Equal( + VerifiedFileRenameState.RolledBack, + (await verification.VerifiedFileRenameJournals.AsNoTracking() + .SingleAsync(candidate => candidate.OperationId == operationId)).State); + Assert.Equal( + sourcePath, + (await verification.Audiobooks.AsNoTracking() + .SingleAsync(candidate => candidate.Id == audiobook.Id)).FilePath); + } + private static string GetFileIdentity(string path) { using var lease = PinnedAudiobookFileRegistrationLease.Open(path); diff --git a/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs b/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs index 98c8d672d..e31e29494 100644 --- a/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs +++ b/tests/Features/Infrastructure/Persistence/LibraryFilesystemStartupReconciliationServiceTests.cs @@ -65,6 +65,8 @@ public async Task StartAsync_ReturnsWhileReconciliationIsBlocked_ThenCompletesIn order.Add("rename"); return Task.CompletedTask; }); + var verifiedRename = new StubVerifiedRenameRecoveryService( + () => order.Add("verified-rename")); var compatibility = new StubCompatibilityRecoveryService( () => order.Add("compatibility")); var files = new Mock(MockBehavior.Strict); @@ -83,7 +85,8 @@ public async Task StartAsync_ReturnsWhileReconciliationIsBlocked_ThenCompletesIn deletion.Object, registration.Object, rename.Object, - compatibility); + compatibility, + verifiedRename); var readiness = new LibraryFilesystemReadiness(); var service = new LibraryFilesystemStartupReconciliationService( provider.GetRequiredService(), @@ -111,6 +114,7 @@ public async Task StartAsync_ReturnsWhileReconciliationIsBlocked_ThenCompletesIn "deletion", "registration-recover", "compatibility", + "verified-rename", "rename", "files" ], @@ -219,7 +223,8 @@ private static ServiceProvider BuildProvider( IAudiobookDeletionIntentReconciler? deletion = null, IFileRegistrationRecoveryService? registration = null, IFileRenameRecoveryReconciler? rename = null, - ICompatibilityFilePublicationRecoveryService? compatibility = null) => + ICompatibilityFilePublicationRecoveryService? compatibility = null, + IVerifiedFileRenameRecoveryService? verifiedRename = null) => new ServiceCollection() .AddScoped(_ => root) .AddScoped(_ => relocation) @@ -233,6 +238,8 @@ private static ServiceProvider BuildProvider( service.ReconcileAsync(It.IsAny()) == Task.CompletedTask)) .AddScoped(_ => compatibility ?? new StubCompatibilityRecoveryService()) + .AddScoped(_ => verifiedRename + ?? new StubVerifiedRenameRecoveryService()) .AddScoped(_ => files) .BuildServiceProvider(new ServiceProviderOptions { @@ -240,6 +247,17 @@ private static ServiceProvider BuildProvider( ValidateOnBuild = true }); + private sealed class StubVerifiedRenameRecoveryService(Action? onRun = null) + : IVerifiedFileRenameRecoveryService + { + public Task ReconcileAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + onRun?.Invoke(); + return Task.CompletedTask; + } + } + private sealed class StubCompatibilityRecoveryService(Action? onRun = null) : ICompatibilityFilePublicationRecoveryService { diff --git a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs index 3dcf89a96..8387dc40f 100644 --- a/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs +++ b/tests/Features/Infrastructure/Persistence/SqliteMigrationSchemaTests.cs @@ -44,6 +44,8 @@ public class SqliteMigrationSchemaTests : BaseTests "20260825021432_AddWeakStorageVerifiedCleanup"; private const string CompatibilityBatchManifestMigrationId = "20260830025709_AddCompatibilityBatchManifest"; + private const string VerifiedFileRenameJournalMigrationId = + "20260901142347_AddVerifiedFileRenameJournal"; private static (SqliteConnection Connection, ListenArrDbContext Context) CreateMigratedSqliteContext() @@ -183,6 +185,22 @@ public async Task WeakStorageMigration_AddsFailClosedMovePolicySnapshot() connection, "CompatibilityFilePublicationJournals", "ExpectedBatchSourceManifestSha256")); + Assert.True(await ColumnExistsAsync( + connection, + "VerifiedFileRenameJournals", + "ExpectedBatchManifestSha256")); + Assert.True(await ColumnExistsAsync( + connection, + "VerifiedFileRenameJournals", + "SourceStorageContractRevision")); + Assert.True(await ColumnExistsAsync( + connection, + "VerifiedFileRenameJournals", + "DestinationStorageContractRevision")); + Assert.True(await ColumnExistsAsync( + connection, + "VerifiedFileRenameJournals", + "RetirementPath")); Assert.Equal( "'RetainSource'", await ColumnDefaultAsync(connection, "MoveJobs", "SourceCleanupMode")); @@ -210,7 +228,8 @@ public async Task MigrationHistory_ContainsOnlyRetainedRepairsAndConsolidatedPrM FileMutationParentGenerationProofsMigrationId, CompatibilityFilePublicationMigrationId, WeakStorageVerifiedCleanupMigrationId, - CompatibilityBatchManifestMigrationId + CompatibilityBatchManifestMigrationId, + VerifiedFileRenameJournalMigrationId ], postCanary); Assert.Contains("20251124102000_AddMoveJobSourcePath", applied); diff --git a/tests/Features/Infrastructure/Persistence/VerifiedFileRenameRecoveryServiceTests.cs b/tests/Features/Infrastructure/Persistence/VerifiedFileRenameRecoveryServiceTests.cs new file mode 100644 index 000000000..961849414 --- /dev/null +++ b/tests/Features/Infrastructure/Persistence/VerifiedFileRenameRecoveryServiceTests.cs @@ -0,0 +1,350 @@ +using System.Security.Cryptography; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.Persistence; + +[Trait("Name", "VerifiedFileRenameRecoveryServiceTests")] +[Trait("Category", "Infrastructure")] +public sealed class VerifiedFileRenameRecoveryServiceTests : BaseTests +{ + [Fact] + public async Task FileRenameRecoveryProbe_ActiveVerifiedJournal_BlocksUntilTerminalState() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.TargetVerified, + ownerAtDestination: false, + createDestination: true); + var factory = _provider.GetRequiredService< + IDbContextFactory>(); + var probe = new FileRenameRecoveryProbe(factory); + + Assert.True(await probe.HasBlockingAsync(scenario.AudiobookId)); + + await using (var db = await factory.CreateDbContextAsync()) + { + var journal = await db.VerifiedFileRenameJournals + .SingleAsync(candidate => candidate.OperationId == scenario.OperationId); + journal.State = VerifiedFileRenameState.CompletedSourceRetained; + await db.SaveChangesAsync(); + } + + Assert.False(await probe.HasBlockingAsync(scenario.AudiobookId)); + } + + [Fact] + public async Task ReconcileAsync_OwnerCommittedWithOldSourcePresent_CompletesSourceRetainedWithoutDeleting() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.OwnerMetadataReconciled, + ownerAtDestination: true, + createDestination: true); + var service = CreateService(); + + await service.ReconcileAsync(); + + Assert.True(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + var journal = await GetJournalAsync(scenario.OperationId); + Assert.Equal(VerifiedFileRenameState.CompletedSourceRetained, journal.State); + Assert.Contains("retained", journal.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReconcileAsync_OwnerCommittedAfterSourceWasDeleted_CompletesWithoutFurtherMutation() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.OwnerMetadataReconciled, + ownerAtDestination: true, + createDestination: true); + File.Delete(scenario.Source); + var service = CreateService(); + + await service.ReconcileAsync(); + + Assert.False(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + Assert.Equal( + VerifiedFileRenameState.Completed, + (await GetJournalAsync(scenario.OperationId)).State); + } + + [Fact] + public async Task ReconcileAsync_TargetVerifiedBeforeOwnerCommit_PreservesBothPathsAndRequiresAttention() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.TargetVerified, + ownerAtDestination: false, + createDestination: true); + var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.ReconcileAsync()); + + Assert.True(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.Destination)); + var journal = await GetJournalAsync(scenario.OperationId); + Assert.Equal(VerifiedFileRenameState.NeedsAttention, journal.State); + Assert.Contains("will not delete", journal.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReconcileAsync_PlannedBeforePublication_RollsBackByDatabaseStateOnly() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.Planned, + ownerAtDestination: false, + createDestination: false); + var service = CreateService(); + + await service.ReconcileAsync(); + + Assert.True(File.Exists(scenario.Source)); + Assert.False(File.Exists(scenario.Destination)); + Assert.False(File.Exists(scenario.StagingPath)); + Assert.Equal( + VerifiedFileRenameState.RolledBack, + (await GetJournalAsync(scenario.OperationId)).State); + } + + [Fact] + public async Task ReconcileAsync_SourceQuarantined_PreservesRetirementArtifactAndRequiresAttention() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.SourceQuarantined, + ownerAtDestination: true, + createDestination: true); + File.Move(scenario.Source, scenario.RetirementPath); + var service = CreateService(); + + await Assert.ThrowsAsync(() => + service.ReconcileAsync()); + + Assert.False(File.Exists(scenario.Source)); + Assert.True(File.Exists(scenario.RetirementPath)); + Assert.Equal( + "verified-recovery-audio", + await File.ReadAllTextAsync(scenario.RetirementPath)); + var journal = await GetJournalAsync(scenario.OperationId); + Assert.Equal(VerifiedFileRenameState.NeedsAttention, journal.State); + Assert.Contains("retirement", journal.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ReconcileAsync_SourceDeletedStateWithReappearedPath_DoesNotDeleteReplacement() + { + var scenario = await CreateScenarioAsync( + VerifiedFileRenameState.SourceDeleted, + ownerAtDestination: true, + createDestination: true); + await File.WriteAllTextAsync(scenario.Source, "reappeared-source"); + var service = CreateService(); + + await service.ReconcileAsync(); + + Assert.True(File.Exists(scenario.Source)); + Assert.Equal("reappeared-source", await File.ReadAllTextAsync(scenario.Source)); + Assert.Equal( + VerifiedFileRenameState.CompletedSourceRetained, + (await GetJournalAsync(scenario.OperationId)).State); + } + + [Fact] + public async Task ReconcileAsync_LegacyOwner_UsesResolvedOwnershipIdentityInsteadOfHostPathComparison() + { + var rootPath = FileService.GetTempDirectory("verified-organize-legacy-recovery"); + var source = Path.Join(rootPath, "old.m4b"); + var destination = Path.Join(rootPath, "new.m4b"); + var logicalDestinationAlias = Path.Join(rootPath, "NEW-ALIAS.m4b"); + await File.WriteAllTextAsync(source, "verified-legacy-recovery-audio"); + File.Copy(source, destination); + + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Verified Legacy Recovery", + BasePath = rootPath, + FilePath = logicalDestinationAlias + }); + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember(0, source, destination) + ]); + var bytes = await File.ReadAllBytesAsync(source); + var factory = _provider.GetRequiredService>(); + await using (var db = await factory.CreateDbContextAsync()) + { + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + AudiobookId = audiobook.Id, + AudiobookFileId = 0, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = source, + DestinationPath = destination, + StagingPath = Path.Join(rootPath, ".listenarr-organize-legacy.partial"), + RetirementPath = Path.Join( + rootPath, + ".listenarr-organize-" + operationId.ToString("N") + ".source"), + SourceLength = bytes.LongLength, + SourceSha256 = Convert.ToHexString(SHA256.HashData(bytes)), + SourceRootFolderId = 1, + SourceStorageContractRevision = 1, + DestinationRootFolderId = 1, + DestinationStorageContractRevision = 1, + State = VerifiedFileRenameState.OwnerMetadataReconciled + }); + await db.SaveChangesAsync(); + } + + var ownershipIdentity = new AudiobookFilePathIdentity( + Path.GetFullPath(destination), + FileSystemPathSemantics.CurrentHostDefault.Syntax, + FileSystemPathSemantics.CurrentHostDefault.CaseSensitivity, + FileSystemCaseSensitivityMode.Auto, + rootPath, + "legacy-lookup", + "legacy-shared-owner", + AudiobookFilePathIdentity.CurrentVersion, + PathIdentityState.Valid); + var identityResolver = new Mock(MockBehavior.Strict); + identityResolver.Setup(resolver => resolver.ResolveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.FromResult(ownershipIdentity)); + var service = new VerifiedFileRenameRecoveryService( + factory, + identityResolver.Object, + TimeProvider.System, + NullLogger.Instance); + + await service.ReconcileAsync(); + + var journal = await GetJournalAsync(operationId); + Assert.Equal(VerifiedFileRenameState.CompletedSourceRetained, journal.State); + Assert.True(File.Exists(source)); + Assert.True(File.Exists(destination)); + identityResolver.Verify(resolver => resolver.ResolveAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Exactly(2)); + } + + private VerifiedFileRenameRecoveryService CreateService() => + new( + _provider.GetRequiredService>(), + _provider.GetRequiredService(), + TimeProvider.System, + NullLogger.Instance); + + private async Task CreateScenarioAsync( + VerifiedFileRenameState state, + bool ownerAtDestination, + bool createDestination) + { + var rootPath = FileService.GetTempDirectory("verified-organize-recovery"); + var root = await AddAuthorizedRootAsync(rootPath); + root.StorageContractRevision = 8; + await _rootFolderRepository.UpdateAsync(root); + + var source = Path.Join(rootPath, "old.m4b"); + var destination = Path.Join(rootPath, "new.m4b"); + await File.WriteAllTextAsync(source, "verified-recovery-audio"); + if (createDestination) + { + File.Copy(source, destination); + } + + var audiobook = await _audiobookRepository.AddAsync(new Audiobook + { + Title = "Verified Recovery", + BasePath = rootPath + }); + var identityResolver = _provider + .GetRequiredService(); + var ownerPath = ownerAtDestination ? destination : source; + var ownerIdentity = await identityResolver.ResolveAsync( + audiobook, + ownerPath); + Assert.Equal(PathIdentityState.Valid, ownerIdentity.State); + var file = new AudiobookFile + { + AudiobookId = audiobook.Id, + Path = ownerPath, + Size = new FileInfo(source).Length, + Format = "m4b" + }; + file.ApplyPathIdentity(ownerPath, ownerIdentity); + file.ClearPhysicalObjectIdentity(); + file = await _audiobookFileRepository.AddAsync(file); + + var operationId = Guid.NewGuid(); + var batchId = Guid.NewGuid(); + var stagingPath = Path.Join( + rootPath, + ".listenarr-organize-" + operationId.ToString("N") + ".partial"); + var retirementPath = Path.Join( + rootPath, + ".listenarr-organize-" + operationId.ToString("N") + ".source"); + var manifest = VerifiedFileRenameBatchManifest.Create( + [ + new VerifiedFileRenameBatchMember(file.Id, source, destination) + ]); + var originalBytes = System.Text.Encoding.UTF8.GetBytes( + "verified-recovery-audio"); + var factory = _provider + .GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + db.VerifiedFileRenameJournals.Add(new VerifiedFileRenameJournal + { + OperationId = operationId, + BatchId = batchId, + AudiobookId = audiobook.Id, + AudiobookFileId = file.Id, + ExpectedBatchMemberCount = manifest.ExpectedMemberCount, + ExpectedBatchManifestSha256 = manifest.ManifestSha256, + SourcePath = source, + DestinationPath = destination, + StagingPath = stagingPath, + RetirementPath = retirementPath, + SourceLength = originalBytes.LongLength, + SourceSha256 = Convert.ToHexString(SHA256.HashData(originalBytes)), + SourceRootFolderId = root.Id, + SourceStorageContractRevision = root.StorageContractRevision, + DestinationRootFolderId = root.Id, + DestinationStorageContractRevision = root.StorageContractRevision, + State = state + }); + await db.SaveChangesAsync(); + + return new Scenario( + operationId, + audiobook.Id, + source, + destination, + stagingPath, + retirementPath); + } + + private async Task GetJournalAsync(Guid operationId) + { + var factory = _provider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + return await db.VerifiedFileRenameJournals + .AsNoTracking() + .SingleAsync(journal => journal.OperationId == operationId); + } + + private sealed record Scenario( + Guid OperationId, + int AudiobookId, + string Source, + string Destination, + string StagingPath, + string RetirementPath); +} From 047681f0c31c0d76401ab577aa5e181166e7657a Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 17 Sep 2026 11:36:28 -0400 Subject: [PATCH 5/6] fix: harden weak-storage recovery for issue 904 --- .../FileRegistrationRecoveryController.cs | 53 +++ .../Features/Library/LibraryDeleteWorkflow.cs | 7 +- .../Library/RootFoldersController.Mapping.cs | 22 ++ .../Features/Library/RootFoldersController.cs | 4 + .../Contracts/IAudiobookFileService.cs | 10 + .../Contracts/IFileRegistrationRecovery.cs | 43 ++- .../IRootFolderStorageConfirmationService.cs | 7 + .../Contracts/PhysicalObjectIdentitySafety.cs | 57 ++++ .../Repositories/IAudiobookFileRepository.cs | 51 +++ .../AudiobookFileService.MetadataRefresh.cs | 110 ++++++ ...AudiobookFileService.PhysicalGeneration.cs | 44 +-- .../RootFolders/RootFolderService.cs | 15 +- .../Downloads/Contracts/IFileMover.cs | 16 + .../Downloads/FileMutationJournal.cs | 38 ++- ...leMover.MarkerlessRegistration.Rollback.cs | 235 +++++++++++++ .../FileMover.MarkerlessRegistration.cs | 28 +- .../FileMover.MarkerlessRegistrationMove.cs | 11 +- .../FileMutationJournalStore.AdvanceState.cs | 41 ++- .../PinnedAudiobookFileRegistrationLease.cs | 32 ++ ...rectoryCreation.LinuxIdentityCandidates.cs | 37 +- .../RootFolderStorageConfirmationService.cs | 51 ++- ...FilesystemDeleteService.GenerationProof.cs | 5 + .../AudiobookFilesystemDeleteService.cs | 6 +- ...olderRelocationService.ExternalRecovery.cs | 57 ++-- ...tFolderRelocationService.MetadataRepair.cs | 7 +- .../Metadata/Jobs/MetadataRescanService.cs | 13 +- .../FileRegistrationRecoveryProbe.cs | 65 +++- ...FileRegistrationRecoveryService.Orphans.cs | 317 ++++++++++++++++++ ...ileRegistrationRecoveryService.Protocol.cs | 36 +- ...ileRegistrationRecoveryService.Receipts.cs | 6 +- ...RegistrationRecoveryService.RepairState.cs | 5 +- .../FileRegistrationRecoveryService.cs | 272 ++++++++------- ...bookFileRepository.BasePathRegistration.cs | 76 +++++ ...AudiobookFileRepository.MetadataRefresh.cs | 92 +++++ ...iobookFileRepository.PhysicalGeneration.cs | 101 ++++++ ...LibraryController_DeleteFilesystemTests.cs | 100 ++++++ ...udiobookFileServiceMetadataRefreshTests.cs | 307 +++++++++++++++++ .../RootFolders/RootFolderServiceTests.cs | 19 +- .../DirectoryObjectIdentityResolverTests.cs | 25 ++ .../FileMutationJournalStoreTests.cs | 103 ++++++ ...nnedAudiobookFileRegistrationLeaseTests.cs | 22 ++ ...otFolderStorageConfirmationServiceTests.cs | 19 +- .../Jobs/MetadataRescanWeakStorageTests.cs | 80 +++++ ...FileRepositoryBasePathRegistrationTests.cs | 80 +++++ .../FileRegistrationRecoveryProbeTests.cs | 55 ++- .../FileRegistrationRecoveryServiceTests.cs | 17 +- 46 files changed, 2501 insertions(+), 296 deletions(-) create mode 100644 listenarr.api/Features/Library/FileRegistrationRecoveryController.cs create mode 100644 listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs create mode 100644 listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs create mode 100644 listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs create mode 100644 listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs create mode 100644 listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.MetadataRefresh.cs create mode 100644 tests/Features/Application/Audiobooks/Files/AudiobookFileServiceMetadataRefreshTests.cs create mode 100644 tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanWeakStorageTests.cs diff --git a/listenarr.api/Features/Library/FileRegistrationRecoveryController.cs b/listenarr.api/Features/Library/FileRegistrationRecoveryController.cs new file mode 100644 index 000000000..201a88239 --- /dev/null +++ b/listenarr.api/Features/Library/FileRegistrationRecoveryController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Listenarr.Api.Features.Library; + +[ApiController] +[Route("api/v{version:apiVersion}/file-registration-recovery")] +[Tags("Library")] +public sealed class FileRegistrationRecoveryController( + IFileRegistrationRecoveryService recoveryService, + IFilesystemMutationCoordinator mutationCoordinator) : ControllerBase +{ + [HttpPost("{operationId:guid}/retry")] + public async Task Retry( + Guid operationId, + CancellationToken cancellationToken) + { + try + { + var status = await mutationCoordinator.ExecuteExclusiveAsync( + token => recoveryService.RetryAsync(operationId, token), + cancellationToken); + return status.CanRetry + || status.Disposition + == FileRegistrationRecoveryDisposition.RequiresOperatorAttention + ? Conflict(status) + : Ok(status); + } + catch (KeyNotFoundException) + { + return NotFound(new + { + code = "registration_recovery_not_found", + message = "File-registration recovery operation not found." + }); + } + catch (ArgumentException) + { + return BadRequest(new + { + code = "registration_recovery_invalid", + message = "The file-registration recovery operation is invalid." + }); + } + catch (InvalidOperationException) + { + return Conflict(new + { + code = "registration_recovery_not_retryable", + message = "The requested operation is not eligible for file-registration recovery." + }); + } + } +} diff --git a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs index ec34bc4b6..261a7ea6a 100644 --- a/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs +++ b/listenarr.api/Features/Library/LibraryDeleteWorkflow.cs @@ -339,8 +339,11 @@ await _deletionIntentStore.MarkCompletedAsync( private static bool HasUnverifiedTrackedDeleteSource(Audiobook audiobook) => audiobook.Files?.Any(file => !string.IsNullOrWhiteSpace(file.Path) - && file.PathIdentityState == PathIdentityState.Valid - && string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity)) == true; + && (PhysicalObjectIdentitySafety.IsKnownWeak( + file.PhysicalObjectIdentity) + || (file.PathIdentityState == PathIdentityState.Valid + && string.IsNullOrWhiteSpace( + file.PhysicalObjectIdentity)))) == true; private async Task GetManagedStorageMutationBlockAsync( Audiobook audiobook, diff --git a/listenarr.api/Features/Library/RootFoldersController.Mapping.cs b/listenarr.api/Features/Library/RootFoldersController.Mapping.cs index be590193c..b9912de64 100644 --- a/listenarr.api/Features/Library/RootFoldersController.Mapping.cs +++ b/listenarr.api/Features/Library/RootFoldersController.Mapping.cs @@ -1,9 +1,31 @@ using Listenarr.Domain.Common; +using Microsoft.AspNetCore.Mvc; namespace Listenarr.Api.Features.Library; public partial class RootFoldersController { + private ConflictObjectResult RegistrationRecoveryConflict( + FileRegistrationRecoveryBlocker blocker) + { + var canRetry = blocker.Recoverability is + FileRegistrationRecoveryDisposition.AutomaticRecovery + or FileRegistrationRecoveryDisposition.WaitingForOwnerRetry; + return Conflict(new + { + message = blocker.PublicReason, + code = "registration_recovery_pending", + operationId = blocker.OperationId, + audiobookId = blocker.AudiobookId, + journalState = blocker.JournalState.ToString(), + ownerKind = blocker.OwnerKind, + recoverability = blocker.Recoverability.ToString(), + canRetry, + canAbandon = false, + retryOperationId = canRetry ? blocker.OperationId : (Guid?)null + }); + } + private async Task MapAsync(RootFolder root) { RootFolderPathChangeResult? active = null; diff --git a/listenarr.api/Features/Library/RootFoldersController.cs b/listenarr.api/Features/Library/RootFoldersController.cs index 0565e924b..f1342996d 100644 --- a/listenarr.api/Features/Library/RootFoldersController.cs +++ b/listenarr.api/Features/Library/RootFoldersController.cs @@ -346,6 +346,10 @@ public async Task ConfirmCurrentFolder( code = "root_folder_identity_unsupported" }); } + catch (RootFolderRecoveryBlockedException exception) + { + return RegistrationRecoveryConflict(exception.Blocker); + } catch (InvalidOperationException) { return Conflict(new diff --git a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs index 8d48dd1ae..00053397c 100644 --- a/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs +++ b/listenarr.application/Audiobooks/Contracts/IAudiobookFileService.cs @@ -100,6 +100,16 @@ Task RefreshPhysicalGenerationAsync( string? source = "scan", CancellationToken cancellationToken = default); + /// + /// Refresh metadata for an already-owned path using a live read lease. + /// Does not enroll, replace, or clear path or physical-generation identity. + /// + Task RefreshMetadataAsync( + Audiobook audiobook, + int fileId, + IAudiobookFileRegistrationLease registrationLease, + CancellationToken cancellationToken = default); + Task RollbackPhysicalGenerationClaimAsync( Audiobook audiobook, int fileId, diff --git a/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs b/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs index 6e82977a7..0cf2d29c3 100644 --- a/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs +++ b/listenarr.application/Audiobooks/Contracts/IFileRegistrationRecovery.cs @@ -3,8 +3,29 @@ namespace Listenarr.Application.Audiobooks.Contracts; /// -/// Reports whether a committed file-registration move still owns source-cleanup state -/// for an audiobook. +/// Describes how a nonterminal file-registration publication can progress. +/// +public enum FileRegistrationRecoveryDisposition +{ + Cleared, + AutomaticRecovery, + WaitingForOwnerRetry, + RequiresOperatorAttention +} + +public sealed record FileRegistrationRecoveryBlocker( + Guid OperationId, + FileMutationJournalState JournalState, + FileAction Action, + int? AudiobookId, + string OwnerKind, + bool SourceTouchesBoundary, + bool DestinationTouchesBoundary, + FileRegistrationRecoveryDisposition Recoverability, + string PublicReason); + +/// +/// Reports file-registration publications that still own recovery state. /// public interface IFileRegistrationRecoveryProbe { @@ -16,6 +37,11 @@ Task HasBlockingBoundaryAsync( string boundaryPath, FileSystemPathSemantics semantics, CancellationToken cancellationToken = default); + + Task> GetBlockingBoundaryAsync( + string boundaryPath, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken = default); } public sealed record FileRegistrationRecoveryReceipt( @@ -24,6 +50,15 @@ public sealed record FileRegistrationRecoveryReceipt( string SourcePath, string DestinationPath); +public sealed record FileRegistrationRecoveryStatus( + Guid OperationId, + FileMutationJournalState JournalState, + int? AudiobookId, + FileRegistrationRecoveryDisposition Disposition, + bool CanRetry, + bool CanAbandon, + string PublicReason); + /// /// Reconciles committed file-registration moves whose published destination is already /// owned by an audiobook but whose original source retirement is still incomplete. @@ -39,6 +74,10 @@ Task ReconcileAudiobookAsync( int audiobookId, CancellationToken cancellationToken = default); + Task RetryAsync( + Guid operationId, + CancellationToken cancellationToken = default); + Task> ReconcileAudiobookWithReceiptsAsync( int audiobookId, diff --git a/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs b/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs index 8739def5b..81dc934ee 100644 --- a/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs +++ b/listenarr.application/Audiobooks/Contracts/IRootFolderStorageConfirmationService.cs @@ -1,5 +1,12 @@ namespace Listenarr.Application.Audiobooks.Contracts; +public sealed class RootFolderRecoveryBlockedException( + FileRegistrationRecoveryBlocker blocker) + : InvalidOperationException(blocker.PublicReason) +{ + public FileRegistrationRecoveryBlocker Blocker { get; } = blocker; +} + public interface IRootFolderStorageConfirmationService { Task ConfirmCurrentFolderAsync( diff --git a/listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs b/listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs new file mode 100644 index 000000000..75e3cfcce --- /dev/null +++ b/listenarr.application/Audiobooks/Contracts/PhysicalObjectIdentitySafety.cs @@ -0,0 +1,57 @@ +namespace Listenarr.Application.Audiobooks.Contracts; + +public static class PhysicalObjectIdentitySafety +{ + public static bool IsKnownWeak(string? identity) + { + if (string.IsNullOrWhiteSpace(identity) + || !identity.StartsWith("linux:", StringComparison.Ordinal) + && !identity.StartsWith( + "linux-generation:", + StringComparison.Ordinal)) + { + return false; + } + + var parts = identity.Split(':'); + if (parts.Length == 6 + && string.Equals(parts[0], "linux", StringComparison.Ordinal) + && IsFixedHex(parts[1], 8) + && IsFixedHex(parts[2], 8) + && IsFixedHex(parts[3], 16) + && IsFixedHex(parts[4], 16) + && IsFixedHex(parts[5], 8)) + { + return true; + } + + var suffixIndex = parts[0] switch + { + "linux-generation" when parts.Length >= 6 + && IsFixedHex(parts[1], 8) + && IsFixedHex(parts[2], 8) + && IsFixedHex(parts[3], 16) => 4, + "linux" when parts.Length >= 8 + && IsFixedHex(parts[1], 8) + && IsFixedHex(parts[2], 8) + && IsFixedHex(parts[3], 16) + && IsFixedHex(parts[4], 16) + && IsFixedHex(parts[5], 8) => 6, + _ => -1 + }; + + return suffixIndex >= 0 + && parts.Length == suffixIndex + 3 + && string.Equals(parts[suffixIndex], "fh", StringComparison.Ordinal) + && string.Equals( + parts[suffixIndex + 1], + "00000081", + StringComparison.OrdinalIgnoreCase) + && parts[suffixIndex + 2].Length > 0 + && parts[suffixIndex + 2].Length % 2 == 0 + && parts[suffixIndex + 2].All(Uri.IsHexDigit); + } + + private static bool IsFixedHex(string value, int length) => + value.Length == length && value.All(Uri.IsHexDigit); +} diff --git a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs index 5f7983aec..0cc429b26 100644 --- a/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs +++ b/listenarr.application/Audiobooks/Contracts/Repositories/IAudiobookFileRepository.cs @@ -28,6 +28,34 @@ public sealed record AudiobookFilePathReferenceSnapshot( int AudiobookId, string? Path); + public sealed record AudiobookFilePhysicalGenerationSnapshot( + long? Size, + double? DurationSeconds, + string? Format, + string? Container, + string? Codec, + int? Bitrate, + int? SampleRate, + int? Channels, + string? Source, + string? PhysicalObjectIdentity, + int PhysicalIdentityVersion, + DateTime? PhysicalIdentityObservedAtUtc); + + public sealed record AudiobookFileMetadataRefreshSnapshot( + int FileId, + int AudiobookId, + AudiobookFilePathState PathState, + string? PhysicalObjectIdentity, + int PhysicalIdentityVersion, + DateTime? PhysicalIdentityObservedAtUtc, + string? BasePath) + { + public static AudiobookFileMetadataRefreshSnapshot Capture(AudiobookFile file, string? basePath) => + new(file.Id, file.AudiobookId, file.CapturePathState(), file.PhysicalObjectIdentity, + file.PhysicalIdentityVersion, file.PhysicalIdentityObservedAtUtc, basePath); + } + public interface IAudiobookFileRepository { Task GetByIdAsync(int id, CancellationToken ct = default); @@ -47,6 +75,14 @@ Task CheckOwnershipAsync( AudiobookFilePathIdentity identity, CancellationToken ct = default); Task UpdateAsync(AudiobookFile file, CancellationToken ct = default); + /// + /// Update metadata only if the complete tracked ownership snapshot and base path still match. + /// Identity and ownership fields are never written by this operation. + /// + Task RefreshMetadataAsync( + AudiobookFileMetadataRefreshSnapshot expectedFile, + AudioMetadata metadata, + CancellationToken ct = default); Task ReplacePhysicalGenerationAsync( int fileId, int audiobookId, @@ -62,6 +98,21 @@ Task ReplacePhysicalGenerationWithBasePathAsync( AudiobookFile replacement, AudiobookBasePathMutation basePathMutation, CancellationToken ct = default); + Task RestorePhysicalGenerationAsync( + int fileId, + int audiobookId, + string? expectedPath, + string? expectedPhysicalObjectIdentity, + AudiobookFilePhysicalGenerationSnapshot predecessor, + CancellationToken ct = default); + Task RestorePhysicalGenerationWithBasePathAsync( + int fileId, + int audiobookId, + string? expectedPath, + string? expectedPhysicalObjectIdentity, + AudiobookFilePhysicalGenerationSnapshot predecessor, + AudiobookBasePathMutation basePathMutation, + CancellationToken ct = default); Task DeletePhysicalGenerationAsync( int fileId, int audiobookId, diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs new file mode 100644 index 000000000..53c96328a --- /dev/null +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.MetadataRefresh.cs @@ -0,0 +1,110 @@ +using Listenarr.Domain.Common; + +namespace Listenarr.Application.Audiobooks.Files; + +public partial class AudiobookFileService +{ + public Task RefreshMetadataAsync( + Audiobook audiobook, + int fileId, + IAudiobookFileRegistrationLease registrationLease, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(audiobook); + ArgumentNullException.ThrowIfNull(registrationLease); + ArgumentException.ThrowIfNullOrWhiteSpace(registrationLease.PublicPath); + ArgumentException.ThrowIfNullOrWhiteSpace(registrationLease.MetadataPath); + if (fileId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(fileId)); + } + + return filesystemMutationCoordinator.ExecuteExclusiveAsync( + globalToken => audiobookOperationCoordinator.ExecuteExclusiveAsync( + audiobook.Id, + token => RefreshMetadataCoreAsync(audiobook.Id, fileId, registrationLease, token), + globalToken), + cancellationToken); + } + + private async Task RefreshMetadataCoreAsync( + int audiobookId, + int fileId, + IAudiobookFileRegistrationLease lease, + CancellationToken cancellationToken) + { + await moveQueueService.EnsureFilesystemMutationAllowedAsync(audiobookId, cancellationToken); + var audiobook = await audiobookRepository.GetByIdSnapshotAsync(audiobookId, cancellationToken); + var currentFile = await audiobookFileRepository.GetByIdAsync(fileId, cancellationToken); + if (audiobook == null + || currentFile == null + || currentFile.AudiobookId != audiobookId + || currentFile.PathIdentityState != PathIdentityState.Valid) + { + return false; + } + + // Capture immutable expected state before extraction; the persistence port + // compares it atomically and writes metadata fields only. + var expectedFile = AudiobookFileMetadataRefreshSnapshot.Capture(currentFile, audiobook.BasePath); + if (!await CanRefreshOwnedMetadataAsync(audiobook, expectedFile, lease, cancellationToken)) + { + return false; + } + + // Operation-local cache identity prevents a path-only read from inheriting + // cached metadata for a previously visible object at the same pathname. + var metadata = await ExtractMetadataAsync( + lease.MetadataPath, + $"metadata-read:{Guid.NewGuid():N}", + lease.PublicPath); + cancellationToken.ThrowIfCancellationRequested(); + if (metadata == null + || !await CanRefreshOwnedMetadataAsync(audiobook, expectedFile, lease, cancellationToken)) + { + return false; + } + + return await audiobookFileRepository.RefreshMetadataAsync( + expectedFile, metadata, cancellationToken); + } + + private async Task CanRefreshOwnedMetadataAsync( + Audiobook audiobook, + AudiobookFileMetadataRefreshSnapshot expectedFile, + IAudiobookFileRegistrationLease lease, + CancellationToken cancellationToken) + { + var authorization = await ResolveAuthorizedClaimPathAsync( + audiobook, lease.PublicPath, cancellationToken); + if (authorization.Path == null) + { + return false; + } + + var identity = await filePathIdentityResolver.ResolveAsync( + audiobook, authorization.Path, cancellationToken); + var storedIdentity = await filePathIdentityResolver.ResolveAsync( + audiobook, expectedFile.PathState.StoredPath!, cancellationToken); + if (identity.State != PathIdentityState.Valid + || storedIdentity.State != PathIdentityState.Valid + || string.IsNullOrWhiteSpace(identity.OwnershipKey) + || identity.OwnershipKey != storedIdentity.OwnershipKey + || identity.OwnershipKey != expectedFile.PathState.OwnershipKey + || identity.LookupKey != expectedFile.PathState.LookupKey + || identity.CanonicalPath != expectedFile.PathState.CanonicalPath + || identity.Syntax != expectedFile.PathState.Syntax + || identity.CaseSensitivity != expectedFile.PathState.CaseSensitivity + || identity.RequestedMode != expectedFile.PathState.RequestedMode + || identity.BoundaryPath != expectedFile.PathState.BoundaryPath + || identity.Version != expectedFile.PathState.Version) + { + return false; + } + + var ownership = await audiobookFileRepository.CheckOwnershipAsync( + audiobook.Id, expectedFile.FileId, identity, cancellationToken); + return ownership.Outcome == AudiobookFileOwnershipCheckOutcome.Available + && lease.MatchesCurrentPublication(); + } +} diff --git a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs index 3dad98b99..5c12d3f3a 100644 --- a/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs +++ b/listenarr.application/Audiobooks/Files/AudiobookFileService.PhysicalGeneration.cs @@ -203,7 +203,7 @@ private async Task RefreshPhysicalGenerationCoreAsync( expectedPhysicalObjectIdentity) && !registrationLease.MatchesPhysicalObjectIdentity( expectedPhysicalObjectIdentity)); - var predecessor = ClonePhysicalGeneration(currentFile); + var predecessor = CapturePhysicalGeneration(currentFile); if (!registrationLease.MatchesCurrentPublication()) { @@ -240,14 +240,14 @@ private async Task RefreshPhysicalGenerationCoreAsync( } var reverted = basePathMutation == null - ? await audiobookFileRepository.ReplacePhysicalGenerationAsync( + ? await audiobookFileRepository.RestorePhysicalGenerationAsync( currentFile.Id, currentFile.AudiobookId, currentFile.Path, registrationLease.PhysicalObjectIdentity, predecessor, CancellationToken.None) - : await audiobookFileRepository.ReplacePhysicalGenerationWithBasePathAsync( + : await audiobookFileRepository.RestorePhysicalGenerationWithBasePathAsync( currentFile.Id, currentFile.AudiobookId, currentFile.Path, @@ -413,27 +413,19 @@ await Task.Delay( return false; } - private static AudiobookFile ClonePhysicalGeneration(AudiobookFile source) - { - var clone = AudiobookFile.CreateUnresolved(source.Path); - clone.AudiobookId = source.AudiobookId; - clone.Size = source.Size; - clone.DurationSeconds = source.DurationSeconds; - clone.Format = source.Format; - clone.Container = source.Container; - clone.Codec = source.Codec; - clone.Bitrate = source.Bitrate; - clone.SampleRate = source.SampleRate; - clone.Channels = source.Channels; - clone.Source = source.Source; - if (!string.IsNullOrWhiteSpace(source.PhysicalObjectIdentity) - && source.PhysicalIdentityObservedAtUtc.HasValue) - { - clone.ApplyPhysicalObjectIdentity( - source.PhysicalObjectIdentity, - source.PhysicalIdentityObservedAtUtc.Value); - } - - return clone; - } + private static AudiobookFilePhysicalGenerationSnapshot + CapturePhysicalGeneration(AudiobookFile source) => + new( + source.Size, + source.DurationSeconds, + source.Format, + source.Container, + source.Codec, + source.Bitrate, + source.SampleRate, + source.Channels, + source.Source, + source.PhysicalObjectIdentity, + source.PhysicalIdentityVersion, + source.PhysicalIdentityObservedAtUtc); } diff --git a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs index ad802d9da..912a35e94 100644 --- a/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs +++ b/listenarr.application/Audiobooks/RootFolders/RootFolderService.cs @@ -353,16 +353,21 @@ private async Task EnsureNoRegistrationRecoveryTouchesRootAsync( string rootPath, FileSystemPathSemantics semantics) { - if (_fileRegistrationRecoveryProbe == null - || !await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync( - rootPath, - semantics)) + if (_fileRegistrationRecoveryProbe == null) + { + return; + } + + var blockers = await _fileRegistrationRecoveryProbe + .GetBlockingBoundaryAsync(rootPath, semantics); + var blocker = blockers.FirstOrDefault(); + if (blocker == null) { return; } throw new InvalidOperationException( - "Root folder has unresolved file-registration recovery touching this path; complete that recovery before deleting or reassigning the root."); + $"Root folder has unresolved file-registration recovery {blocker.OperationId} in state {blocker.JournalState}; complete that recovery before deleting or reassigning the root."); } private async Task ResolveSemanticsAsync( diff --git a/listenarr.application/Downloads/Contracts/IFileMover.cs b/listenarr.application/Downloads/Contracts/IFileMover.cs index ce289bd05..954883761 100644 --- a/listenarr.application/Downloads/Contracts/IFileMover.cs +++ b/listenarr.application/Downloads/Contracts/IFileMover.cs @@ -40,6 +40,15 @@ public sealed record FilePublicationPreparationResult( && RegistrationLease != null; } + public enum UncommittedPublicationRollbackOutcome + { + RolledBack, + AlreadyTerminal, + OwnershipCommitted, + Pending, + NeedsAttention + } + /// /// Handles file manipulation within a destination hierarchy that has already /// been established by the caller. Implementations must not create missing @@ -158,5 +167,12 @@ Task CompletePreparedMoveAsync( string destination, IAudiobookFileRegistrationLease registrationLease, Guid operationId); + + /// + /// Compensates an anonymous, verified registration publication by removing + /// only its pinned target generation while retaining the exact source. + /// + Task + RollbackUncommittedRegistrationAsync(Guid operationId); } } diff --git a/listenarr.domain/Downloads/FileMutationJournal.cs b/listenarr.domain/Downloads/FileMutationJournal.cs index 47b10546c..1f2a8d7ba 100644 --- a/listenarr.domain/Downloads/FileMutationJournal.cs +++ b/listenarr.domain/Downloads/FileMutationJournal.cs @@ -37,7 +37,43 @@ public enum FileMutationJournalState SourceDeleted, Completed, OwnerMetadataReconciled, - NeedsAttention + NeedsAttention, + RollbackAuthorized, + RolledBack +} + +public static class FileMutationJournalLifecycle +{ + public static bool IsRegistrationPublicationTerminal( + FileMutationJournalState state) => + state is FileMutationJournalState.Completed + or FileMutationJournalState.RolledBack + or FileMutationJournalState.NeedsAttention; + + public static bool IsRegistrationPublicationRecoverable( + FileMutationJournalState state) => + state is FileMutationJournalState.Planned + or FileMutationJournalState.TargetIdentityPersisted + or FileMutationJournalState.TargetVerified + or FileMutationJournalState.RegistrationCommitted + or FileMutationJournalState.SourceDeletionAuthorized + or FileMutationJournalState.SourceDeleted + or FileMutationJournalState.RollbackAuthorized; + + public static bool RequiresOperatorAttention( + FileMutationJournalState state) => + state == FileMutationJournalState.NeedsAttention; + + public static bool MayRetireSource(FileMutationJournalState state) => + state is FileMutationJournalState.RegistrationCommitted + or FileMutationJournalState.SourceDeletionAuthorized + or FileMutationJournalState.SourceDeleted + or FileMutationJournalState.Completed; + + public static bool ClearsRegistrationRecoveryBoundary( + FileMutationJournalState state) => + state is FileMutationJournalState.Completed + or FileMutationJournalState.RolledBack; } /// diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs new file mode 100644 index 000000000..ee0b38e40 --- /dev/null +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.Rollback.cs @@ -0,0 +1,235 @@ +using Listenarr.Domain.Audiobooks.Enumerations; + +namespace Listenarr.Infrastructure.FileSystem; + +public partial class FileMover +{ + public async Task + RollbackUncommittedRegistrationAsync(Guid operationId) + { + if (operationId == Guid.Empty) + { + throw new ArgumentException( + "A registration rollback requires a non-empty operation ID.", + nameof(operationId)); + } + if (_fileMutationJournalStore == null) + { + return UncommittedPublicationRollbackOutcome.Pending; + } + + var cancellationToken = CancellationToken.None; + var journal = await _fileMutationJournalStore.GetAsync( + operationId, + cancellationToken); + if (journal == null) + { + return UncommittedPublicationRollbackOutcome.Pending; + } + if (FileMutationJournalLifecycle.IsRegistrationPublicationTerminal( + journal.State)) + { + return UncommittedPublicationRollbackOutcome.AlreadyTerminal; + } + if (journal.AudiobookId.HasValue + || journal.AudiobookFileId.HasValue + || journal.Action is not ( + FileAction.Move or FileAction.Copy or FileAction.HardlinkCopy) + || journal.State is not ( + FileMutationJournalState.Planned + or FileMutationJournalState.TargetIdentityPersisted + or FileMutationJournalState.TargetVerified + or FileMutationJournalState.RollbackAuthorized)) + { + return journal.AudiobookId.HasValue + ? UncommittedPublicationRollbackOutcome.OwnershipCommitted + : UncommittedPublicationRollbackOutcome.Pending; + } + + using var gate = await TryAcquireFileMoveGateAsync( + journal.SourcePath, + journal.DestinationPath, + allowExistingAliasForRecovery: true); + if (gate == null) + { + return UncommittedPublicationRollbackOutcome.Pending; + } + if (!await JournalPathsMatchGateAsync(journal, gate)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The uncommitted registration paths no longer match their durable journal.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + if (!JournalParentGenerationsMatchGate(journal, gate)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "An uncommitted registration parent directory changed physical generation.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + + var sourceOutcome = gate.SourceParent.TryOpenExistingFileWithOutcome( + gate.SourceName, + requireDeleteAccess: false, + out var sourceEntry); + using (sourceEntry) + { + if (sourceOutcome == PinnedFileOpenOutcome.Unavailable) + { + return UncommittedPublicationRollbackOutcome.Pending; + } + + var targetOutcome = + gate.DestinationParent.TryOpenExistingFileForStableDeleteWithOutcome( + gate.DestinationName, + out var targetEntry); + using (targetEntry) + { + if (targetOutcome == PinnedFileOpenOutcome.Unavailable) + { + return UncommittedPublicationRollbackOutcome.Pending; + } + if (sourceOutcome != PinnedFileOpenOutcome.Opened + || !await MatchesMarkerlessSourceProofAsync( + sourceEntry!, + journal, + cancellationToken)) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The uncommitted registration source is missing or no longer matches its durable proof; the target was preserved.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + + if (targetOutcome == PinnedFileOpenOutcome.NotFound) + { + try + { + await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.RolledBack, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: "The uncommitted registration target was already absent; the exact source remains intact.", + cancellationToken); + } + catch (InvalidOperationException) + { + var current = await _fileMutationJournalStore.GetAsync( + journal.OperationId, + cancellationToken); + if (current?.AudiobookId.HasValue == true) + { + return UncommittedPublicationRollbackOutcome.OwnershipCommitted; + } + if (current != null + && FileMutationJournalLifecycle + .ClearsRegistrationRecoveryBoundary(current.State)) + { + return UncommittedPublicationRollbackOutcome.AlreadyTerminal; + } + if (current?.State == FileMutationJournalState.NeedsAttention) + { + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + throw; + } + return UncommittedPublicationRollbackOutcome.RolledBack; + } + if (journal.State == FileMutationJournalState.Planned) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "A target exists but this planned registration never persisted authority over its generation; it was preserved.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + if (!TargetMatchesMarkerlessJournal(targetEntry!, journal) + || (journal.State == FileMutationJournalState.TargetVerified + && !await MatchesMarkerlessTargetContentAsync( + targetEntry!, + journal, + cancellationToken))) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The uncommitted registration target changed generation or content; it was preserved.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + + if (journal.State is FileMutationJournalState.TargetIdentityPersisted + or FileMutationJournalState.TargetVerified) + { + try + { + journal = await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.RollbackAuthorized, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: "Startup recovery proved an exact source and unowned target and authorized compensation.", + cancellationToken); + } + catch (InvalidOperationException) + { + var current = await _fileMutationJournalStore.GetAsync( + journal.OperationId, + cancellationToken); + if (current?.AudiobookId.HasValue == true) + { + return UncommittedPublicationRollbackOutcome.OwnershipCommitted; + } + if (current != null + && FileMutationJournalLifecycle + .ClearsRegistrationRecoveryBoundary(current.State)) + { + return UncommittedPublicationRollbackOutcome.AlreadyTerminal; + } + if (current?.State == FileMutationJournalState.NeedsAttention) + { + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + throw; + } + } + if (journal.State != FileMutationJournalState.RollbackAuthorized + || journal.AudiobookId.HasValue) + { + return journal.AudiobookId.HasValue + ? UncommittedPublicationRollbackOutcome.OwnershipCommitted + : UncommittedPublicationRollbackOutcome.Pending; + } + + targetEntry!.Delete(immediateWindows: true); + gate.DestinationParent.FlushDirectoryEntry(); + var sourceVisibility = sourceEntry!.ProbeVisiblePathMatch(); + if (sourceVisibility == RegistrationPublicationMatchOutcome.Unavailable) + { + return UncommittedPublicationRollbackOutcome.Pending; + } + if (sourceVisibility == RegistrationPublicationMatchOutcome.Mismatch) + { + await MarkMarkerlessRegistrationNeedsAttentionAsync( + journal, + "The registration source changed after exact target compensation.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.NeedsAttention; + } + + await _fileMutationJournalStore.AdvanceAsync( + journal.OperationId, + FileMutationJournalState.RolledBack, + journal.TargetPhysicalObjectIdentity, + audiobookId: null, + error: "Startup recovery removed the exact unowned publication target and retained its source.", + cancellationToken); + return UncommittedPublicationRollbackOutcome.RolledBack; + } + } + } +} diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs index 4fbb1e025..5ca7d7714 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistration.cs @@ -151,7 +151,9 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( } } - if (journal.State == FileMutationJournalState.NeedsAttention) + if (journal.State == FileMutationJournalState.NeedsAttention + || journal.State == FileMutationJournalState.RollbackAuthorized + || journal.State == FileMutationJournalState.RolledBack) { return new MarkerlessRegistrationPreparation(true, null); } @@ -199,7 +201,8 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( return new MarkerlessRegistrationPreparation(true, null); } } - else if (journal.State >= FileMutationJournalState.TargetVerified) + else if (FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable( + journal.State)) { if (!await MarkerlessRegistrationTargetMatchesAsync( gate, @@ -273,7 +276,9 @@ private bool CommitMarkerlessRegistration( throw new InvalidOperationException( "The markerless registration identity changed before commit."); } - if (journal.State == FileMutationJournalState.NeedsAttention) + if (journal.State == FileMutationJournalState.NeedsAttention + || journal.State == FileMutationJournalState.RollbackAuthorized + || journal.State == FileMutationJournalState.RolledBack) { throw new InvalidOperationException( "A markerless registration requiring attention cannot be committed."); @@ -291,7 +296,11 @@ private bool CommitMarkerlessRegistration( "The registration destination changed before its journal commit."); return false; } - if (journal.State < FileMutationJournalState.TargetVerified) + if (journal.State != FileMutationJournalState.TargetVerified + && journal.State != FileMutationJournalState.RegistrationCommitted + && journal.State != FileMutationJournalState.SourceDeletionAuthorized + && journal.State != FileMutationJournalState.SourceDeleted + && journal.State != FileMutationJournalState.Completed) { throw new InvalidOperationException( "The markerless registration destination is not verified."); @@ -307,7 +316,7 @@ RegistrationPublicationMatchOutcome ValidateCommitPublication() : validation; } - if (journal.State < FileMutationJournalState.RegistrationCommitted) + if (journal.State == FileMutationJournalState.TargetVerified) { var commitValidation = _fileMutationJournalStore.AdvanceWithCommitValidation( @@ -362,7 +371,7 @@ RegistrationPublicationMatchOutcome ValidateCommitPublication() } if (action != FileAction.Move - && journal.State < FileMutationJournalState.Completed) + && journal.State != FileMutationJournalState.Completed) { var completionValidation = _fileMutationJournalStore.AdvanceWithCommitValidation( @@ -387,10 +396,9 @@ RegistrationPublicationMatchOutcome ValidateCommitPublication() "The markerless registration journal disappeared after publication completion."); } - return journal.State != FileMutationJournalState.NeedsAttention - && (action == FileAction.Move - ? journal.State >= FileMutationJournalState.RegistrationCommitted - : journal.State >= FileMutationJournalState.Completed); + return action == FileAction.Move + ? FileMutationJournalLifecycle.MayRetireSource(journal.State) + : journal.State == FileMutationJournalState.Completed; } diff --git a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs index ee916e1ca..87055f4b6 100644 --- a/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs +++ b/listenarr.infrastructure/FileSystem/FileMover.MarkerlessRegistrationMove.cs @@ -40,11 +40,13 @@ public partial class FileMover throw new InvalidOperationException( "The markerless registration move identity does not match the requested completion."); } - if (journal.State == FileMutationJournalState.NeedsAttention) + if (journal.State == FileMutationJournalState.NeedsAttention + || journal.State == FileMutationJournalState.RollbackAuthorized + || journal.State == FileMutationJournalState.RolledBack) { return false; } - if (journal.State < FileMutationJournalState.RegistrationCommitted + if (!FileMutationJournalLifecycle.MayRetireSource(journal.State) || !journal.AudiobookId.HasValue) { _logger.LogWarning( @@ -127,7 +129,8 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( return false; } - if (journal.State >= FileMutationJournalState.SourceDeleted) + if (journal.State is FileMutationJournalState.SourceDeleted + or FileMutationJournalState.Completed) { var sourceOpenOutcome = gate.SourceParent.TryOpenExistingFileWithOutcome( gate.SourceName, @@ -150,7 +153,7 @@ await MarkMarkerlessRegistrationNeedsAttentionAsync( } } - if (journal.State < FileMutationJournalState.SourceDeletionAuthorized) + if (journal.State == FileMutationJournalState.RegistrationCommitted) { journal = await _fileMutationJournalStore.AdvanceAsync( journal.OperationId, diff --git a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs index 133cac239..32c718bab 100644 --- a/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs +++ b/listenarr.infrastructure/FileSystem/FileMutationJournalStore.AdvanceState.cs @@ -153,7 +153,8 @@ private static void ValidateAdvanceRequest( "Owner metadata reconciliation must be committed atomically with the owning audiobook metadata, not through the filesystem journal store."); } if (state >= FileMutationJournalState.TargetIdentityPersisted - && state != FileMutationJournalState.NeedsAttention + && state is not (FileMutationJournalState.NeedsAttention + or FileMutationJournalState.RolledBack) && string.IsNullOrWhiteSpace(targetPhysicalObjectIdentity)) { throw new ArgumentException( @@ -183,6 +184,44 @@ private void ApplyAdvance( throw new InvalidOperationException( "A file mutation whose owner metadata is reconciled is terminal and cannot be advanced."); } + if (journal.State == FileMutationJournalState.RolledBack + && state != FileMutationJournalState.RolledBack) + { + throw new InvalidOperationException( + "A rolled-back file-registration publication cannot be advanced."); + } + if (journal.State == FileMutationJournalState.Completed + && state is not (FileMutationJournalState.Completed + or FileMutationJournalState.NeedsAttention)) + { + throw new InvalidOperationException( + "A completed file-registration publication cannot resume filesystem mutation."); + } + if (state == FileMutationJournalState.RollbackAuthorized + && (journal.State is not ( + FileMutationJournalState.TargetIdentityPersisted + or FileMutationJournalState.TargetVerified) + || journal.AudiobookId.HasValue + || journal.AudiobookFileId.HasValue + || audiobookId.HasValue)) + { + throw new InvalidOperationException( + "Only an anonymous verified registration publication can authorize rollback."); + } + if (state == FileMutationJournalState.RolledBack + && (journal.State is not ( + FileMutationJournalState.Planned + or FileMutationJournalState.TargetIdentityPersisted + or FileMutationJournalState.TargetVerified + or FileMutationJournalState.RollbackAuthorized + or FileMutationJournalState.RolledBack) + || journal.AudiobookId.HasValue + || journal.AudiobookFileId.HasValue + || audiobookId.HasValue)) + { + throw new InvalidOperationException( + "Only an anonymous uncommitted registration publication can be rolled back."); + } if (journal.State == FileMutationJournalState.NeedsAttention && state != FileMutationJournalState.NeedsAttention) { diff --git a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs index 0adbf044d..b2eeb9875 100644 --- a/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs +++ b/listenarr.infrastructure/FileSystem/PinnedAudiobookFileRegistrationLease.cs @@ -98,6 +98,38 @@ internal static PinnedAudiobookFileRegistrationLease Open( commitRegistration); } + internal static PinnedAudiobookFileRegistrationLease OpenForMetadataRead( + string publicPath, + string? expectedPhysicalObjectIdentity) + { + if (OperatingSystem.IsLinux() + && PhysicalObjectIdentitySafety.IsKnownWeak(expectedPhysicalObjectIdentity)) + { + return OpenPinnedPathOnly(publicPath); + } + + try + { + return Open(publicPath, expectedPhysicalObjectIdentity); + } + catch (PlatformNotSupportedException) when (OperatingSystem.IsLinux()) + { + return OpenPinnedPathOnly(publicPath); + } + } + + private static PinnedAudiobookFileRegistrationLease OpenPinnedPathOnly( + string publicPath) + { + var canonicalPath = Path.GetFullPath(publicPath); + var parentPath = Path.GetDirectoryName(canonicalPath) + ?? throw new InvalidOperationException("The metadata path has no parent directory."); + using var parent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( + parentPath, createMissing: false); + var file = parent.OpenExistingFileForStableRead(Path.GetFileName(canonicalPath)); + return CreatePinnedPathOnly(file, canonicalPath); + } + internal static PinnedAudiobookFileRegistrationLease Create( PinnedDirectoryCreation.PinnedFileEntry file, string publicPath, diff --git a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs index 1cb430484..e9215d165 100644 --- a/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs +++ b/listenarr.infrastructure/FileSystem/PinnedDirectoryCreation.LinuxIdentityCandidates.cs @@ -150,8 +150,8 @@ internal static bool ArePersistedObjectIdentitiesDurablyEquivalent( { ArgumentException.ThrowIfNullOrWhiteSpace(left); ArgumentException.ThrowIfNullOrWhiteSpace(right); - if (IsLegacyWeakLinuxObjectIdentity(left) - || IsLegacyWeakLinuxObjectIdentity(right)) + if (PhysicalObjectIdentitySafety.IsKnownWeak(left) + || PhysicalObjectIdentitySafety.IsKnownWeak(right)) { return false; } @@ -313,39 +313,6 @@ private static bool IsLegacyWeakLinuxGenerationIdentity(string candidate) && string.Equals(parts[1], "00000081", StringComparison.OrdinalIgnoreCase); } - private static bool IsLegacyWeakLinuxObjectIdentity(string identity) - { - var parts = identity.Split(':'); - if (parts.Length >= 6 - && string.Equals(parts[0], "linux-generation", StringComparison.Ordinal) - && IsFixedHex(parts[1], 8) - && IsFixedHex(parts[2], 8) - && IsFixedHex(parts[3], 16)) - { - return IsLegacyWeakLinuxGenerationSuffix(parts, 4); - } - - return parts.Length >= 8 - && string.Equals(parts[0], "linux", StringComparison.Ordinal) - && IsFixedHex(parts[1], 8) - && IsFixedHex(parts[2], 8) - && IsFixedHex(parts[3], 16) - && IsFixedHex(parts[4], 16) - && IsFixedHex(parts[5], 8) - && IsLegacyWeakLinuxGenerationSuffix(parts, 6); - } - - private static bool IsLegacyWeakLinuxGenerationSuffix( - string[] parts, - int suffixIndex) => - parts.Length == suffixIndex + 3 - && string.Equals(parts[suffixIndex], "fh", StringComparison.Ordinal) - && TryValidateLinuxFileHandle(parts, suffixIndex, requireDurable: false) - && string.Equals( - parts[suffixIndex + 1], - "00000081", - StringComparison.OrdinalIgnoreCase); - private static bool TryValidateLinuxGenerationSuffix( string[] parts, int suffixIndex) => diff --git a/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs b/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs index 121aa5bbf..c4dd5661b 100644 --- a/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs +++ b/listenarr.infrastructure/FileSystem/RootFolderStorageConfirmationService.cs @@ -9,7 +9,8 @@ internal sealed class RootFolderStorageConfirmationService( IFileSystemSemanticsResolver semanticsResolver, IMoveQueueService moveQueueService, IFilesystemMutationCoordinator mutationCoordinator, - IAudiobookOperationCoordinator audiobookOperationCoordinator) + IAudiobookOperationCoordinator audiobookOperationCoordinator, + IFileRegistrationRecoveryProbe fileRegistrationRecoveryProbe) : IRootFolderStorageConfirmationService { internal Action? BeforeCommitForTest { get; set; } @@ -247,26 +248,42 @@ await MarkPostCommitConfirmationUnstableAsync( } } - private static async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync( + private async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync( ListenArrDbContext db, int rootFolderId, string canonicalRootPath, FileSystemPathSemantics semantics, CancellationToken cancellationToken) { - var audiobooks = await db.Audiobooks + var audiobookPaths = await db.Audiobooks .AsNoTracking() - .AsSplitQuery() - .Include(audiobook => audiobook.Files) + .Select(audiobook => new + { + audiobook.Id, + audiobook.BasePath, + audiobook.FilePath + }) .ToListAsync(cancellationToken); - var audiobookIds = audiobooks + var audiobookIds = audiobookPaths .Where(audiobook => PathTouchesConfirmedRoot(audiobook.BasePath, canonicalRootPath, semantics) - || PathTouchesConfirmedRoot(audiobook.FilePath, canonicalRootPath, semantics) - || (audiobook.Files?.Any(file => - PathTouchesConfirmedRoot(file.Path, canonicalRootPath, semantics)) ?? false)) + || PathTouchesConfirmedRoot(audiobook.FilePath, canonicalRootPath, semantics)) .Select(audiobook => audiobook.Id) .ToHashSet(); + var audiobookFilePaths = await db.AudiobookFiles + .AsNoTracking() + .Select(file => new + { + file.AudiobookId, + file.Path + }) + .ToListAsync(cancellationToken); + audiobookIds.UnionWith(audiobookFilePaths + .Where(file => PathTouchesConfirmedRoot( + file.Path, + canonicalRootPath, + semantics)) + .Select(file => file.AudiobookId)); audiobookIds.UnionWith(await db.LibraryDirectoryOwnerships .AsNoTracking() .Where(ownership => ownership.ManagedRootFolderId == rootFolderId @@ -274,18 +291,26 @@ private static async Task EnsureNoExternalRecoveryOwnerTouchesRootAsync( && ownership.State != LibraryDirectoryOwnershipState.Removed) .Select(ownership => ownership.AudiobookId!.Value) .ToListAsync(cancellationToken)); + var registrationBlocker = (await fileRegistrationRecoveryProbe + .GetBlockingBoundaryAsync( + canonicalRootPath, + semantics, + cancellationToken)) + .FirstOrDefault(); + if (registrationBlocker != null) + { + throw new RootFolderRecoveryBlockedException(registrationBlocker); + } var activeMutationJournals = await db.FileMutationJournals .AsNoTracking() .Where(journal => - (journal.AudiobookFileId == null - && journal.State != FileMutationJournalState.Completed) - || (journal.AudiobookId != null + journal.AudiobookId != null && journal.AudiobookFileId != null && (journal.AudiobookFileId == FileMutationOwner.CompanionFile || journal.AudiobookFileId == FileMutationOwner.RegistrationCompanionFile ? journal.State != FileMutationJournalState.Completed - : journal.State != FileMutationJournalState.OwnerMetadataReconciled))) + : journal.State != FileMutationJournalState.OwnerMetadataReconciled)) .ToListAsync(cancellationToken); if (activeMutationJournals.Any(journal => (journal.AudiobookId.HasValue diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs index dada861f2..718af7e0f 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.GenerationProof.cs @@ -7,6 +7,11 @@ internal static bool VerifyTrackedFileCleanupComplete( { foreach (var tracked in trackedPhysicalObjectIdentities) { + if (PhysicalObjectIdentitySafety.IsKnownWeak(tracked.Value)) + { + return false; + } + var parentPath = Path.GetDirectoryName(tracked.Key); var fileName = Path.GetFileName(tracked.Key); if (string.IsNullOrWhiteSpace(parentPath) diff --git a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs index b0e884865..86985f877 100644 --- a/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs +++ b/listenarr.infrastructure/Library/Moving/AudiobookFilesystemDeleteService.cs @@ -285,11 +285,13 @@ private static IReadOnlyDictionary ResolveTrackedPhysicalObjectI continue; } - if (string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity)) + if (string.IsNullOrWhiteSpace(file.PhysicalObjectIdentity) + || PhysicalObjectIdentitySafety.IsKnownWeak( + file.PhysicalObjectIdentity)) { hasUnprovenTrackedPhysicalIdentities = true; result.Warnings.Add( - "A tracked audiobook file has no persisted physical generation, so filesystem deletion was blocked."); + "A tracked audiobook file has no durable persisted physical generation, so filesystem deletion was blocked."); continue; } diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs index a75ac90d2..4652523b1 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.ExternalRecovery.cs @@ -161,26 +161,30 @@ private async Task sourcePath, sourceMode, cancellationToken); - if (sourceSemantics.HasValue - && await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync( + var sourceBlocker = sourceSemantics.HasValue + ? (await _fileRegistrationRecoveryProbe.GetBlockingBoundaryAsync( sourcePath, sourceSemantics.Value, - cancellationToken)) + cancellationToken)).FirstOrDefault() + : null; + if (sourceBlocker != null) { - return RegistrationBoundaryConflict(sourcePath); + return RegistrationBoundaryConflict(sourcePath, sourceBlocker); } var targetSemantics = await ResolveRecoveryBoundarySemanticsAsync( targetPath, targetMode, cancellationToken); - if (targetSemantics.HasValue - && await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync( + var targetBlocker = targetSemantics.HasValue + ? (await _fileRegistrationRecoveryProbe.GetBlockingBoundaryAsync( targetPath, targetSemantics.Value, - cancellationToken)) + cancellationToken)).FirstOrDefault() + : null; + if (targetBlocker != null) { - return RegistrationBoundaryConflict(targetPath); + return RegistrationBoundaryConflict(targetPath, targetBlocker); } return null; @@ -244,18 +248,26 @@ private async Task ValidateStartRecoveryBoundariesAsync( var sourceSemantics = sourcePathSemantics.MetadataSourcePathSemantics?.Semantics ?? sourcePathSemantics.SourceOperationSemantics; - if (_fileRegistrationRecoveryProbe != null - && ((sourceSemantics.HasValue - && await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync( + FileRegistrationRecoveryBlocker? registrationBlocker = null; + if (_fileRegistrationRecoveryProbe != null) + { + if (sourceSemantics.HasValue) + { + registrationBlocker = (await _fileRegistrationRecoveryProbe + .GetBlockingBoundaryAsync( root.Path, sourceSemantics.Value, - cancellationToken)) - || await _fileRegistrationRecoveryProbe.HasBlockingBoundaryAsync( + cancellationToken)).FirstOrDefault(); + } + registrationBlocker ??= (await _fileRegistrationRecoveryProbe + .GetBlockingBoundaryAsync( targetPath, targetResolution.Semantics, - cancellationToken))) + cancellationToken)).FirstOrDefault(); + } + if (registrationBlocker != null) { - var conflict = RegistrationBoundaryConflict(root.Path); + var conflict = RegistrationBoundaryConflict(root.Path, registrationBlocker); throw new RootFolderPathChangeRejectedException( conflict.Code, conflict.PublicMessage, @@ -276,11 +288,13 @@ await EnsureNoTargetBoundaryConflictAsync( return targetIdentityKey; } - private static ExternalRecoveryConflict RegistrationBoundaryConflict(string path) => + private static ExternalRecoveryConflict RegistrationBoundaryConflict( + string path, + FileRegistrationRecoveryBlocker blocker) => new( "registration_recovery_pending", - "An unresolved file publication still owns a path under this root. Complete file-registration recovery before changing the root folder path.", - $"File-registration recovery touches relocation boundary {LogRedaction.SanitizeFilePath(path)}."); + $"File publication {blocker.OperationId} is in state {blocker.JournalState}. Complete file-registration recovery before changing the root folder path.", + $"File-registration recovery {blocker.OperationId} touches relocation boundary {LogRedaction.SanitizeFilePath(path)}."); private static async Task FindExternalRecoveryConflictAsync( @@ -298,8 +312,11 @@ private static async Task .Where(journal => journal.AudiobookId != null && audiobookIds.Contains(journal.AudiobookId.Value) && journal.AudiobookFileId == null - && journal.Action == FileAction.Move - && journal.State != FileMutationJournalState.Completed) + && (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack) .Select(journal => journal.AudiobookId) .FirstOrDefaultAsync(cancellationToken); if (registrationOwnerId.HasValue) diff --git a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs index af10ed3e6..30b9c32e5 100644 --- a/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs +++ b/listenarr.infrastructure/Library/Moving/RootFolderRelocationService.MetadataRepair.cs @@ -153,8 +153,11 @@ private async Task EnsureMetadataRepairRowMutationAllowedAsync( .AnyAsync( journal => journal.AudiobookId == audiobookId && journal.AudiobookFileId == null - && journal.Action == FileAction.Move - && journal.State != FileMutationJournalState.Completed, + && (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack, cancellationToken)) { throw new ApplicationConflictException( diff --git a/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs b/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs index 19db9caad..144fc7bc2 100644 --- a/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs +++ b/listenarr.infrastructure/Metadata/Jobs/MetadataRescanService.cs @@ -142,7 +142,7 @@ await RemoveNonAudioFileAsync( .GetRequiredService(); cancellationToken.ThrowIfCancellationRequested(); using var registrationLease = - PinnedAudiobookFileRegistrationLease.Open( + PinnedAudiobookFileRegistrationLease.OpenForMetadataRead( resolvedIdentity.CanonicalPath, file.PhysicalObjectIdentity); if (!registrationLease.MatchesCurrentPublication()) @@ -153,13 +153,20 @@ await RemoveNonAudioFileAsync( return; } - if (await taskFileService.RefreshPhysicalGenerationAsync( + var updated = registrationLease.HasDurablePhysicalObjectIdentity + ? await taskFileService.RefreshPhysicalGenerationAsync( new Audiobook { Id = file.AudiobookId }, file.Id, file.PhysicalObjectIdentity, registrationLease, "MetadataRescan", - cancellationToken)) + cancellationToken) + : await taskFileService.RefreshMetadataAsync( + new Audiobook { Id = file.AudiobookId }, + file.Id, + registrationLease, + cancellationToken); + if (updated) { logger.LogInformation( "Updated metadata for file id={Id}", diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs index fc77fcaf5..492db6ddb 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryProbe.cs @@ -23,15 +23,28 @@ public async Task HasBlockingAsync( .AnyAsync(journal => journal.AudiobookId == audiobookId && journal.AudiobookFileId == null - && journal.Action == FileAction.Move - && journal.State != FileMutationJournalState.Completed, + && (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack, cancellationToken); } public async Task HasBlockingBoundaryAsync( string boundaryPath, FileSystemPathSemantics semantics, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) => + (await GetBlockingBoundaryAsync( + boundaryPath, + semantics, + cancellationToken)).Count > 0; + + public async Task> + GetBlockingBoundaryAsync( + string boundaryPath, + FileSystemPathSemantics semantics, + CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(boundaryPath); var canonicalBoundary = FileSystemPathIdentity.Canonicalize( @@ -43,22 +56,56 @@ public async Task HasBlockingBoundaryAsync( .AsNoTracking() .Where(journal => journal.AudiobookFileId == null - && journal.State != FileMutationJournalState.Completed) + && (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack) .Select(journal => new { + journal.OperationId, + journal.State, + journal.Action, + journal.AudiobookId, journal.SourcePath, journal.DestinationPath }) .ToListAsync(cancellationToken); - return journals.Any(journal => - FileSystemPathIdentity.StoredPathMayTouchBoundary( + return journals.Select(journal => + { + var sourceTouches = FileSystemPathIdentity.StoredPathMayTouchBoundary( journal.SourcePath, canonicalBoundary, - semantics) - || FileSystemPathIdentity.StoredPathMayTouchBoundary( + semantics); + var destinationTouches = FileSystemPathIdentity.StoredPathMayTouchBoundary( journal.DestinationPath, canonicalBoundary, - semantics)); + semantics); + return new FileRegistrationRecoveryBlocker( + journal.OperationId, + journal.State, + journal.Action, + journal.AudiobookId, + journal.AudiobookId.HasValue + ? "Audiobook" + : journal.State == FileMutationJournalState.RollbackAuthorized + ? "StartupRecovery" + : "Unknown", + sourceTouches, + destinationTouches, + journal.State == FileMutationJournalState.NeedsAttention + ? FileRegistrationRecoveryDisposition.RequiresOperatorAttention + : journal.AudiobookId.HasValue + ? FileRegistrationRecoveryDisposition.WaitingForOwnerRetry + : FileRegistrationRecoveryDisposition.AutomaticRecovery, + journal.State == FileMutationJournalState.NeedsAttention + ? "This file publication requires operator repair." + : "This file publication is waiting for restart recovery."); + }) + .Where(blocker => blocker.SourceTouchesBoundary + || blocker.DestinationTouchesBoundary) + .OrderBy(blocker => blocker.OperationId) + .ToList(); } } diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs new file mode 100644 index 000000000..4cfe060e1 --- /dev/null +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs @@ -0,0 +1,317 @@ +using Listenarr.Domain.Audiobooks.Enumerations; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Listenarr.Infrastructure.Persistence; + +public sealed partial class FileRegistrationRecoveryService +{ + private async Task AdoptCommittedAnonymousPublicationsAsync( + int? audiobookId, + Guid? operationId, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var anonymousQuery = db.FileMutationJournals + .AsNoTracking() + .Where(journal => (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.AudiobookId == null + && journal.AudiobookFileId == null + && journal.State == FileMutationJournalState.TargetVerified); + if (operationId.HasValue) + { + anonymousQuery = anonymousQuery.Where( + journal => journal.OperationId == operationId.Value); + } + var anonymousJournals = await anonymousQuery + .OrderBy(journal => journal.CreatedAt) + .ThenBy(journal => journal.OperationId) + .ToListAsync(cancellationToken); + if (anonymousJournals.Count == 0) + { + return; + } + var targetClaims = await db.FileMutationJournals + .AsNoTracking() + .Where(journal => journal.TargetPhysicalObjectIdentity != null + && journal.State != FileMutationJournalState.RolledBack) + .ToListAsync(cancellationToken); + + var filesQuery = db.AudiobookFiles.AsNoTracking(); + if (audiobookId.HasValue) + { + filesQuery = filesQuery.Where(file => file.AudiobookId == audiobookId.Value); + } + var trackedFiles = await filesQuery.ToListAsync(cancellationToken); + foreach (var journal in anonymousJournals) + { + cancellationToken.ThrowIfCancellationRequested(); + var matches = trackedFiles + .Where(file => RegisteredPathMatches(file, journal.DestinationPath) + && RegisteredGenerationMatches( + file, + journal.TargetPhysicalObjectIdentity)) + .ToList(); + if (matches.Count == 0) + { + continue; + } + if (targetClaims.Count(candidate => + AnonymousTargetGenerationMatches(candidate, journal)) != 1) + { + if (!audiobookId.HasValue) + { + await TryMarkNeedsAttentionAsync( + journal.OperationId, + journal.State, + "Another anonymous registration journal claims the same published target generation.", + cancellationToken); + } + continue; + } + if (matches.Count != 1) + { + if (!audiobookId.HasValue) + { + await TryMarkNeedsAttentionAsync( + journal.OperationId, + journal.State, + "Multiple tracked audiobook files claim the anonymous publication target generation.", + cancellationToken); + } + continue; + } + + var matchedFile = matches[0]; + var adopted = await TryAdoptAnonymousOwnerAsync( + db, + journal, + matchedFile.AudiobookId, + cancellationToken); + if (adopted) + { + logger.LogInformation( + "Adopted committed anonymous file-registration publication {OperationId} for audiobook {AudiobookId}", + journal.OperationId, + matchedFile.AudiobookId); + } + } + } + + private async Task TryAdoptAnonymousOwnerAsync( + ListenArrDbContext db, + FileMutationJournal expected, + int audiobookId, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow().UtcDateTime; + if (!db.Database.IsRelational()) + { + var tracked = await db.FileMutationJournals.SingleOrDefaultAsync( + candidate => candidate.OperationId == expected.OperationId, + cancellationToken); + if (tracked == null + || tracked.AudiobookId != null + || tracked.AudiobookFileId != null + || !IsRegistrationPublicationAction(tracked.Action) + || tracked.State != FileMutationJournalState.TargetVerified + || !string.Equals( + tracked.TargetPhysicalObjectIdentity, + expected.TargetPhysicalObjectIdentity, + StringComparison.Ordinal)) + { + return false; + } + + tracked.AudiobookId = audiobookId; + tracked.UpdatedAt = now; + await db.SaveChangesAsync(cancellationToken); + return true; + } + + var affected = await db.FileMutationJournals + .Where(candidate => candidate.OperationId == expected.OperationId + && candidate.AudiobookId == null + && candidate.AudiobookFileId == null + && (candidate.Action == FileAction.Move + || candidate.Action == FileAction.Copy + || candidate.Action == FileAction.HardlinkCopy) + && candidate.State == FileMutationJournalState.TargetVerified + && candidate.TargetPhysicalObjectIdentity + == expected.TargetPhysicalObjectIdentity) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(candidate => candidate.AudiobookId, audiobookId) + .SetProperty(candidate => candidate.UpdatedAt, now), + cancellationToken); + return affected == 1; + } + + private async Task ReconcileOrphanedAnonymousPublicationsAsync( + Guid? operationId, + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var journalQuery = db.FileMutationJournals + .AsNoTracking() + .Where(journal => (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.AudiobookId == null + && journal.AudiobookFileId == null + && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack + && journal.State != FileMutationJournalState.NeedsAttention); + if (operationId.HasValue) + { + journalQuery = journalQuery.Where( + journal => journal.OperationId == operationId.Value); + } + var journals = await journalQuery + .OrderBy(journal => journal.CreatedAt) + .ThenBy(journal => journal.OperationId) + .ToListAsync(cancellationToken); + if (journals.Count == 0) + { + return; + } + var targetClaims = await db.FileMutationJournals + .AsNoTracking() + .Where(journal => journal.TargetPhysicalObjectIdentity != null + && journal.State != FileMutationJournalState.RolledBack) + .ToListAsync(cancellationToken); + + var trackedFiles = await db.AudiobookFiles + .AsNoTracking() + .ToListAsync(cancellationToken); + foreach (var journal in journals) + { + cancellationToken.ThrowIfCancellationRequested(); + if (journal.State is FileMutationJournalState.RegistrationCommitted + or FileMutationJournalState.SourceDeletionAuthorized + or FileMutationJournalState.SourceDeleted + or FileMutationJournalState.OwnerMetadataReconciled) + { + await TryMarkNeedsAttentionAsync( + journal.OperationId, + journal.State, + "The registration publication reached an owner-bound state without a durable audiobook owner; its target was preserved.", + cancellationToken); + continue; + } + + var pathOwners = trackedFiles + .Where(file => RegisteredPathMatches(file, journal.DestinationPath)) + .ToList(); + var exactOwners = pathOwners + .Where(file => RegisteredGenerationMatches( + file, + journal.TargetPhysicalObjectIdentity)) + .ToList(); + if (exactOwners.Count == 1) + { + if (journal.State == FileMutationJournalState.TargetVerified) + { + await TryAdoptAnonymousOwnerAsync( + db, + journal, + exactOwners[0].AudiobookId, + cancellationToken); + } + else + { + await TryMarkNeedsAttentionAsync( + journal.OperationId, + journal.State, + "A tracked audiobook file claimed the target before uncommitted compensation completed; the target was preserved.", + cancellationToken); + } + continue; + } + if (pathOwners.Count > 0) + { + await TryMarkNeedsAttentionAsync( + journal.OperationId, + journal.State, + exactOwners.Count > 1 + ? "Multiple tracked audiobook files claim the anonymous publication target generation." + : "A tracked audiobook file claims the publication path with contradictory generation evidence.", + cancellationToken); + continue; + } + if (journal.State != FileMutationJournalState.Planned + && targetClaims.Count(candidate => + AnonymousTargetGenerationMatches(candidate, journal)) != 1) + { + await TryMarkNeedsAttentionAsync( + journal.OperationId, + journal.State, + "Another anonymous registration journal claims the same published target generation.", + cancellationToken); + continue; + } + + try + { + var outcome = await fileMover.RollbackUncommittedRegistrationAsync( + journal.OperationId); + if (outcome == UncommittedPublicationRollbackOutcome.RolledBack) + { + logger.LogInformation( + "Rolled back orphaned anonymous file-registration publication {OperationId}", + journal.OperationId); + } + } + catch (Exception exception) when ( + IsTransientRecoveryFilesystemException(exception)) + { + logger.LogWarning( + exception, + "Anonymous file-registration publication {OperationId} remains pending because storage is temporarily unavailable", + journal.OperationId); + } + } + } + + private async Task LogRegistrationPublicationSummaryAsync( + CancellationToken cancellationToken) + { + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var publications = await db.FileMutationJournals + .AsNoTracking() + .Where(journal => journal.AudiobookFileId == null + && (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy)) + .Select(journal => new + { + journal.State, + journal.AudiobookId + }) + .ToListAsync(cancellationToken); + var completed = publications.Count(journal => + journal.State == FileMutationJournalState.Completed); + var rolledBack = publications.Count(journal => + journal.State == FileMutationJournalState.RolledBack); + var waitingForCommittedOwner = publications.Count(journal => + journal.AudiobookId.HasValue + && FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable( + journal.State)); + var pendingWithoutOwner = publications.Count(journal => + !journal.AudiobookId.HasValue + && FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable( + journal.State)); + var needsAttention = publications.Count(journal => + FileMutationJournalLifecycle.RequiresOperatorAttention(journal.State)); + + logger.LogInformation( + "Registration publication recovery: {Completed} completed, {RolledBack} rolled back, {WaitingForCommittedOwner} waiting for committed owner recovery, {PendingWithoutOwner} transiently pending without owner, {NeedsAttention} need attention", + completed, + rolledBack, + waitingForCommittedOwner, + pendingWithoutOwner, + needsAttention); + } +} diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs index 1c3f39614..45243ee44 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs @@ -5,15 +5,23 @@ namespace Listenarr.Infrastructure.Persistence; public sealed partial class FileRegistrationRecoveryService { private async Task EnsureCurrentRecoveryProtocolAsync( - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid? operationId = null) { await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var unsupported = await db.FileMutationJournals + var unsupportedQuery = db.FileMutationJournals .AsNoTracking() .Where(journal => journal.ProtocolVersion != FileMutationProtocol.Current && journal.State != FileMutationJournalState.Completed - && journal.State != FileMutationJournalState.OwnerMetadataReconciled) + && journal.State != FileMutationJournalState.RolledBack + && journal.State != FileMutationJournalState.OwnerMetadataReconciled); + if (operationId.HasValue) + { + unsupportedQuery = unsupportedQuery.Where( + journal => journal.OperationId == operationId.Value); + } + var unsupported = await unsupportedQuery .OrderBy(journal => journal.CreatedAt) .ThenBy(journal => journal.OperationId) .Select(journal => new @@ -32,12 +40,19 @@ private async Task EnsureCurrentRecoveryProtocolAsync( "This interrupted file mutation predates durable parent-directory generation fencing and cannot be resumed automatically."; if (!db.Database.IsRelational()) { - var tracked = await db.FileMutationJournals + var trackedQuery = db.FileMutationJournals .Where(journal => journal.ProtocolVersion != FileMutationProtocol.Current && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack && journal.State != FileMutationJournalState.OwnerMetadataReconciled - && journal.State != FileMutationJournalState.NeedsAttention) + && journal.State != FileMutationJournalState.NeedsAttention); + if (operationId.HasValue) + { + trackedQuery = trackedQuery.Where( + journal => journal.OperationId == operationId.Value); + } + var tracked = await trackedQuery .ToListAsync(cancellationToken); foreach (var journal in tracked) { @@ -49,12 +64,19 @@ private async Task EnsureCurrentRecoveryProtocolAsync( } else { - await db.FileMutationJournals + var trackedQuery = db.FileMutationJournals .Where(journal => journal.ProtocolVersion != FileMutationProtocol.Current && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack && journal.State != FileMutationJournalState.OwnerMetadataReconciled - && journal.State != FileMutationJournalState.NeedsAttention) + && journal.State != FileMutationJournalState.NeedsAttention); + if (operationId.HasValue) + { + trackedQuery = trackedQuery.Where( + journal => journal.OperationId == operationId.Value); + } + await trackedQuery .ExecuteUpdateAsync( setters => setters .SetProperty( diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs index ca409f908..1a326768f 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Receipts.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using Listenarr.Domain.Audiobooks.Enumerations; using Microsoft.EntityFrameworkCore; namespace Listenarr.Infrastructure.Persistence; @@ -14,8 +15,9 @@ private async Task AppendDurableCompletedReceiptsAsync( await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); var completedJournals = await db.FileMutationJournals .AsNoTracking() - .Where(RegistrationMoveOwnerPredicate) - .Where(journal => journal.AudiobookId == audiobookId + .Where(RegistrationPublicationOwnerPredicate) + .Where(journal => journal.Action == FileAction.Move + && journal.AudiobookId == audiobookId && journal.State == FileMutationJournalState.Completed) .OrderBy(journal => journal.CreatedAt) .ThenBy(journal => journal.OperationId) diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs index af25b4ea1..23958d677 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.RepairState.cs @@ -17,7 +17,8 @@ private async Task TryMarkNeedsAttentionAsync( .Where(candidate => candidate.OperationId == operationId) .Select(candidate => candidate.State) .SingleAsync(cancellationToken); - if (observedState == FileMutationJournalState.Completed) + if (FileMutationJournalLifecycle.ClearsRegistrationRecoveryBoundary( + observedState)) { return false; } @@ -90,7 +91,7 @@ private async Task ThrowIfNeedsAttentionAsync( private static ApplicationConflictException RecoveryPending(Guid operationId) => new( "registration_recovery_pending", - $"A previously committed file import ({operationId}) is still retiring its original source file. Retry after the source file is no longer in use."); + $"A previously committed file import ({operationId}) is still completing its durable publication. Retry after recovery finishes."); private static ApplicationConflictException RepairRequired(Guid operationId) => new( diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs index 83bb32593..4883c8d53 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.cs @@ -6,9 +6,9 @@ namespace Listenarr.Infrastructure.Persistence; /// -/// Resumes source retirement for Move publications after the destination generation -/// and audiobook ownership were already committed. These journals are deliberately -/// separate from organize/rename recovery because they do not own an AudiobookFileId. +/// Adopts registration publications after audiobook ownership was committed and +/// resumes any remaining source retirement. These journals are separate from +/// organize/rename recovery because they do not own an AudiobookFileId. /// public sealed partial class FileRegistrationRecoveryService( IDbContextFactory dbContextFactory, @@ -21,18 +21,23 @@ public async Task AdoptCommittedAnonymousAsync( CancellationToken cancellationToken = default) { await EnsureCurrentRecoveryProtocolAsync(cancellationToken); - await AdoptCommittedAnonymousMoveRegistrationsAsync( + await AdoptCommittedAnonymousPublicationsAsync( audiobookId: null, + operationId: null, cancellationToken); } public async Task ReconcileAsync(CancellationToken cancellationToken = default) { await AdoptCommittedAnonymousAsync(cancellationToken); + await ReconcileOrphanedAnonymousPublicationsAsync( + operationId: null, + cancellationToken); + await LogRegistrationPublicationSummaryAsync(cancellationToken); await using var readContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); var attentionOperationId = await readContext.FileMutationJournals .AsNoTracking() - .Where(RegistrationMoveOwnerPredicate) + .Where(RegistrationPublicationPredicate) .Where(journal => journal.State == FileMutationJournalState.NeedsAttention) .OrderBy(journal => journal.CreatedAt) .ThenBy(journal => journal.OperationId) @@ -41,12 +46,12 @@ public async Task ReconcileAsync(CancellationToken cancellationToken = default) if (attentionOperationId.HasValue) { throw new InvalidOperationException( - $"File-registration move journal {attentionOperationId.Value} requires operator repair before filesystem mutations can resume."); + $"File-registration publication {attentionOperationId.Value} requires operator repair before filesystem mutations can resume."); } var operationIds = await readContext.FileMutationJournals .AsNoTracking() - .Where(RegistrationMoveOwnerPredicate) + .Where(RegistrationPublicationOwnerPredicate) .Where(journal => journal.State == FileMutationJournalState.TargetVerified || journal.State == FileMutationJournalState.RegistrationCommitted || journal.State == FileMutationJournalState.SourceDeletionAuthorized @@ -89,15 +94,17 @@ public async Task> ArgumentNullException.ThrowIfNull(requestedSourcePaths); await EnsureCurrentRecoveryProtocolAsync(cancellationToken); - await AdoptCommittedAnonymousMoveRegistrationsAsync( + await AdoptCommittedAnonymousPublicationsAsync( audiobookId, + operationId: null, cancellationToken); await using var readContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); var operationIds = await readContext.FileMutationJournals .AsNoTracking() - .Where(RegistrationMoveOwnerPredicate) + .Where(RegistrationPublicationOwnerPredicate) .Where(journal => journal.AudiobookId == audiobookId - && journal.State != FileMutationJournalState.Completed) + && journal.State != FileMutationJournalState.Completed + && journal.State != FileMutationJournalState.RolledBack) .OrderBy(journal => journal.CreatedAt) .ThenBy(journal => journal.OperationId) .Select(journal => journal.OperationId) @@ -129,124 +136,111 @@ await AppendDurableCompletedReceiptsAsync( return receipts; } - private async Task AdoptCommittedAnonymousMoveRegistrationsAsync( - int? audiobookId, - CancellationToken cancellationToken) + public async Task RetryAsync( + Guid operationId, + CancellationToken cancellationToken = default) { - await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var anonymousJournals = await db.FileMutationJournals - .AsNoTracking() - .Where(journal => journal.Action == FileAction.Move - && journal.AudiobookId == null - && journal.AudiobookFileId == null - && journal.State == FileMutationJournalState.TargetVerified) - .OrderBy(journal => journal.CreatedAt) - .ThenBy(journal => journal.OperationId) - .ToListAsync(cancellationToken); - if (anonymousJournals.Count == 0) + if (operationId == Guid.Empty) { - return; + throw new ArgumentException( + "A registration recovery retry requires a non-empty operation ID.", + nameof(operationId)); } - var filesQuery = db.AudiobookFiles.AsNoTracking(); - if (audiobookId.HasValue) + await EnsureCurrentRecoveryProtocolAsync( + cancellationToken, + operationId); + var journal = await LoadRegistrationPublicationAsync( + operationId, + cancellationToken); + if (journal.State == FileMutationJournalState.NeedsAttention) { - filesQuery = filesQuery.Where(file => file.AudiobookId == audiobookId.Value); + return CreateRecoveryStatus(journal); } - var trackedFiles = await filesQuery.ToListAsync(cancellationToken); - foreach (var journal in anonymousJournals) + + if (!FileMutationJournalLifecycle.ClearsRegistrationRecoveryBoundary( + journal.State)) { - cancellationToken.ThrowIfCancellationRequested(); - var matches = trackedFiles - .Where(file => RegisteredPathMatches(file, journal.DestinationPath) - && RegisteredGenerationMatches( - file, - journal.TargetPhysicalObjectIdentity)) - .ToList(); - if (matches.Count == 0) - { - // This is the valid crash-before-metadata-commit state, or an anonymous - // journal owned by another audiobook during scoped recovery. Leave it - // anonymous so its own operation/recovery can resolve it later. - continue; - } - if (anonymousJournals.Count(candidate => - AnonymousTargetGenerationMatches(candidate, journal)) != 1) - { - throw new InvalidOperationException( - $"Anonymous file-registration move journal {journal.OperationId} shares its published target generation with another unowned journal and cannot be adopted safely."); - } - if (matches.Count != 1) + if (!journal.AudiobookId.HasValue) { - throw new InvalidOperationException( - $"Anonymous file-registration move journal {journal.OperationId} matches multiple tracked audiobook files and cannot be adopted safely."); + await AdoptCommittedAnonymousPublicationsAsync( + audiobookId: null, + operationId, + cancellationToken); + await ReconcileOrphanedAnonymousPublicationsAsync( + operationId, + cancellationToken); } - var matchedFile = matches[0]; - var adopted = await TryAdoptAnonymousOwnerAsync( - db, - journal, - matchedFile.AudiobookId, + journal = await LoadRegistrationPublicationAsync( + operationId, cancellationToken); - if (adopted) + if (journal.AudiobookId.HasValue + && FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable( + journal.State)) { - logger.LogInformation( - "Adopted committed anonymous file-registration move {OperationId} for audiobook {AudiobookId}", - journal.OperationId, - matchedFile.AudiobookId); + await ReconcileOperationAsync( + operationId, + failWhenStillPending: false, + cancellationToken); + journal = await LoadRegistrationPublicationAsync( + operationId, + cancellationToken); } } + + return CreateRecoveryStatus(journal); } - private async Task TryAdoptAnonymousOwnerAsync( - ListenArrDbContext db, - FileMutationJournal expected, - int audiobookId, + private async Task LoadRegistrationPublicationAsync( + Guid operationId, CancellationToken cancellationToken) { - var now = timeProvider.GetUtcNow().UtcDateTime; - if (!db.Database.IsRelational()) + await using var db = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var journal = await db.FileMutationJournals + .AsNoTracking() + .SingleOrDefaultAsync( + candidate => candidate.OperationId == operationId, + cancellationToken) + ?? throw new KeyNotFoundException( + "File-registration recovery operation not found."); + if (!IsRegistrationPublicationAction(journal.Action) + || journal.AudiobookFileId.HasValue) { - var tracked = await db.FileMutationJournals.SingleOrDefaultAsync( - candidate => candidate.OperationId == expected.OperationId, - cancellationToken); - if (tracked == null - || tracked.AudiobookId != null - || tracked.AudiobookFileId != null - || tracked.Action != FileAction.Move - || tracked.State != FileMutationJournalState.TargetVerified - || !string.Equals( - tracked.TargetPhysicalObjectIdentity, - expected.TargetPhysicalObjectIdentity, - StringComparison.Ordinal)) - { - return false; - } - - tracked.AudiobookId = audiobookId; - tracked.UpdatedAt = now; - await db.SaveChangesAsync(cancellationToken); - return true; + throw new InvalidOperationException( + "The requested operation is not a file-registration publication."); } - var affected = await db.FileMutationJournals - .Where(candidate => candidate.OperationId == expected.OperationId - && candidate.AudiobookId == null - && candidate.AudiobookFileId == null - && candidate.Action == FileAction.Move - && candidate.State == FileMutationJournalState.TargetVerified - && candidate.TargetPhysicalObjectIdentity - == expected.TargetPhysicalObjectIdentity) - .ExecuteUpdateAsync( - setters => setters - .SetProperty( - candidate => candidate.AudiobookId, - audiobookId) - .SetProperty( - candidate => candidate.UpdatedAt, - now), - cancellationToken); - return affected == 1; + return journal; + } + + private static FileRegistrationRecoveryStatus CreateRecoveryStatus( + FileMutationJournal journal) + { + var needsAttention = FileMutationJournalLifecycle + .RequiresOperatorAttention(journal.State); + var recoverable = FileMutationJournalLifecycle + .IsRegistrationPublicationRecoverable(journal.State); + var cleared = FileMutationJournalLifecycle + .ClearsRegistrationRecoveryBoundary(journal.State); + return new FileRegistrationRecoveryStatus( + journal.OperationId, + journal.State, + journal.AudiobookId, + cleared + ? FileRegistrationRecoveryDisposition.Cleared + : needsAttention || !recoverable + ? FileRegistrationRecoveryDisposition.RequiresOperatorAttention + : journal.AudiobookId.HasValue + ? FileRegistrationRecoveryDisposition.WaitingForOwnerRetry + : FileRegistrationRecoveryDisposition.AutomaticRecovery, + CanRetry: recoverable, + CanAbandon: false, + cleared + ? "The file-registration recovery boundary is clear." + : needsAttention || !recoverable + ? "This publication still requires operator repair; no destructive action was authorized." + : "Recovery remains pending because its durable evidence could not yet be reconciled."); } private async Task ReconcileOperationAsync( @@ -262,7 +256,8 @@ private async Task TryAdoptAnonymousOwnerAsync( journal = await db.FileMutationJournals .AsNoTracking() .SingleAsync(candidate => candidate.OperationId == operationId, cancellationToken); - if (journal.State == FileMutationJournalState.Completed) + if (FileMutationJournalLifecycle.ClearsRegistrationRecoveryBoundary( + journal.State)) { return null; } @@ -270,8 +265,9 @@ private async Task TryAdoptAnonymousOwnerAsync( { throw RepairRequired(operationId); } - if (!IsRegistrationMoveOwner(journal) - || journal.State < FileMutationJournalState.TargetVerified + if (!IsRegistrationPublicationOwner(journal) + || !FileMutationJournalLifecycle.IsRegistrationPublicationRecoverable( + journal.State) || !journal.AudiobookId.HasValue) { return null; @@ -320,7 +316,7 @@ private async Task TryAdoptAnonymousOwnerAsync( { preparedLease = !string.IsNullOrWhiteSpace(journal.SourceSha256) ? await fileMover.PrepareActionForRegistrationAsync( - FileAction.Move, + journal.Action, journal.SourcePath, journal.DestinationPath, journal.OperationId, @@ -330,7 +326,7 @@ private async Task TryAdoptAnonymousOwnerAsync( journal.SourceLength, journal.SourceSha256)) : await fileMover.PrepareActionForRegistrationAsync( - FileAction.Move, + journal.Action, journal.SourcePath, journal.DestinationPath, journal.OperationId, @@ -363,11 +359,12 @@ private async Task TryAdoptAnonymousOwnerAsync( if (!lease.PrepareCleanupRecovery(audiobookId) || lease.CompletePublication() == RegistrationPublicationCompletion.CommittedCleanupPending - || !await fileMover.CompletePreparedMoveAsync( - journal.SourcePath, - journal.DestinationPath, - lease, - journal.OperationId)) + || (journal.Action == FileAction.Move + && !await fileMover.CompletePreparedMoveAsync( + journal.SourcePath, + journal.DestinationPath, + lease, + journal.OperationId))) { await ThrowIfNeedsAttentionAsync(operationId, cancellationToken); if (failWhenStillPending) @@ -378,14 +375,16 @@ private async Task TryAdoptAnonymousOwnerAsync( } logger.LogInformation( - "Recovered committed file-registration move {OperationId} for audiobook {AudiobookId}", + "Recovered committed file-registration publication {OperationId} for audiobook {AudiobookId}", operationId, audiobookId); - return new FileRegistrationRecoveryReceipt( - journal.OperationId, - audiobookId, - journal.SourcePath, - journal.DestinationPath); + return journal.Action == FileAction.Move + ? new FileRegistrationRecoveryReceipt( + journal.OperationId, + audiobookId, + journal.SourcePath, + journal.DestinationPath) + : null; } private static bool IsTransientRecoveryFilesystemException(Exception exception) @@ -407,7 +406,11 @@ private static bool AnonymousTargetGenerationMatches( FileMutationJournal left, FileMutationJournal right) { - if (string.IsNullOrWhiteSpace(left.TargetPhysicalObjectIdentity) + if (!string.Equals( + left.DestinationPath, + right.DestinationPath, + StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(left.TargetPhysicalObjectIdentity) || string.IsNullOrWhiteSpace(right.TargetPhysicalObjectIdentity)) { return false; @@ -467,15 +470,26 @@ ArgumentException or InvalidOperationException } } - private static bool IsRegistrationMoveOwner(FileMutationJournal journal) => - journal.Action == FileAction.Move + private static bool IsRegistrationPublicationAction(FileAction action) => + action is FileAction.Move or FileAction.Copy or FileAction.HardlinkCopy; + + private static bool IsRegistrationPublicationOwner(FileMutationJournal journal) => + IsRegistrationPublicationAction(journal.Action) && journal.AudiobookId != null && journal.AudiobookFileId == null; private static System.Linq.Expressions.Expression> - RegistrationMoveOwnerPredicate => journal => - journal.Action == FileAction.Move - && journal.AudiobookId != null + RegistrationPublicationOwnerPredicate => journal => + (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) + && journal.AudiobookId != null && journal.AudiobookFileId == null; + + private static System.Linq.Expressions.Expression> + RegistrationPublicationPredicate => journal => + (journal.Action == FileAction.Move + || journal.Action == FileAction.Copy + || journal.Action == FileAction.HardlinkCopy) && journal.AudiobookFileId == null; } diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs index 0bf2f2590..7b9947390 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.BasePathRegistration.cs @@ -252,6 +252,82 @@ public async Task ReplacePhysicalGenerationWithBasePathAsync( return true; } + public async Task RestorePhysicalGenerationWithBasePathAsync( + int fileId, + int audiobookId, + string? expectedPath, + string? expectedPhysicalObjectIdentity, + AudiobookFilePhysicalGenerationSnapshot predecessor, + AudiobookBasePathMutation basePathMutation, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(predecessor); + ValidateBasePathMutation(audiobookId, basePathMutation); + + if (!_db.Database.IsRelational()) + { + var audiobook = await _db.Audiobooks.SingleOrDefaultAsync(candidate => candidate.Id == audiobookId, ct); + var existing = await _db.AudiobookFiles.SingleOrDefaultAsync( + candidate => candidate.Id == fileId + && candidate.AudiobookId == audiobookId + && candidate.Path == expectedPath + && candidate.PhysicalObjectIdentity == expectedPhysicalObjectIdentity, + ct); + if (audiobook == null || existing == null + || !string.Equals(audiobook.BasePath, basePathMutation.ExpectedCurrentBasePath, StringComparison.Ordinal)) + { + return false; + } + audiobook.BasePath = basePathMutation.ResultingBasePath; + ApplyPhysicalGenerationSnapshot(existing, predecessor); + var nonRelationalCompletionToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); + return true; + } + + await using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + var basePathUpdated = await _db.Audiobooks + .Where(candidate => candidate.Id == audiobookId + && candidate.BasePath == basePathMutation.ExpectedCurrentBasePath) + .ExecuteUpdateAsync( + setters => setters.SetProperty(candidate => candidate.BasePath, basePathMutation.ResultingBasePath), + ct); + if (basePathUpdated != 1) + { + await transaction.RollbackAsync(CancellationToken.None); + return false; + } + var fileUpdated = await _db.AudiobookFiles + .Where(candidate => candidate.Id == fileId + && candidate.AudiobookId == audiobookId + && candidate.Path == expectedPath + && candidate.PhysicalObjectIdentity == expectedPhysicalObjectIdentity) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(candidate => candidate.Size, predecessor.Size) + .SetProperty(candidate => candidate.DurationSeconds, predecessor.DurationSeconds) + .SetProperty(candidate => candidate.Format, predecessor.Format) + .SetProperty(candidate => candidate.Container, predecessor.Container) + .SetProperty(candidate => candidate.Codec, predecessor.Codec) + .SetProperty(candidate => candidate.Bitrate, predecessor.Bitrate) + .SetProperty(candidate => candidate.SampleRate, predecessor.SampleRate) + .SetProperty(candidate => candidate.Channels, predecessor.Channels) + .SetProperty(candidate => candidate.Source, predecessor.Source) + .SetProperty(candidate => candidate.PhysicalObjectIdentity, predecessor.PhysicalObjectIdentity) + .SetProperty(candidate => candidate.PhysicalIdentityVersion, predecessor.PhysicalIdentityVersion) + .SetProperty(candidate => candidate.PhysicalIdentityObservedAtUtc, predecessor.PhysicalIdentityObservedAtUtc), + ct); + if (fileUpdated != 1) + { + await transaction.RollbackAsync(CancellationToken.None); + return false; + } + var completionToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await transaction.CommitAsync(completionToken); + SynchronizeTrackedBasePath(basePathMutation); + SynchronizeTrackedPhysicalGeneration(fileId, predecessor); + return true; + } public async Task DeletePhysicalGenerationWithBasePathAsync( int fileId, int audiobookId, diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.MetadataRefresh.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.MetadataRefresh.cs new file mode 100644 index 000000000..00bbccac8 --- /dev/null +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.MetadataRefresh.cs @@ -0,0 +1,92 @@ +using Listenarr.Domain.Common; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Infrastructure.Persistence.Repositories; + +public partial class EfAudiobookFileRepository +{ + public async Task RefreshMetadataAsync( + AudiobookFileMetadataRefreshSnapshot expectedFile, + AudioMetadata metadata, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(expectedFile); + ArgumentNullException.ThrowIfNull(metadata); + if (expectedFile.PathState.State != PathIdentityState.Valid + || string.IsNullOrWhiteSpace(expectedFile.PathState.OwnershipKey)) + { + return false; + } + + var query = _db.AudiobookFiles.Where(file => + file.Id == expectedFile.FileId + && file.AudiobookId == expectedFile.AudiobookId + && file.Path == expectedFile.PathState.StoredPath + && file.CanonicalPath == expectedFile.PathState.CanonicalPath + && file.PathSyntax == expectedFile.PathState.Syntax + && file.PathCaseSensitivity == expectedFile.PathState.CaseSensitivity + && file.PathCaseSensitivityMode == expectedFile.PathState.RequestedMode + && file.PathIdentityBoundary == expectedFile.PathState.BoundaryPath + && file.PathIdentityLookupKey == expectedFile.PathState.LookupKey + && file.PathOwnershipKey == expectedFile.PathState.OwnershipKey + && file.PathIdentityVersion == expectedFile.PathState.Version + && file.PathIdentityState == expectedFile.PathState.State + && file.PhysicalObjectIdentity == expectedFile.PhysicalObjectIdentity + && file.PhysicalIdentityVersion == expectedFile.PhysicalIdentityVersion + && file.PhysicalIdentityObservedAtUtc == expectedFile.PhysicalIdentityObservedAtUtc + && file.Audiobook != null + && file.Audiobook.BasePath == expectedFile.BasePath); + var duration = metadata.Duration.TotalSeconds; + if (_db.Database.IsRelational()) + { + var completionToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); + var updated = await query.ExecuteUpdateAsync( + setters => setters + .SetProperty(file => file.DurationSeconds, file => + duration > 0 ? duration : file.DurationSeconds) + .SetProperty(file => file.Format, file => + !string.IsNullOrEmpty(metadata.Format) ? metadata.Format : file.Format) + .SetProperty(file => file.Container, file => + !string.IsNullOrEmpty(metadata.Container) ? metadata.Container : file.Container) + .SetProperty(file => file.Codec, file => + !string.IsNullOrEmpty(metadata.Codec) ? metadata.Codec : file.Codec) + .SetProperty(file => file.Bitrate, file => + metadata.BitRate > 0 ? metadata.BitRate : file.Bitrate) + .SetProperty(file => file.SampleRate, file => + metadata.SampleRate > 0 ? metadata.SampleRate : file.SampleRate) + .SetProperty(file => file.Channels, file => + metadata.Channels > 0 ? metadata.Channels : file.Channels), + completionToken); + if (updated != 1) + { + return false; + } + + // ExecuteUpdate bypasses tracking. Discard only this now-stale snapshot, + // so later reads/saves cannot put its old metadata back. + var tracked = _db.ChangeTracker.Entries() + .FirstOrDefault(entry => entry.Entity.Id == expectedFile.FileId); + if (tracked != null) + { + tracked.State = EntityState.Detached; + } + return true; + } + + var existing = await query.SingleOrDefaultAsync(ct); + if (existing == null) + { + return false; + } + existing.DurationSeconds = duration > 0 ? duration : existing.DurationSeconds; + existing.Format = !string.IsNullOrEmpty(metadata.Format) ? metadata.Format : existing.Format; + existing.Container = !string.IsNullOrEmpty(metadata.Container) ? metadata.Container : existing.Container; + existing.Codec = !string.IsNullOrEmpty(metadata.Codec) ? metadata.Codec : existing.Codec; + existing.Bitrate = metadata.BitRate > 0 ? metadata.BitRate : existing.Bitrate; + existing.SampleRate = metadata.SampleRate > 0 ? metadata.SampleRate : existing.SampleRate; + existing.Channels = metadata.Channels > 0 ? metadata.Channels : existing.Channels; + var nonRelationalCompletionToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); + return true; + } +} diff --git a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs index a37dae9f7..fe4a277ef 100644 --- a/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs +++ b/listenarr.infrastructure/Persistence/Repositories/EfAudiobookFileRepository.PhysicalGeneration.cs @@ -4,6 +4,56 @@ namespace Listenarr.Infrastructure.Persistence.Repositories; public partial class EfAudiobookFileRepository { + public async Task RestorePhysicalGenerationAsync( + int fileId, + int audiobookId, + string? expectedPath, + string? expectedPhysicalObjectIdentity, + AudiobookFilePhysicalGenerationSnapshot predecessor, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(predecessor); + var query = _db.AudiobookFiles.Where(candidate => + candidate.Id == fileId + && candidate.AudiobookId == audiobookId + && candidate.Path == expectedPath + && candidate.PhysicalObjectIdentity == expectedPhysicalObjectIdentity); + if (_db.Database.IsRelational()) + { + var completionToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); + var updated = await query.ExecuteUpdateAsync( + setters => setters + .SetProperty(candidate => candidate.Size, predecessor.Size) + .SetProperty(candidate => candidate.DurationSeconds, predecessor.DurationSeconds) + .SetProperty(candidate => candidate.Format, predecessor.Format) + .SetProperty(candidate => candidate.Container, predecessor.Container) + .SetProperty(candidate => candidate.Codec, predecessor.Codec) + .SetProperty(candidate => candidate.Bitrate, predecessor.Bitrate) + .SetProperty(candidate => candidate.SampleRate, predecessor.SampleRate) + .SetProperty(candidate => candidate.Channels, predecessor.Channels) + .SetProperty(candidate => candidate.Source, predecessor.Source) + .SetProperty(candidate => candidate.PhysicalObjectIdentity, predecessor.PhysicalObjectIdentity) + .SetProperty(candidate => candidate.PhysicalIdentityVersion, predecessor.PhysicalIdentityVersion) + .SetProperty(candidate => candidate.PhysicalIdentityObservedAtUtc, predecessor.PhysicalIdentityObservedAtUtc), + completionToken); + if (updated != 1) + { + return false; + } + SynchronizeTrackedPhysicalGeneration(fileId, predecessor); + return true; + } + + var existing = await query.SingleOrDefaultAsync(ct); + if (existing == null) + { + return false; + } + ApplyPhysicalGenerationSnapshot(existing, predecessor); + var nonRelationalCompletionToken = RequestCancellationBoundary.EnterNonCancelablePhase(ct); + await _db.SaveChangesAsync(nonRelationalCompletionToken); + return true; + } public async Task ReplacePhysicalGenerationAsync( int fileId, int audiobookId, @@ -155,6 +205,57 @@ void Synchronize(string propertyName, object? value) } } + private void SynchronizeTrackedPhysicalGeneration( + int fileId, + AudiobookFilePhysicalGenerationSnapshot snapshot) + { + var trackedEntry = _db.ChangeTracker.Entries() + .FirstOrDefault(entry => entry.Entity.Id == fileId); + if (trackedEntry == null) + { + return; + } + + Synchronize(nameof(AudiobookFile.Size), snapshot.Size); + Synchronize(nameof(AudiobookFile.DurationSeconds), snapshot.DurationSeconds); + Synchronize(nameof(AudiobookFile.Format), snapshot.Format); + Synchronize(nameof(AudiobookFile.Container), snapshot.Container); + Synchronize(nameof(AudiobookFile.Codec), snapshot.Codec); + Synchronize(nameof(AudiobookFile.Bitrate), snapshot.Bitrate); + Synchronize(nameof(AudiobookFile.SampleRate), snapshot.SampleRate); + Synchronize(nameof(AudiobookFile.Channels), snapshot.Channels); + Synchronize(nameof(AudiobookFile.Source), snapshot.Source); + Synchronize(nameof(AudiobookFile.PhysicalObjectIdentity), snapshot.PhysicalObjectIdentity); + Synchronize(nameof(AudiobookFile.PhysicalIdentityVersion), snapshot.PhysicalIdentityVersion); + Synchronize(nameof(AudiobookFile.PhysicalIdentityObservedAtUtc), snapshot.PhysicalIdentityObservedAtUtc); + + void Synchronize(string propertyName, object? value) + { + var property = trackedEntry.Property(propertyName); + property.CurrentValue = value; + property.OriginalValue = value; + property.IsModified = false; + } + } + + private void ApplyPhysicalGenerationSnapshot( + AudiobookFile target, + AudiobookFilePhysicalGenerationSnapshot snapshot) + { + target.Size = snapshot.Size; + target.DurationSeconds = snapshot.DurationSeconds; + target.Format = snapshot.Format; + target.Container = snapshot.Container; + target.Codec = snapshot.Codec; + target.Bitrate = snapshot.Bitrate; + target.SampleRate = snapshot.SampleRate; + target.Channels = snapshot.Channels; + target.Source = snapshot.Source; + var entry = _db.Entry(target); + entry.Property(nameof(AudiobookFile.PhysicalObjectIdentity)).CurrentValue = snapshot.PhysicalObjectIdentity; + entry.Property(nameof(AudiobookFile.PhysicalIdentityVersion)).CurrentValue = snapshot.PhysicalIdentityVersion; + entry.Property(nameof(AudiobookFile.PhysicalIdentityObservedAtUtc)).CurrentValue = snapshot.PhysicalIdentityObservedAtUtc; + } private static void ApplyPhysicalGeneration( AudiobookFile target, AudiobookFile source) diff --git a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs index 648332687..3349ae903 100644 --- a/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs +++ b/tests/Features/Api/Features/Library/LibraryController_DeleteFilesystemTests.cs @@ -208,6 +208,64 @@ public async Task DeleteAudiobook_UnverifiedTrackedGeneration_BlocksBeforeDeleti intent => intent.AudiobookId == audiobook.Id); } + [Fact] + public async Task DeleteAudiobook_LegacyWeakTrackedGeneration_BlocksBeforeDeletionIntent() + { + var rootPath = FileService.GetTempDirectory( + "listenarr-delete-legacy-weak-root"); + var root = new RootFolderBuilder() + .WithName("Legacy Weak Delete Root") + .WithPath(rootPath) + .WithIsDefault() + .Build(); + await AddAuthorizedRootAsync(root); + var bookFolder = Path.Join(rootPath, "Author", "Book"); + Directory.CreateDirectory(bookFolder); + var filePath = await FileService.GetFileAsync( + bookFolder, + "book.m4b", + "audio"); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Legacy weak delete") + .WithBasePath(bookFolder) + .WithFilePath(filePath) + .Build()); + await AddTrackedGenerationAsync( + audiobook, + filePath, + _ => "linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000"); + + var controller = _provider.GetRequiredService(); + var capabilitiesResult = await controller.GetDeleteCapabilities( + audiobook.Id); + var capabilities = Assert.IsType( + Assert.IsType(capabilitiesResult).Value); + Assert.False(capabilities.CanDeleteTrackedFiles); + Assert.False(capabilities.CanDeleteFolder); + Assert.Equal("RemoveFromLibraryOnly", capabilities.FallbackAction); + + var result = await controller.DeleteAudiobook( + audiobook.Id, + deleteFiles: true, + deleteFolder: true); + + var conflict = Assert.IsType(result); + var payload = System.Text.Json.JsonSerializer.Serialize(conflict.Value); + Assert.Contains( + "delete_source_unverified", + payload, + StringComparison.Ordinal); + Assert.True(File.Exists(filePath)); + Assert.NotNull(await _audiobookRepository.GetByIdAsync(audiobook.Id)); + await using var db = await _provider + .GetRequiredService>() + .CreateDbContextAsync(); + Assert.DoesNotContain( + db.AudiobookDeletionIntents, + intent => intent.AudiobookId == audiobook.Id); + } + [Fact] public async Task DeleteAudiobook_ExistingPlannedIntentWithUnverifiedTrackedGeneration_ReconcilesAndResumes() { @@ -1925,6 +1983,48 @@ await AddAuthorizedRootAsync(new RootFolderBuilder() || warning.Contains("generation", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task FilesystemDelete_LegacyWeakTrackedGeneration_BlocksDirectServiceDelete() + { + var tempRoot = FileService.GetTempDirectory( + "listenarr-delete-legacy-weak-direct"); + var bookFolder = Path.Join(tempRoot, "Book"); + var audioPath = Path.Join(bookFolder, "book.mp3"); + Directory.CreateDirectory(bookFolder); + await File.WriteAllTextAsync(audioPath, "owned audio"); + await AddAuthorizedRootAsync(new RootFolderBuilder() + .WithName("Library") + .WithPath(tempRoot) + .WithCaseSensitivityMode(FileSystemCaseSensitivityMode.Auto) + .WithIsDefault() + .Build()); + var audiobook = await _audiobookRepository.AddAsync( + new AudiobookBuilder() + .WithTitle("Legacy Weak Direct Delete") + .WithBasePath(bookFolder) + .WithFilePath(audioPath) + .Build()); + await AddTrackedGenerationAsync( + audiobook, + audioPath, + _ => "linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000"); + var snapshot = await _audiobookRepository.GetByIdSnapshotAsync( + audiobook.Id); + Assert.NotNull(snapshot); + + var result = await _provider + .GetRequiredService() + .DeleteAsync(snapshot!, deleteFolder: true); + + Assert.False(result.TrackedFileCleanupComplete); + Assert.Equal(0, result.DeletedFiles); + Assert.True(File.Exists(audioPath)); + Assert.True(Directory.Exists(bookFolder)); + Assert.Contains(result.Warnings, warning => + warning.Contains("durable", StringComparison.OrdinalIgnoreCase) + && warning.Contains("generation", StringComparison.OrdinalIgnoreCase)); + } + [Fact] public async Task FilesystemDelete_TrackedFileWithoutPhysicalIdentity_ReplacedBeforeDelete_PreservesReplacement() { diff --git a/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceMetadataRefreshTests.cs b/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceMetadataRefreshTests.cs new file mode 100644 index 000000000..e7acb2491 --- /dev/null +++ b/tests/Features/Application/Audiobooks/Files/AudiobookFileServiceMetadataRefreshTests.cs @@ -0,0 +1,307 @@ +using Listenarr.Infrastructure.Persistence.Repositories; +using Microsoft.Data.Sqlite; +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; + +namespace Listenarr.Tests.Features.Application.Audiobooks.Files; + +[Trait("Name", "AudiobookFileServiceMetadataRefreshTests")] +[Trait("Category", "Application")] +public sealed class AudiobookFileServiceMetadataRefreshTests : BaseTests +{ + [Theory] + [InlineData(null)] + [InlineData("legacy-physical-evidence")] + public async Task RefreshMetadataAsync_ReadOnlyLease_PreservesAllOwnershipAndPhysicalEvidence(string? physicalIdentity) + { + // Given a valid tracked path with partial metadata and a read-only lease. + var (audiobook, file, lease) = await CreateFixtureAsync(physicalIdentity); + using (lease) + { + // When metadata is refreshed without durable-generation authority. + var service = _provider.GetRequiredService(); + Assert.True(await service.RefreshMetadataAsync(audiobook, file.Id, lease)); + + // Then the ownership/physical identity snapshot is unchanged. + var persisted = await ReloadFileAsync(file.Id); + Assert.Equal(file.CapturePathState(), persisted.CapturePathState()); + Assert.Equal(file.PhysicalObjectIdentity, persisted.PhysicalObjectIdentity); + Assert.Equal(file.PhysicalIdentityVersion, persisted.PhysicalIdentityVersion); + Assert.Equal(file.PhysicalIdentityObservedAtUtc, persisted.PhysicalIdentityObservedAtUtc); + Assert.Equal(file.Source, persisted.Source); + Assert.Equal(file.Size, persisted.Size); + Assert.Equal(222, persisted.DurationSeconds); + Assert.Equal("refreshed-format", persisted.Format); + Assert.Equal(48000, persisted.SampleRate); + Assert.Equal("existing-codec", persisted.Codec); + Assert.Equal(64000, persisted.Bitrate); + Assert.Equal(2, persisted.Channels); + await Assert.ThrowsAsync(() => service.RefreshPhysicalGenerationAsync( + audiobook, file.Id, physicalIdentity, lease)); + } + } + + [Theory] + [InlineData("path")] + [InlineData("base-path")] + [InlineData("owner")] + [InlineData("identity-state")] + [InlineData("physical-identity")] + [InlineData("publication")] + [InlineData("cancellation")] + public async Task RefreshMetadataAsync_StateChangesDuringExtraction_DoesNotApplyStaleMetadata(string mutation) + { + // Given extraction interrupted by a change outside the operation lock. + using var cancellation = new CancellationTokenSource(); + var publicationMatches = true; + Audiobook? audiobook = null; + AudiobookFile? file = null; + var fixture = await CreateFixtureAsync(null, async () => + { + if (mutation == "publication") + { + publicationMatches = false; + return; + } + if (mutation == "cancellation") + { + cancellation.Cancel(); + return; + } + var factory = _provider.GetRequiredService>(); + await using var context = await factory.CreateDbContextAsync(); + var row = await context.AudiobookFiles.SingleAsync(candidate => candidate.Id == file!.Id); + switch (mutation) + { + case "path": + row.Path = Path.Join(audiobook!.BasePath!, "changed.m4b"); + break; + case "base-path": + var owner = await context.Audiobooks.SingleAsync(candidate => candidate.Id == audiobook!.Id); + owner.BasePath = Path.Join(audiobook!.BasePath!, "changed"); + break; + case "owner": + var other = new AudiobookBuilder().WithTitle("Other Owner").Build(); + context.Audiobooks.Add(other); + await context.SaveChangesAsync(); + row.AudiobookId = other.Id; + break; + case "identity-state": + row.PreparePathIdentityReconciliation("Concurrent identity repair"); + break; + case "physical-identity": + row.ApplyPhysicalObjectIdentity("new-physical-evidence", DateTime.UtcNow); + break; + } + await context.SaveChangesAsync(); + }, () => publicationMatches); + audiobook = fixture.Audiobook; + file = fixture.File; + using (fixture.Lease) + { + // When the refresh attempts to commit the extracted result. + var service = _provider.GetRequiredService(); + if (mutation == "cancellation") + { + await Assert.ThrowsAnyAsync(() => + service.RefreshMetadataAsync(audiobook, file.Id, fixture.Lease, cancellation.Token)); + } + else + { + Assert.False(await service.RefreshMetadataAsync(audiobook, file.Id, fixture.Lease)); + } + + // Then the newer state is preserved and old metadata remains unchanged. + var persisted = await ReloadFileAsync(file.Id); + Assert.Null(persisted.DurationSeconds); + Assert.Null(persisted.Format); + Assert.Null(persisted.SampleRate); + Assert.Equal("existing-codec", persisted.Codec); + } + } + + [Theory] + [InlineData(null)] + [InlineData("legacy-physical-evidence")] + public async Task RefreshMetadataAsync_SqliteReload_PreservesExistingPhysicalEvidence(string? physicalIdentity) + { + var fixture = await CreateFixtureAsync(physicalIdentity); + using (fixture.Lease) + await using (var connection = new SqliteConnection("Data Source=:memory:")) + { + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var context = new ListenArrDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Audiobooks.Add(fixture.Audiobook); + context.AudiobookFiles.Add(fixture.File); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + var reloaded = await context.AudiobookFiles.SingleAsync(); + if (physicalIdentity != null) + { + Assert.Equal(DateTimeKind.Unspecified, reloaded.PhysicalIdentityObservedAtUtc!.Value.Kind); + } + var repository = new EfAudiobookFileRepository(context); + var service = ActivatorUtilities.CreateInstance(_provider, repository); + + Assert.True(await service.RefreshMetadataAsync(fixture.Audiobook, reloaded.Id, fixture.Lease)); + + context.ChangeTracker.Clear(); + var persisted = await context.AudiobookFiles.SingleAsync(); + Assert.Equal(222, persisted.DurationSeconds); + Assert.Equal("refreshed-format", persisted.Format); + Assert.Equal("existing-codec", persisted.Codec); + Assert.Equal(64000, persisted.Bitrate); + Assert.Equal(fixture.File.CapturePathState(), persisted.CapturePathState()); + Assert.Equal(fixture.File.PhysicalObjectIdentity, persisted.PhysicalObjectIdentity); + Assert.Equal(fixture.File.PhysicalIdentityObservedAtUtc, persisted.PhysicalIdentityObservedAtUtc); + } + } + + [Theory] + [InlineData("path")] + [InlineData("base-path")] + [InlineData("identity-state")] + [InlineData("physical-identity")] + [InlineData("deleted")] + public async Task RefreshMetadataAsync_SqliteConcurrentChange_RejectsStaleSnapshot(string mutation) + { + DbContextOptions? options = null; + var fixture = await CreateFixtureAsync(null, async () => + { + await using var competing = new ListenArrDbContext(options!); + var row = await competing.AudiobookFiles.SingleAsync(); + switch (mutation) + { + case "path": + row.Path += ".changed.m4b"; + break; + case "base-path": + (await competing.Audiobooks.SingleAsync()).BasePath += "-changed"; + break; + case "identity-state": + row.PreparePathIdentityReconciliation("Concurrent repair"); + break; + case "physical-identity": + row.ApplyPhysicalObjectIdentity("new-evidence", DateTime.UtcNow); + break; + case "deleted": + competing.AudiobookFiles.Remove(row); + break; + } + await competing.SaveChangesAsync(); + }); + using (fixture.Lease) + await using (var connection = new SqliteConnection("Data Source=:memory:")) + { + await connection.OpenAsync(); + options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using var context = new ListenArrDbContext(options); + await context.Database.EnsureCreatedAsync(); + context.Audiobooks.Add(fixture.Audiobook); + context.AudiobookFiles.Add(fixture.File); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + var service = ActivatorUtilities.CreateInstance( + _provider, new EfAudiobookFileRepository(context)); + + Assert.False(await service.RefreshMetadataAsync(fixture.Audiobook, fixture.File.Id, fixture.Lease)); + + context.ChangeTracker.Clear(); + var persisted = await context.AudiobookFiles.SingleOrDefaultAsync(); + if (mutation == "deleted") + { + Assert.Null(persisted); + } + else + { + Assert.NotNull(persisted); + Assert.Null(persisted.DurationSeconds); + Assert.Null(persisted.Format); + Assert.Null(persisted.SampleRate); + Assert.Equal("existing-codec", persisted.Codec); + } + } + } + + [LinuxFact] + public async Task RefreshMetadataAsync_IdenticalContentReplacement_PreservesReplacementAndRejectsStaleRead() + { + string? path = null; + string? displaced = null; + var fixture = await CreateFixtureAsync(null, async () => + { + displaced = path + ".original"; + File.Move(path!, displaced); + await File.WriteAllTextAsync(path!, "metadata test audio"); + }); + path = fixture.Lease.PublicPath; + fixture.Lease.Dispose(); + using var parent = PinnedDirectoryCreation.OpenPinnedHierarchyNoFollow( + Path.GetDirectoryName(path)!, createMissing: false); + using var lease = PinnedAudiobookFileRegistrationLease.CreatePinnedPathOnly( + parent.OpenExistingFileForStableRead(Path.GetFileName(path)), path); + + Assert.False(await _provider.GetRequiredService() + .RefreshMetadataAsync(fixture.Audiobook, fixture.File.Id, lease)); + + Assert.Equal("metadata test audio", await File.ReadAllTextAsync(path)); + Assert.Equal("metadata test audio", await File.ReadAllTextAsync(displaced!)); + Assert.Null((await ReloadFileAsync(fixture.File.Id)).DurationSeconds); + Assert.Null((await ReloadFileAsync(fixture.File.Id)).PhysicalObjectIdentity); + } + + private async Task<(Audiobook Audiobook, AudiobookFile File, IAudiobookFileRegistrationLease Lease)> + CreateFixtureAsync(string? physicalIdentity, Func? duringExtraction = null, Func? publicationMatches = null) + { + var metadataService = new Mock(MockBehavior.Strict); + metadataService.Setup(service => service.ExtractFileMetadataAsync(It.IsAny())) + .Returns(async () => + { + if (duringExtraction != null) + { + await duringExtraction(); + } + return new AudioMetadata + { + Duration = TimeSpan.FromSeconds(222), + Format = "refreshed-format", + SampleRate = 48000 + }; + }); + Init(builder => builder.WithSingleton(metadataService.Object)); + var path = await FileService.GetFileAsync( + FileService.GetTempDirectory("metadata-only-refresh"), "book.m4b", "metadata test audio"); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Metadata Only Refresh").WithBasePath(Path.GetDirectoryName(path)!).Build()); + var identity = await _provider.GetRequiredService() + .ResolveAsync(audiobook, path); + Assert.Equal(PathIdentityState.Valid, identity.State); + var file = new AudiobookFileBuilder().WithAudiobook(audiobook).WithPath(path).Build(); + file.ApplyPathIdentity(path, identity); + file.Codec = "existing-codec"; + file.Bitrate = 64000; + file.Channels = 2; + if (physicalIdentity != null) + { + file.ApplyPhysicalObjectIdentity(physicalIdentity, DateTime.UtcNow); + } + file = await _audiobookFileRepository.AddAsync(file); + var lease = new Mock(MockBehavior.Strict); + lease.SetupGet(value => value.PublicPath).Returns(path); + lease.SetupGet(value => value.MetadataPath).Returns(path); + lease.SetupGet(value => value.HasDurablePhysicalObjectIdentity).Returns(false); + lease.Setup(value => value.MatchesCurrentPublication()).Returns(() => publicationMatches?.Invoke() ?? true); + lease.Setup(value => value.Dispose()); + return (audiobook, file, lease.Object); + } + + private async Task ReloadFileAsync(int id) + { + var factory = _provider.GetRequiredService>(); + await using var context = await factory.CreateDbContextAsync(); + return await context.AudiobookFiles.AsNoTracking().SingleAsync(file => file.Id == id); + } +} diff --git a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs index 6016ae183..1ac3b16ce 100644 --- a/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs +++ b/tests/Features/Application/Audiobooks/RootFolders/RootFolderServiceTests.cs @@ -1302,12 +1302,24 @@ public async Task Delete_AnonymousRegistrationRecoveryTouchesRoot_BlocksRemoval( It.IsAny())) .ReturnsAsync([]); var registrationRecovery = new Mock(); + var blockerOperationId = Guid.NewGuid(); registrationRecovery - .Setup(probe => probe.HasBlockingBoundaryAsync( + .Setup(probe => probe.GetBlockingBoundaryAsync( It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(true); + .ReturnsAsync([ + new FileRegistrationRecoveryBlocker( + blockerOperationId, + FileMutationJournalState.TargetVerified, + FileAction.Copy, + AudiobookId: null, + OwnerKind: "anonymous", + SourceTouchesBoundary: false, + DestinationTouchesBoundary: true, + FileRegistrationRecoveryDisposition.AutomaticRecovery, + "This file publication is waiting for restart recovery.") + ]); var service = new RootFolderService( repo, null!, @@ -1317,7 +1329,8 @@ public async Task Delete_AnonymousRegistrationRecoveryTouchesRoot_BlocksRemoval( var exception = await Assert.ThrowsAsync(() => service.DeleteAsync(root.Id)); - Assert.Contains("file-registration recovery", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains(blockerOperationId.ToString(), exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("TargetVerified", exception.Message, StringComparison.Ordinal); await using var verification = new ListenArrDbContext(options); Assert.Single(verification.RootFolders); } diff --git a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs index 48190edb5..15c5e7bcb 100644 --- a/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs +++ b/tests/Features/Infrastructure/FileSystem/DirectoryObjectIdentityResolverTests.cs @@ -354,6 +354,28 @@ public void LinuxIdentity_GenericFidWeakCandidates_PreserveReleasedSpellingsOnly candidates); } + [Theory] + [InlineData("linux:00000008:00000001:0000000000001234:0000000000005678:00009abc")] + [InlineData("linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000")] + [InlineData("linux:00000008:00000001:0000000000001234:0000000000005678:00009abc:fh:00000081:341200000000000000000000")] + public void LinuxIdentity_ReleasedGenericFidSpellings_AreClassifiedAsLegacyWeak( + string identity) + { + Assert.True( + PhysicalObjectIdentitySafety.IsKnownWeak(identity)); + } + + [Theory] + [InlineData("linux-generation:00000008:00000001:0000000000001234:gen:00000002")] + [InlineData("linux-generation:00000008:00000001:0000000000001234:fh:00000001:deadbeef")] + [InlineData("linux-generation:00000008:00000001:0000000000001234:fh:00000081")] + public void LinuxIdentity_StrongOrMalformedSpellings_AreNotLegacyWeak( + string identity) + { + Assert.False( + PhysicalObjectIdentitySafety.IsKnownWeak(identity)); + } + [Fact] public void LinuxIdentity_WithoutBirthTime_UsesStrongAlternativeGenerationEvidence() { @@ -410,6 +432,9 @@ public void LinuxPersistedIdentityEquivalence_RequiresSameStrongGenerationEviden Assert.False(PinnedDirectoryCreation.ArePersistedObjectIdentitiesDurablyEquivalent( birthTimeIdentity, augmented)); + Assert.False(PinnedDirectoryCreation.ArePersistedObjectIdentitiesDurablyEquivalent( + birthTimeIdentity, + birthTimeIdentity)); Assert.True(PinnedDirectoryCreation.ArePersistedObjectIdentitiesDurablyEquivalent( augmented, strong)); diff --git a/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs b/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs index ab0d70637..90d52ecc4 100644 --- a/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs +++ b/tests/Features/Infrastructure/FileSystem/FileMutationJournalStoreTests.cs @@ -403,6 +403,109 @@ await Assert.ThrowsAsync(() => CancellationToken.None)); } + [Fact] + public async Task AdvanceAsync_RollbackCannotClaimCommittedRegistration() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync(CreateClaim(operationId), CancellationToken.None); + await store.AdvanceAsync(operationId, FileMutationJournalState.TargetVerified, + "target-generation", null, null, CancellationToken.None); + await store.AdvanceAsync(operationId, FileMutationJournalState.RegistrationCommitted, + "target-generation", 42, null, CancellationToken.None); + + await Assert.ThrowsAsync(() => + store.AdvanceAsync(operationId, FileMutationJournalState.RollbackAuthorized, + "target-generation", null, null, CancellationToken.None)); + + var persisted = await store.GetAsync(operationId, CancellationToken.None); + Assert.NotNull(persisted); + Assert.Equal(FileMutationJournalState.RegistrationCommitted, persisted.State); + Assert.Equal(42, persisted.AudiobookId); + } + + [Fact] + public async Task AdvanceAsync_RollbackAuthorizationCannotAttachOwner() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync(CreateClaim(operationId), CancellationToken.None); + await store.AdvanceAsync(operationId, + FileMutationJournalState.TargetVerified, + "target-generation", null, null, CancellationToken.None); + + await Assert.ThrowsAsync(() => + store.AdvanceAsync(operationId, + FileMutationJournalState.RollbackAuthorized, + "target-generation", 42, null, CancellationToken.None)); + } + + [Fact] + public async Task AdvanceAsync_RolledBackCannotAttachOwner() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync(CreateClaim(operationId), CancellationToken.None); + + await Assert.ThrowsAsync(() => + store.AdvanceAsync(operationId, + FileMutationJournalState.RolledBack, + targetPhysicalObjectIdentity: null, + audiobookId: 42, + error: "No target was published.", + CancellationToken.None)); + } + + [Fact] + public async Task AdvanceAsync_PlannedRegistrationCanCloseWhenNoTargetWasPublished() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync(CreateClaim(operationId), CancellationToken.None); + + var rolledBack = await store.AdvanceAsync(operationId, + FileMutationJournalState.RolledBack, null, null, + "No target was published.", CancellationToken.None); + + Assert.Equal(FileMutationJournalState.RolledBack, rolledBack.State); + Assert.Null(rolledBack.TargetPhysicalObjectIdentity); + } + + [Fact] + public async Task AdvanceAsync_PersistedTargetGenerationCanAuthorizeRollback() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync(CreateClaim(operationId), CancellationToken.None); + await store.AdvanceAsync(operationId, + FileMutationJournalState.TargetIdentityPersisted, + "target-generation", null, null, CancellationToken.None); + + var authorized = await store.AdvanceAsync(operationId, + FileMutationJournalState.RollbackAuthorized, + "target-generation", null, "Exact target generation proved.", + CancellationToken.None); + + Assert.Equal(FileMutationJournalState.RollbackAuthorized, authorized.State); + } + + [Fact] + public async Task AdvanceAsync_RolledBackRegistrationIsTerminal() + { + var operationId = Guid.NewGuid(); + var store = CreateStore(); + await store.GetOrCreateAsync(CreateClaim(operationId), CancellationToken.None); + await store.AdvanceAsync(operationId, FileMutationJournalState.TargetVerified, + "target-generation", null, null, CancellationToken.None); + await store.AdvanceAsync(operationId, FileMutationJournalState.RollbackAuthorized, + "target-generation", null, null, CancellationToken.None); + await store.AdvanceAsync(operationId, FileMutationJournalState.RolledBack, + "target-generation", null, null, CancellationToken.None); + + await Assert.ThrowsAsync(() => + store.AdvanceAsync(operationId, FileMutationJournalState.NeedsAttention, + "target-generation", null, "stale writer", CancellationToken.None)); + } private EfFileMutationJournalStore CreateStore() => CreateStore(_provider.GetRequiredService< IDbContextFactory>()); diff --git a/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs b/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs index 43617c61f..45385caa4 100644 --- a/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs +++ b/tests/Features/Infrastructure/FileSystem/PinnedAudiobookFileRegistrationLeaseTests.cs @@ -6,6 +6,28 @@ namespace Listenarr.Tests.Features.Infrastructure.FileSystem; [Trait("Category", "FileSystem")] public sealed class PinnedAudiobookFileRegistrationLeaseTests : BaseTests { + [LinuxFact] + public async Task OpenForMetadataRead_LegacyWeakIdentity_UsesPathOnlyLease() + { + var parentPath = FileService.GetTempDirectory( + "registration-lease-legacy-weak-refresh"); + var publicPath = await FileService.GetFileAsync( + parentPath, + "book.m4b", + "metadata generation"); + + using var lease = PinnedAudiobookFileRegistrationLease.OpenForMetadataRead( + publicPath, + "linux-generation:00000008:00000001:0000000000001234:fh:00000081:341200000000000000000000"); + + Assert.False(lease.HasDurablePhysicalObjectIdentity); + Assert.Equal( + RegistrationPublicationMatchOutcome.Match, + lease.ProbeCurrentPublication()); + Assert.Equal( + "metadata generation", + await File.ReadAllTextAsync(lease.MetadataPath)); + } [LinuxFact] public async Task CreatePinnedPathOnly_PublicPathReplaced_KeepsOriginalMetadataHandleWithoutDurableAuthority() { diff --git a/tests/Features/Infrastructure/FileSystem/RootFolderStorageConfirmationServiceTests.cs b/tests/Features/Infrastructure/FileSystem/RootFolderStorageConfirmationServiceTests.cs index 73c136ae2..7978b60a4 100644 --- a/tests/Features/Infrastructure/FileSystem/RootFolderStorageConfirmationServiceTests.cs +++ b/tests/Features/Infrastructure/FileSystem/RootFolderStorageConfirmationServiceTests.cs @@ -186,13 +186,14 @@ public async Task ConfirmCurrentFolderAsync_ActiveRegistrationRecoveryUnderRoot_ var root = await fixture.LoadRootAsync(); var observation = await fixture.HealthResolver.ResolveAsync(root); - var exception = await Assert.ThrowsAsync(() => + var exception = await Assert.ThrowsAsync(() => fixture.Service.ConfirmCurrentFolderAsync( root.Id, root.Path, observation.ConfirmationToken!)); - Assert.Contains("file import", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("waiting for restart recovery", exception.Blocker.PublicReason, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(Guid.Empty, exception.Blocker.OperationId); var persisted = await fixture.LoadRootAsync(); Assert.Null(persisted.DirectoryObjectIdentity); } @@ -206,13 +207,14 @@ public async Task ConfirmCurrentFolderAsync_AnonymousRegistrationPublicationUnde var root = await fixture.LoadRootAsync(); var observation = await fixture.HealthResolver.ResolveAsync(root); - var exception = await Assert.ThrowsAsync(() => + var exception = await Assert.ThrowsAsync(() => fixture.Service.ConfirmCurrentFolderAsync( root.Id, root.Path, observation.ConfirmationToken!)); - Assert.Contains("file import", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("waiting for restart recovery", exception.Blocker.PublicReason, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(Guid.Empty, exception.Blocker.OperationId); var persisted = await fixture.LoadRootAsync(); Assert.Null(persisted.DirectoryObjectIdentity); } @@ -226,13 +228,14 @@ public async Task ConfirmCurrentFolderAsync_ActiveRegistrationRecoveryWithDevice var root = await fixture.LoadRootAsync(); var observation = await fixture.HealthResolver.ResolveAsync(root); - var exception = await Assert.ThrowsAsync(() => + var exception = await Assert.ThrowsAsync(() => fixture.Service.ConfirmCurrentFolderAsync( root.Id, root.Path, observation.ConfirmationToken!)); - Assert.Contains("file import", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("waiting for restart recovery", exception.Blocker.PublicReason, StringComparison.OrdinalIgnoreCase); + Assert.NotEqual(Guid.Empty, exception.Blocker.OperationId); var persisted = await fixture.LoadRootAsync(); Assert.Null(persisted.DirectoryObjectIdentity); } @@ -690,12 +693,14 @@ private async Task CreateFixtureAsync( var mutationCoordinator = new FilesystemMutationCoordinator(); var audiobookCoordinator = new AudiobookOperationCoordinator(); var identityResolver = new DirectoryObjectIdentityResolver(); + var recoveryProbe = new FileRegistrationRecoveryProbe(dbFactory); var service = new RootFolderStorageConfirmationService( dbFactory, new FileSystemSemanticsResolver(), moveQueue.Object, mutationCoordinator, - audiobookCoordinator); + audiobookCoordinator, + recoveryProbe); var healthResolver = new RootFolderStorageHealthResolver(identityResolver); var ownershipStore = new EfLibraryDirectoryOwnershipStore( dbFactory, diff --git a/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanWeakStorageTests.cs b/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanWeakStorageTests.cs new file mode 100644 index 000000000..89c214444 --- /dev/null +++ b/tests/Features/Infrastructure/Metadata/Jobs/MetadataRescanWeakStorageTests.cs @@ -0,0 +1,80 @@ +using Listenarr.Tests.Builders; +using Listenarr.Tests.Common; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Listenarr.Tests.Features.Infrastructure.Metadata.Jobs; + +[Trait("Name", "MetadataRescanWeakStorageTests")] +[Trait("Category", "Infrastructure")] +public sealed class MetadataRescanWeakStorageTests : BaseTests +{ + [NativeWeakStorageFact] + public async Task RunCycleAsync_WeakStorage_RefreshesMetadataWithoutGrantingPhysicalIdentity() + { + // Given a real weak mounted filesystem and a path-only scan registration. + var metadataService = new Mock(); + metadataService.Setup(service => service.ExtractFileMetadataAsync(It.IsAny())) + .ReturnsAsync(new AudioMetadata + { + Duration = TimeSpan.FromSeconds(222), + Format = "m4b", + SampleRate = 48000 + }); + Init(builder => builder.WithSingleton(metadataService.Object)); + var mountPath = Environment.GetEnvironmentVariable( + NativeStorageIdentityFactAttribute.PathEnvironmentVariable)!; + var folder = Path.Join(mountPath, "metadata-rescan-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(folder); + var path = Path.Join(folder, "book.m4b"); + try + { + await File.WriteAllTextAsync(path, "original metadata source"); + Assert.Throws(() => + PinnedAudiobookFileRegistrationLease.Open(path)); + var audiobook = await _audiobookRepository.AddAsync(new AudiobookBuilder() + .WithTitle("Weak Metadata Rescan") + .WithBasePath(folder) + .Build()); + var identity = await _provider.GetRequiredService() + .ResolveAsync(audiobook, path); + Assert.Equal(PathIdentityState.Valid, identity.State); + var pending = new AudiobookFileBuilder() + .WithAudiobook(audiobook) + .WithPath(path) + .Build(); + pending.ApplyPathIdentity(path, identity); + pending.ClearPhysicalObjectIdentity(); + var file = await _audiobookFileRepository.AddAsync(pending); + var processor = new MetadataRescanProcessor( + _provider.GetRequiredService(), + _provider.GetRequiredService(), + _provider.GetRequiredService(), + NullLogger.Instance); + + // When the real processor runs after path-only registration. + await processor.RunCycleAsync(CancellationToken.None); + + // Then only metadata changes; the read cannot enroll a physical generation. + var factory = _provider.GetRequiredService>(); + await using var verification = await factory.CreateDbContextAsync(); + var persisted = await verification.AudiobookFiles.SingleAsync(row => row.Id == file.Id); + Assert.Equal(222, persisted.DurationSeconds); + Assert.Equal("m4b", persisted.Format); + Assert.Equal(48000, persisted.SampleRate); + Assert.Equal(file.PathOwnershipKey, persisted.PathOwnershipKey); + Assert.Equal(file.PathIdentityLookupKey, persisted.PathIdentityLookupKey); + Assert.Null(persisted.PhysicalObjectIdentity); + Assert.Null(persisted.PhysicalIdentityObservedAtUtc); + Assert.Equal(file.PhysicalIdentityVersion, persisted.PhysicalIdentityVersion); + metadataService.Verify(service => service.ExtractFileMetadataAsync( + It.Is(source => source.ReadPath.StartsWith("/proc/"))), Times.Once); + Assert.Equal("original metadata source", await File.ReadAllTextAsync(path)); + } + finally + { + File.Delete(path); + Directory.Delete(folder); + } + } +} diff --git a/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs b/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs index 0af46cdcd..31b773069 100644 --- a/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs +++ b/tests/Features/Infrastructure/Persistence/EfAudiobookFileRepositoryBasePathRegistrationTests.cs @@ -47,6 +47,86 @@ public async Task ReplacePhysicalGenerationAsync_CancelledAtMutationCommand_Comm (await verification.AudiobookFiles.AsNoTracking().SingleAsync()).PhysicalObjectIdentity); } + [Fact] + public async Task RestorePhysicalGenerationAsync_RestoresRawPredecessorTimestamp() + { + await using var connection = await OpenDatabaseAsync(); + var options = CreateOptions(connection); + var boundary = Path.GetFullPath(Path.Join("library", "PhysicalGenerationRestore")); + var filePath = Path.Join(boundary, "Book.m4b"); + await SeedAudiobooksAsync(options, new Audiobook { Id = 1, Title = "Book", BasePath = boundary }); + AudiobookFile persisted; + await using (var seed = new ListenArrDbContext(options)) + { + persisted = CreateFile(1, filePath, boundary, "generation-two"); + seed.AudiobookFiles.Add(persisted); + await seed.SaveChangesAsync(); + } + var observedAt = DateTime.SpecifyKind( + new DateTime(2026, 9, 16, 12, 34, 56), + DateTimeKind.Unspecified); + var predecessor = new AudiobookFilePhysicalGenerationSnapshot( + 123, 45.5, "m4b", "mp4", "aac", 64_000, 44_100, 2, + "scan", "generation-one", 1, observedAt); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var restored = await repository.RestorePhysicalGenerationAsync( + persisted.Id, + 1, + filePath, + "generation-two", + predecessor); + + Assert.True(restored); + await using var verification = new ListenArrDbContext(options); + var file = await verification.AudiobookFiles.AsNoTracking().SingleAsync(); + Assert.Equal("generation-one", file.PhysicalObjectIdentity); + Assert.Equal(observedAt, file.PhysicalIdentityObservedAtUtc); + Assert.Equal(123, file.Size); + Assert.Equal("scan", file.Source); + } + + [Fact] + public async Task RestorePhysicalGenerationWithBasePathAsync_WrongCurrentGeneration_RollsBackBasePath() + { + await using var connection = await OpenDatabaseAsync(); + var options = CreateOptions(connection); + var originalBasePath = Path.GetFullPath(Path.Join("library", "RestoreOriginal")); + var destination = Path.GetFullPath(Path.Join("library", "RestoreDestination")); + var filePath = Path.Join(destination, "Book.m4b"); + await SeedAudiobooksAsync( + options, + new Audiobook { Id = 1, Title = "Book", BasePath = destination }); + AudiobookFile persisted; + await using (var seed = new ListenArrDbContext(options)) + { + persisted = CreateFile(1, filePath, destination, "replacement-generation"); + seed.AudiobookFiles.Add(persisted); + await seed.SaveChangesAsync(); + } + var predecessor = new AudiobookFilePhysicalGenerationSnapshot( + 123, null, null, null, null, null, null, null, null, + "generation-one", 1, DateTime.UtcNow); + await using var context = new ListenArrDbContext(options); + var repository = new EfAudiobookFileRepository(context); + + var restored = await repository.RestorePhysicalGenerationWithBasePathAsync( + persisted.Id, + 1, + filePath, + "wrong-generation", + predecessor, + new AudiobookBasePathMutation(1, destination, originalBasePath)); + + Assert.False(restored); + await using var verification = new ListenArrDbContext(options); + Assert.Equal(destination, (await verification.Audiobooks.AsNoTracking().SingleAsync()).BasePath); + Assert.Equal( + "replacement-generation", + (await verification.AudiobookFiles.AsNoTracking().SingleAsync()).PhysicalObjectIdentity); + } + [Fact] public async Task DeletePhysicalGenerationAsync_CancelledAtMutationCommand_CommitsUnambiguously() { diff --git a/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProbeTests.cs b/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProbeTests.cs index 93f563fda..5ba8ae7a8 100644 --- a/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProbeTests.cs +++ b/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryProbeTests.cs @@ -7,6 +7,42 @@ namespace Listenarr.Tests.Features.Infrastructure.Persistence; [Trait("Category", "Infrastructure")] public sealed class FileRegistrationRecoveryProbeTests : BaseTests { + [Theory] + [InlineData(FileAction.Move)] + [InlineData(FileAction.Copy)] + [InlineData(FileAction.HardlinkCopy)] + public async Task HasBlockingAsync_AllRegistrationPublicationActions_Block( + FileAction action) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using (var db = new ListenArrDbContext(options)) + { + db.FileMutationJournals.Add(new FileMutationJournal + { + OperationId = Guid.NewGuid(), + ProtocolVersion = FileMutationProtocol.Current, + Action = action, + SourcePath = Path.Join(Path.GetTempPath(), "incoming.m4b"), + DestinationPath = Path.Join(Path.GetTempPath(), "library.m4b"), + SourceParentDirectoryObjectIdentity = "source-parent", + DestinationParentDirectoryObjectIdentity = "destination-parent", + SourcePhysicalObjectIdentity = "source-generation", + TargetPhysicalObjectIdentity = "target-generation", + SourceLength = 5, + State = FileMutationJournalState.RegistrationCommitted, + AudiobookId = 42, + AudiobookFileId = null + }); + await db.SaveChangesAsync(); + } + + var probe = new FileRegistrationRecoveryProbe(new TestDbFactory(options)); + + Assert.True(await probe.HasBlockingAsync(42)); + } + [Fact] public async Task HasBlockingBoundaryAsync_AnonymousVerifiedPublicationUnderBoundary_Blocks() { @@ -42,13 +78,22 @@ public async Task HasBlockingBoundaryAsync_AnonymousVerifiedPublicationUnderBoun var probe = new FileRegistrationRecoveryProbe(new TestDbFactory(options)); - Assert.True(await probe.HasBlockingBoundaryAsync( + var blockers = await probe.GetBlockingBoundaryAsync( root, - FileSystemPathSemantics.CurrentHostDefault)); + FileSystemPathSemantics.CurrentHostDefault); + + var blocker = Assert.Single(blockers); + Assert.Equal(FileMutationJournalState.TargetVerified, blocker.JournalState); + Assert.Equal(FileRegistrationRecoveryDisposition.AutomaticRecovery, blocker.Recoverability); + Assert.False(blocker.SourceTouchesBoundary); + Assert.True(blocker.DestinationTouchesBoundary); } - [Fact] - public async Task HasBlockingBoundaryAsync_CompletedAnonymousPublication_DoesNotBlock() + [Theory] + [InlineData(FileMutationJournalState.Completed)] + [InlineData(FileMutationJournalState.RolledBack)] + public async Task HasBlockingBoundaryAsync_TerminalAnonymousPublication_DoesNotBlock( + FileMutationJournalState state) { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) @@ -72,7 +117,7 @@ public async Task HasBlockingBoundaryAsync_CompletedAnonymousPublication_DoesNot SourcePhysicalObjectIdentity = "source-generation", TargetPhysicalObjectIdentity = "target-generation", SourceLength = 5, - State = FileMutationJournalState.Completed, + State = state, AudiobookId = 42, AudiobookFileId = null }); diff --git a/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryServiceTests.cs b/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryServiceTests.cs index 2a1554932..fc75e6bb8 100644 --- a/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryServiceTests.cs +++ b/tests/Features/Infrastructure/Persistence/FileRegistrationRecoveryServiceTests.cs @@ -305,7 +305,7 @@ public async Task ReconcileAudiobookAsync_UnrelatedAmbiguousAnonymousMoves_DoNot } [WindowsFact] - public async Task ReconcileAsync_MultipleAnonymousMovesShareCommittedTargetGeneration_FailsClosedWithoutRetiringSources() + public async Task ReconcileAsync_MultipleAnonymousMovesShareCommittedTargetGeneration_MarksAttentionWithoutRetiringSources() { var root = FileService.GetTempDirectory("registration-ambiguous-adoption"); await AddAuthorizedRootAsync(root); @@ -372,7 +372,7 @@ public async Task ReconcileAsync_MultipleAnonymousMovesShareCommittedTargetGener var exception = await Assert.ThrowsAsync(() => recovery.ReconcileAsync()); - Assert.Contains("shares its published target generation", exception.Message); + Assert.Contains("requires operator repair", exception.Message); Assert.True(File.Exists(firstSource)); Assert.True(File.Exists(secondSource)); await using var db = await factory.CreateDbContextAsync(); @@ -384,11 +384,15 @@ public async Task ReconcileAsync_MultipleAnonymousMovesShareCommittedTargetGener Assert.Equal(2, journals.Count); Assert.All(journals, journal => Assert.Null(journal.AudiobookId)); Assert.All(journals, journal => - Assert.Equal(FileMutationJournalState.TargetVerified, journal.State)); + Assert.Equal(FileMutationJournalState.NeedsAttention, journal.State)); + Assert.All(journals, journal => Assert.Contains( + "same published target generation", + journal.Error, + StringComparison.OrdinalIgnoreCase)); } [WindowsFact] - public async Task ReconcileAsync_AnonymousVerifiedMoveWithoutCommittedTrackedGeneration_RemainsRetryable() + public async Task ReconcileAsync_AnonymousVerifiedMoveWithoutDurableOwner_RollsBackExactTarget() { var root = FileService.GetTempDirectory("registration-anonymous-retry"); await AddAuthorizedRootAsync(root); @@ -429,14 +433,15 @@ public async Task ReconcileAsync_AnonymousVerifiedMoveWithoutCommittedTrackedGen .ReconcileAsync(); Assert.True(File.Exists(source)); - Assert.Equal("audio", await File.ReadAllTextAsync(destination)); + Assert.False(File.Exists(destination)); await using var db = await factory.CreateDbContextAsync(); var anonymous = await db.FileMutationJournals .AsNoTracking() .SingleAsync(candidate => candidate.OperationId == operationId); - Assert.Equal(FileMutationJournalState.TargetVerified, anonymous.State); + Assert.Equal(FileMutationJournalState.RolledBack, anonymous.State); Assert.Null(anonymous.AudiobookId); Assert.Null(anonymous.AudiobookFileId); + Assert.Contains("retained its source", anonymous.Error, StringComparison.OrdinalIgnoreCase); } [WindowsFact] From 2972b44d41230f5cbcec9adfea7f354bc0611156 Mon Sep 17 00:00:00 2001 From: Robbie Davis Date: Thu, 17 Sep 2026 14:12:25 -0400 Subject: [PATCH 6/6] fix: keep weak rename identities on verified path --- .../RenameService.VerifiedExecution.cs | 2 + ...FileRegistrationRecoveryService.Orphans.cs | 12 +-- ...ileRegistrationRecoveryService.Protocol.cs | 12 +-- .../Audiobooks/Renaming/RenameServiceTests.cs | 98 +++++++++++++++++++ 4 files changed, 112 insertions(+), 12 deletions(-) diff --git a/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs b/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs index 6238ff2b2..206a134c9 100644 --- a/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs +++ b/listenarr.application/Audiobooks/Renaming/RenameService.VerifiedExecution.cs @@ -79,6 +79,8 @@ private async Task BuildRenameExecutionPlanAsync( proof.HasDurablePhysicalObjectIdentity && (databaseFile == null || !string.IsNullOrWhiteSpace( + databaseFile.PhysicalObjectIdentity) + && !PhysicalObjectIdentitySafety.IsKnownWeak( databaseFile.PhysicalObjectIdentity)); } diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs index 4cfe060e1..110ab5cbe 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Orphans.cs @@ -20,10 +20,10 @@ private async Task AdoptCommittedAnonymousPublicationsAsync( && journal.AudiobookId == null && journal.AudiobookFileId == null && journal.State == FileMutationJournalState.TargetVerified); - if (operationId.HasValue) + if (operationId is Guid scopedOperationId) { anonymousQuery = anonymousQuery.Where( - journal => journal.OperationId == operationId.Value); + journal => journal.OperationId == scopedOperationId); } var anonymousJournals = await anonymousQuery .OrderBy(journal => journal.CreatedAt) @@ -40,9 +40,9 @@ private async Task AdoptCommittedAnonymousPublicationsAsync( .ToListAsync(cancellationToken); var filesQuery = db.AudiobookFiles.AsNoTracking(); - if (audiobookId.HasValue) + if (audiobookId is int scopedAudiobookId) { - filesQuery = filesQuery.Where(file => file.AudiobookId == audiobookId.Value); + filesQuery = filesQuery.Where(file => file.AudiobookId == scopedAudiobookId); } var trackedFiles = await filesQuery.ToListAsync(cancellationToken); foreach (var journal in anonymousJournals) @@ -164,10 +164,10 @@ private async Task ReconcileOrphanedAnonymousPublicationsAsync( && journal.State != FileMutationJournalState.Completed && journal.State != FileMutationJournalState.RolledBack && journal.State != FileMutationJournalState.NeedsAttention); - if (operationId.HasValue) + if (operationId is Guid scopedOperationId) { journalQuery = journalQuery.Where( - journal => journal.OperationId == operationId.Value); + journal => journal.OperationId == scopedOperationId); } var journals = await journalQuery .OrderBy(journal => journal.CreatedAt) diff --git a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs index 45243ee44..b17bc6e85 100644 --- a/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs +++ b/listenarr.infrastructure/Persistence/FileRegistrationRecoveryService.Protocol.cs @@ -16,10 +16,10 @@ private async Task EnsureCurrentRecoveryProtocolAsync( && journal.State != FileMutationJournalState.Completed && journal.State != FileMutationJournalState.RolledBack && journal.State != FileMutationJournalState.OwnerMetadataReconciled); - if (operationId.HasValue) + if (operationId is Guid scopedOperationId) { unsupportedQuery = unsupportedQuery.Where( - journal => journal.OperationId == operationId.Value); + journal => journal.OperationId == scopedOperationId); } var unsupported = await unsupportedQuery .OrderBy(journal => journal.CreatedAt) @@ -47,10 +47,10 @@ private async Task EnsureCurrentRecoveryProtocolAsync( && journal.State != FileMutationJournalState.RolledBack && journal.State != FileMutationJournalState.OwnerMetadataReconciled && journal.State != FileMutationJournalState.NeedsAttention); - if (operationId.HasValue) + if (operationId is Guid trackedOperationId) { trackedQuery = trackedQuery.Where( - journal => journal.OperationId == operationId.Value); + journal => journal.OperationId == trackedOperationId); } var tracked = await trackedQuery .ToListAsync(cancellationToken); @@ -71,10 +71,10 @@ private async Task EnsureCurrentRecoveryProtocolAsync( && journal.State != FileMutationJournalState.RolledBack && journal.State != FileMutationJournalState.OwnerMetadataReconciled && journal.State != FileMutationJournalState.NeedsAttention); - if (operationId.HasValue) + if (operationId is Guid relationalOperationId) { trackedQuery = trackedQuery.Where( - journal => journal.OperationId == operationId.Value); + journal => journal.OperationId == relationalOperationId); } await trackedQuery .ExecuteUpdateAsync( diff --git a/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs b/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs index 380f5c1b6..b1153ff1b 100644 --- a/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs +++ b/tests/Features/Application/Audiobooks/Renaming/RenameServiceTests.cs @@ -1246,6 +1246,104 @@ public async Task ExecuteRename_ContentOnlySource_UsesVerifiedProtocolAndClearsP coordinator.VerifyAll(); } + [Fact] + public async Task ExecuteRename_DurableLiveProofWithPersistedWeakIdentity_UsesVerifiedProtocol() + { + const string legacyWeakIdentity = + "linux:00000008:00000001:0000000000000001:0000000000000001:00000001"; + Assert.True(PhysicalObjectIdentitySafety.IsKnownWeak(legacyWeakIdentity)); + + var libraryRoot = Path.Join(_tempRoot, "verified-organize-legacy-weak"); + var sourceFolder = Path.Join(libraryRoot, "Old"); + var targetFolder = Path.Join(libraryRoot, "Author", "Book"); + var sourcePath = Path.Join(sourceFolder, "old-name.m4b"); + var targetPath = Path.Join(targetFolder, "Book.m4b"); + Directory.CreateDirectory(sourceFolder); + await File.WriteAllTextAsync(sourcePath, "legacy-weak-audio"); + + var coordinator = new Mock(MockBehavior.Strict); + coordinator.Setup(candidate => candidate.PrepareAsync( + sourcePath, + targetPath, + It.IsAny(), + It.IsAny(), + It.IsAny(), + 45, + 451, + It.Is(proof => proof.HasDurablePhysicalObjectIdentity), + It.IsAny())) + .Returns( + (_, _, operationId, _, _, _, _, _, _) => + { + Directory.CreateDirectory(targetFolder); + File.Copy(sourcePath, targetPath, overwrite: false); + return Task.FromResult(new VerifiedFileRenamePreparationResult( + true, + new TestVerifiedRenameLease( + operationId, + rollBack: () => + { + File.Delete(targetPath); + return true; + }, + complete: () => + { + File.Delete(sourcePath); + return VerifiedFileRenameRetirementOutcome.Completed; + }))); + }); + + var (service, db, dbName) = BuildService( + new ApplicationSettings + { + OutputPath = libraryRoot, + FolderNamingPattern = "{Author}/{Title}", + FileNamingPattern = "{Title}" + }, + verifiedFileRenameTransactionCoordinatorOverride: coordinator.Object); + var trackedFile = CreateTrackedFile(451, 45, sourcePath); + trackedFile.ApplyPhysicalObjectIdentity(legacyWeakIdentity, DateTime.UtcNow); + db.Audiobooks.Add(new Audiobook + { + Id = 45, + Title = "Book", + Authors = ["Author"], + BasePath = sourceFolder, + FilePath = sourcePath, + Files = [trackedFile] + }); + await db.SaveChangesAsync(); + + var result = Assert.Single(await service.ExecuteRenameAsync( + [ + new RenameOperation + { + AudiobookId = 45, + CurrentFolderPath = sourceFolder, + CurrentFolderSemantics = ExpectedSemantics(sourceFolder), + NewFolderPath = targetFolder, + FileRenames = + [ + new FileRenameOperation + { + FileId = 451, + CurrentPath = sourcePath, + NewPath = targetPath + } + ] + } + ])); + + Assert.True(result.Success, result.Error); + Assert.False(File.Exists(sourcePath)); + Assert.True(File.Exists(targetPath)); + await using var verifyDb = CreateContext(dbName); + var saved = await verifyDb.Audiobooks + .Include(candidate => candidate.Files) + .SingleAsync(candidate => candidate.Id == 45); + Assert.Null(Assert.Single(saved.Files!).PhysicalObjectIdentity); + coordinator.VerifyAll(); + } [Fact] public async Task ExecuteRename_VerifiedRetirementNeedsAttention_ReportsFailureAfterOwnerCommit() {